Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions moli-bounded-buffer/src/buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ where
self.limits
}

/// Changes budgets, returning oversized and then oldest entries that no
/// longer fit. Retained entries keep their original insertion order.
pub fn set_limits(&mut self, limits: ByteLimits) -> Vec<(K, V)> {
self.limits = limits;
let mut evicted = self.retain_entries(|_, entry| entry.byte_len <= limits.max_entry_bytes);
while self.used_bytes > limits.max_total_bytes {
if let Some(entry) = self.pop_oldest() {
evicted.push(entry);
} else {
break;
}
}
evicted
}

/// Returns the sum of the logical byte charges for retained entries.
pub fn used_bytes(&self) -> usize {
self.used_bytes
Expand Down Expand Up @@ -151,12 +166,43 @@ where
}

/// Removes all entries and releases all byte charges.
///
/// This does not return removed entries; use [`Self::retain`] to take ownership.
pub fn clear(&mut self) {
self.entries.clear();
self.insertion_order.clear();
self.used_bytes = 0;
}

/// Visits each value once in insertion order, returning removed entries in
/// that order and releasing their byte charges. Mutating a value does not
/// change its recorded charge; use [`Self::insert`] to replace its charge.
pub fn retain(&mut self, mut keep: impl FnMut(&K, &mut V) -> bool) -> Vec<(K, V)> {
self.retain_entries(|key, entry| keep(key, &mut entry.value))
}

fn retain_entries(
&mut self,
mut keep: impl FnMut(&K, &mut BufferedValue<V>) -> bool,
) -> Vec<(K, V)> {
let mut removed = Vec::new();
self.insertion_order.retain(|key| {
if self
.entries
.get_mut(key)
.is_some_and(|entry| keep(key, entry))
{
return true;
}
if let Some(entry) = self.entries.remove(key) {
self.used_bytes -= entry.byte_len;
removed.push((key.clone(), entry.value));
}
false
});
removed
}

fn pop_oldest(&mut self) -> Option<(K, V)> {
while let Some(key) = self.insertion_order.pop_front() {
let Some(entry) = self.entries.remove(&key) else {
Expand Down
73 changes: 73 additions & 0 deletions moli-bounded-buffer/src/tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,44 @@
use super::{BoundedByteBuffer, ByteLimits, InsertOutcome};

#[test]
fn shrinking_entry_limit_keeps_interleaved_survivors_in_fifo_order() {
let mut buffer = BoundedByteBuffer::new(ByteLimits::new(100, 20));
for (key, bytes) in [(0, 10), (1, 1), (2, 10), (3, 1), (4, 10)] {
buffer.insert(key, key, bytes);
}
assert_eq!(
buffer.set_limits(ByteLimits::new(100, 1)),
vec![(0, 0), (2, 2), (4, 4)]
);
assert_eq!(buffer.used_bytes(), 2);
assert_eq!(buffer.set_limits(ByteLimits::new(1, 1)), vec![(1, 1)]);
assert_eq!(buffer.get(&3), Some(&3));
assert_eq!(buffer.len(), 1);
}

#[test]
fn changing_limits_evicts_oversized_then_oldest_without_reordering_survivors() {
let mut buffer = BoundedByteBuffer::new(ByteLimits::new(20, 10));
buffer.insert("a", 1, 3);
buffer.insert("b", 2, 6);
buffer.insert("c", 3, 3);
assert_eq!(
buffer.set_limits(ByteLimits::new(4, 4)),
vec![("b", 2), ("a", 1)]
);
assert_eq!(buffer.used_bytes(), 3);
assert_eq!(buffer.get(&"c"), Some(&3));
assert!(buffer.set_limits(ByteLimits::new(10, 10)).is_empty());
assert_eq!(
buffer.insert("d", 4, 8),
InsertOutcome::Stored {
evicted: vec![("c", 3)]
}
);
assert_eq!(buffer.set_limits(ByteLimits::new(0, 0)), vec![("d", 4)]);
assert!(buffer.is_empty());
}

#[test]
fn accepts_entries_at_exact_limits() {
let mut buffer = BoundedByteBuffer::new(ByteLimits::new(4, 4));
Expand Down Expand Up @@ -115,3 +154,37 @@ fn remove_and_clear_return_all_byte_charges() {
assert!(buffer.is_empty());
assert_eq!(buffer.used_bytes(), 0);
}

#[test]
fn retain_visits_once_preserves_fifo_and_releases_only_removed_charges() {
let mut buffer = BoundedByteBuffer::new(ByteLimits::new(10, 10));
for key in 0..4 {
let _ = buffer.insert(key, key * 10, 2);
}
let mut visited = Vec::new();
let removed = buffer.retain(|key, value| {
visited.push(*key);
*value += 1;
key % 2 == 0
});
assert_eq!(visited, vec![0, 1, 2, 3]);
assert_eq!(removed, vec![(1, 11), (3, 31)]);
assert_eq!(buffer.used_bytes(), 4);
assert_eq!(buffer.get(&2), Some(&21));
assert_eq!(
buffer.insert(4, 40, 8),
InsertOutcome::Stored {
evicted: vec![(0, 1)]
}
);
assert!(buffer.retain(|_, _| true).is_empty());
assert_eq!(buffer.used_bytes(), 10);
assert_eq!(buffer.retain(|_, _| false), vec![(2, 21), (4, 40)]);
assert_eq!(buffer.used_bytes(), 0);
assert!(buffer.is_empty());
assert!(
buffer
.retain(|_, _| panic!("empty buffer must not visit"))
.is_empty()
);
}
3 changes: 2 additions & 1 deletion moli-fetch/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ pub use request::{
RequestAuthScheme, RequestAuthTarget, RequestCacheMode, RequestCredentialsMode,
RequestHeaderOverride, RequestMode, RequestPriorityHints, RequestRedirectMode,
RequestResourceType, ResourceLoadPriority, ScriptFetchRequestMetadata,
ScriptFetchSchedulerPriority, SubresourceRequestMetadata,
ScriptFetchSchedulerPriority, SubresourceRequestMetadata, is_request_body_header_name,
redirect_status_rewrites_to_get,
};
pub use request_policy::{is_bad_port, should_request_be_blocked_due_to_bad_port};
pub use response::{
Expand Down
34 changes: 33 additions & 1 deletion moli-fetch/src/network_fetch_result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const MAX_OBSERVED_HEADER_BLOCK_BYTES: usize = 256 * 1024;
/// Request headers observed after the transport has finalized an HTTP exchange.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NetworkRequestObservation {
method: Option<String>,
headers: Vec<(String, String)>,
cookie_report: Option<StoredCookieQueryReport>,
truncated: bool,
Expand All @@ -24,6 +25,16 @@ pub struct NetworkRequestObservation {
impl NetworkRequestObservation {
pub fn new(headers: Vec<(String, String)>) -> Self {
Self {
method: None,
headers,
cookie_report: None,
truncated: false,
}
}

pub fn new_with_method(method: impl Into<String>, headers: Vec<(String, String)>) -> Self {
Self {
method: Some(method.into()),
headers,
cookie_report: None,
truncated: false,
Expand All @@ -33,12 +44,17 @@ impl NetworkRequestObservation {
fn from_header_block(data: &[u8], cookie_report: Option<StoredCookieQueryReport>) -> Self {
let (data, truncated) = bounded_header_data(data);
Self {
method: parse_request_method(data),
headers: parse_request_header_block(data),
cookie_report,
truncated,
}
}

pub fn method(&self) -> Option<&str> {
self.method.as_deref()
}

pub fn headers(&self) -> &[(String, String)] {
&self.headers
}
Expand Down Expand Up @@ -340,6 +356,14 @@ fn parse_request_header_block(data: &[u8]) -> Vec<(String, String)> {
.collect()
}

fn parse_request_method(data: &[u8]) -> Option<String> {
let line = String::from_utf8_lossy(data);
let line = line.lines().next()?.trim_end_matches('\r');
is_http_request_line(line)
.then(|| line.split_whitespace().next().map(str::to_owned))
.flatten()
}

fn is_http_request_line(line: &str) -> bool {
let mut fields = line.split_whitespace();
let (Some(method), Some(target), Some(version), None) =
Expand Down Expand Up @@ -706,7 +730,7 @@ mod tests {
fn recorder_preserves_redirect_exchange_order_and_raw_response_status() {
let recorder = NetworkObservationRecorder::default();
recorder.record_request_header_block(
b"GET /start HTTP/1.1\r\nHost: example.test\r\nAccept-Encoding: gzip\r\n\r\n",
b"POST /start HTTP/1.1\r\nHost: example.test\r\nAccept-Encoding: gzip\r\n\r\n",
);
recorder.record_response_header_line(b"HTTP/1.1 302 Found\r\n");
recorder.record_response_header_line(b"Location: /final\r\n");
Expand All @@ -720,6 +744,7 @@ mod tests {

let journal = recorder.snapshot();
assert_eq!(journal.exchanges().len(), 2);
assert_eq!(journal.exchanges()[0].request().method(), Some("POST"));
assert_eq!(
journal.exchanges()[0].request().headers(),
[
Expand All @@ -734,6 +759,13 @@ mod tests {
.status(),
302
);
assert_eq!(
journal
.final_request_observation()
.expect("final request")
.method(),
Some("GET")
);
assert_eq!(
journal
.final_request_observation()
Expand Down
6 changes: 4 additions & 2 deletions moli-fetch/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1122,12 +1122,14 @@ impl Request {
}
}

fn redirect_status_rewrites_to_get(status: u16, method: &str) -> bool {
/// Whether following this HTTP redirect replaces the request method with GET.
pub fn redirect_status_rewrites_to_get(status: u16, method: &str) -> bool {
status == 303 && !method.eq_ignore_ascii_case("GET") && !method.eq_ignore_ascii_case("HEAD")
|| matches!(status, 301 | 302) && method.eq_ignore_ascii_case("POST")
}

fn is_request_body_header_name(name: &str) -> bool {
/// Whether a header must be removed when a redirect discards the request body.
pub fn is_request_body_header_name(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"content-encoding"
Expand Down
8 changes: 8 additions & 0 deletions moli-page-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3287,6 +3287,14 @@ pub struct ChildFrameDocumentNetworkSnapshot {
pub request_method: String,
#[serde(default)]
pub request_headers: Vec<(String, String)>,
/// Exact in-process transport observations for the actual child-navigation
/// hops. Serialized snapshots retain their historical lightweight shape.
#[serde(skip)]
pub network_observation_journal: moli_fetch::NetworkObservationJournal,
/// In-process browser-facing redirect metadata paired with the transport
/// journal. Serialized snapshots retain their historical lightweight shape.
#[serde(skip)]
pub redirect_chain: Vec<moli_fetch::RedirectInfo>,
pub final_url: String,
pub status: u16,
#[serde(default)]
Expand Down
8 changes: 8 additions & 0 deletions moli-protocol/src/conn/browser_context/network_owner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,14 @@ impl TargetNetworkListenerOwnerMut<'_> {
}
self.remove_network_observation_cursor(listener_session_id.as_deref());
self.remove_captured_response_body_visibility_for_session(listener_session_id.as_deref());
if !self.is_attached_session()
&& let Some(primary_session_id) = self.target.session_id().map(str::to_owned)
{
// Primary commands share one listener whether routed on the root
// channel or its flattened session. Captured events can carry the
// explicit primary ID, so revoke both wire identities on disable.
self.remove_captured_response_body_visibility_for_session(Some(&primary_session_id));
}
self.clear_network_observation_artifacts_if_unobserved();
true
}
Expand Down
2 changes: 1 addition & 1 deletion moli-protocol/src/conn/page_state/fetch_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ impl PageTargetHost {
needs_fetch_navigation_request_id: bool,
) -> (String, Option<String>, Option<String>) {
if clear_captured_response_bodies {
self.runtime_slot.clear_captured_response_bodies();
self.runtime_slot.prepare_response_bodies_for_navigation();
}
let mut allocator = self.runtime_slot.request_id_allocator();
let document_loader_id =
Expand Down
13 changes: 13 additions & 0 deletions moli-protocol/src/conn/state/runtime_slot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,19 @@ impl TargetRuntimeSlot {
self.network_agent.clear_captured_response_bodies();
}

pub(crate) fn configure_durable_response_bodies(
&mut self,
session_id: Option<&str>,
limits: Option<moli_bounded_buffer::ByteLimits>,
) {
self.network_agent
.configure_durable_response_bodies(session_id, limits);
}

pub(crate) fn prepare_response_bodies_for_navigation(&mut self) {
self.network_agent.prepare_response_bodies_for_navigation();
}

pub(crate) fn clear_network_body_artifacts(&mut self) {
self.network_agent.clear_body_artifacts();
}
Expand Down
13 changes: 13 additions & 0 deletions moli-protocol/src/domains/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ mod load_resource;
mod main_document_progress;
mod output;
mod output_queue;
mod redirect_request;
mod response_body;
pub(crate) mod settings;
#[cfg(test)]
Expand Down Expand Up @@ -504,6 +505,14 @@ fn start_set_network_domain_enabled_command(
cmd: &Cmd<'_>,
enabled: bool,
) -> NetworkCommandTaskStep {
let durable_limits = if enabled {
match settings::durable_body_limits(cmd) {
Ok(limits) => limits,
Err(plan) => return NetworkCommandTaskStep::Complete(plan),
}
} else {
None
};
let updated = if enabled {
conn.enable_network_listener_for_session_owner(cmd.session_id)
} else {
Expand All @@ -516,6 +525,10 @@ fn start_set_network_domain_enabled_command(
));
}

if enabled && let Ok(slot) = conn.runtime_session_owner_slot_mut(cmd.session_id) {
slot.configure_durable_response_bodies(cmd.session_id, durable_limits);
}

let kind = if enabled {
PendingNetworkCommandKind::Enable
} else {
Expand Down
Loading
Loading