diff --git a/moli-bounded-buffer/src/buffer.rs b/moli-bounded-buffer/src/buffer.rs index d5a415e6e7..ed0fa4ed35 100644 --- a/moli-bounded-buffer/src/buffer.rs +++ b/moli-bounded-buffer/src/buffer.rs @@ -70,22 +70,23 @@ where /// longer fit. Retained entries keep their original insertion order. pub fn set_limits(&mut self, limits: ByteLimits) -> Vec<(K, V)> { self.limits = limits; - let oversized = self - .insertion_order - .iter() - .filter(|key| { - self.entries - .get(*key) - .is_some_and(|entry| entry.byte_len > limits.max_entry_bytes) - }) - .cloned() - .collect::>(); let mut evicted = Vec::new(); - for key in oversized { - if let Some(value) = self.remove(&key) { - evicted.push((key, value)); + // Filter the order once. Calling remove for every oversized entry + // would rescan the whole queue on each removal when budgets shrink. + self.insertion_order.retain(|key| { + if self + .entries + .get(key) + .is_none_or(|entry| entry.byte_len <= limits.max_entry_bytes) + { + return true; } - } + if let Some(entry) = self.entries.remove(key) { + self.used_bytes -= entry.byte_len; + evicted.push((key.clone(), entry.value)); + } + false + }); while self.used_bytes > limits.max_total_bytes { if let Some(entry) = self.pop_oldest() { evicted.push(entry); diff --git a/moli-bounded-buffer/src/tests.rs b/moli-bounded-buffer/src/tests.rs index d4558052ce..0f3b57d566 100644 --- a/moli-bounded-buffer/src/tests.rs +++ b/moli-bounded-buffer/src/tests.rs @@ -1,5 +1,21 @@ 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)); diff --git a/moli-layout/src/inline.rs b/moli-layout/src/inline.rs index 785126243c..eb5c0a8132 100644 --- a/moli-layout/src/inline.rs +++ b/moli-layout/src/inline.rs @@ -573,14 +573,39 @@ pub(crate) fn build_inline_fragments( .iter() .map(text_style_paint_outsets) .collect::>(); + // Inline objects occupy positions between text units, not text bytes. + // Retain their logical offsets so spaces before an atomic box cannot be + // mistaken for line-end whitespace (including in bidi-reordered lines). + let atomic_offsets: HashMap<_, _> = layout + .inline_boxes() + .filter(|inline_box| { + context + .object(inline_box.id) + .is_some_and(|object| object.role == InlineObjectRole::Atomic) + }) + .map(|inline_box| (inline_box.id, inline_box.index)) + .collect(); for (line_index, line) in layout.lines().enumerate() { let metrics = line.metrics(); let line_range = line.text_range(); + let last_atomic_offset = line + .items() + .filter_map(|item| match item { + PositionedLayoutItem::InlineBox(positioned) => { + atomic_offsets.get(&positioned.id).copied() + } + PositionedLayoutItem::GlyphRun(_) => None, + }) + .max() + .unwrap_or(line_range.start); let trailing_start = overlapping_output_ranges(&context.text_units, &line_range) .iter() .rev() - .take_while(|unit| unit.control || unit.collapsible_whitespace) + .take_while(|unit| { + unit.output_range.start >= last_atomic_offset + && (unit.control || unit.collapsible_whitespace) + }) .filter(|unit| unit.collapsible_whitespace) .map(|unit| unit.output_range.start) .last(); diff --git a/moli-layout/src/projection.rs b/moli-layout/src/projection.rs index e563082330..63c8407167 100644 --- a/moli-layout/src/projection.rs +++ b/moli-layout/src/projection.rs @@ -773,37 +773,7 @@ where let bottom = last.final_layout.location.y - last_offset.y + last.final_layout.size.height; let rect = LayoutRect::new(content.x, top, content.width, (bottom - top).max(0.0)); - // A zero-area leading continuation follows escaped leading - // margins into the first block's flow position. It carries no - // line strut of its own and must not remain at the pre-collapse y. - for id in &self.boxes[run.preceding_inline.index()].fragments { - let existing = &mut self.fragments[id.index()]; - if matches!(existing.kind, LayoutFragmentKind::InlineBox { .. }) - && existing.rect.width == 0.0 - && existing.rect.height == 0.0 - { - let original = self.coordinate_spaces[existing.coordinate_space.index()] - .layout - .local_to_document_ignoring_css_transforms - .map_point(LayoutPoint::new(existing.rect.x, existing.rect.y)); - let x = self.coordinate_spaces[parent.index() + 1] - .layout - .local_to_document_ignoring_css_transforms - .inverse() - .map(|inverse| inverse.map_point(original).x) - .unwrap_or(existing.rect.x); - let boundary = LayoutRect::new(x, top, 0.0, 0.0); - existing.rect = boundary; - existing.coordinate_space = - LayoutCoordinateSpaceId::from_index(parent.index() + 1); - existing.box_model = Some(LayoutFragmentBoxModel { - content: boundary, - padding: boundary, - border: boundary, - margin: boundary, - }); - } - } + self.reposition_empty_inline_continuation(run.preceding_inline, parent, top); let fragment = self.push_fragment(LayoutFragment { id: LayoutFragmentId::from_index(0), kind: LayoutFragmentKind::Box { @@ -824,6 +794,46 @@ where } } + /// An empty leading continuation follows escaped margins into the first + /// block's flow position; it carries no independent line strut. + fn reposition_empty_inline_continuation( + &mut self, + inline: LayoutBoxId, + parent: LayoutBoxId, + top: f32, + ) { + let coordinate_space = LayoutCoordinateSpaceId::from_index(parent.index() + 1); + let document_to_parent = self.coordinate_spaces[coordinate_space.index()] + .layout + .local_to_document_ignoring_css_transforms + .inverse(); + for id in &self.boxes[inline.index()].fragments { + let fragment = &mut self.fragments[id.index()]; + if !matches!(fragment.kind, LayoutFragmentKind::InlineBox { .. }) + || fragment.rect.width != 0.0 + || fragment.rect.height != 0.0 + { + continue; + } + let original = self.coordinate_spaces[fragment.coordinate_space.index()] + .layout + .local_to_document_ignoring_css_transforms + .map_point(LayoutPoint::new(fragment.rect.x, fragment.rect.y)); + let x = document_to_parent + .map(|inverse| inverse.map_point(original).x) + .unwrap_or(fragment.rect.x); + let boundary = LayoutRect::new(x, top, 0.0, 0.0); + fragment.rect = boundary; + fragment.coordinate_space = coordinate_space; + fragment.box_model = Some(LayoutFragmentBoxModel { + content: boundary, + padding: boundary, + border: boundary, + margin: boundary, + }); + } + } + fn split_block_relative_inset_basis(&self, parent: LayoutBoxId) -> taffy::Size> { let content = self.boxes[parent.index()].content_box; let Some(inputs) = self diff --git a/moli-layout/src/taffy_tree.rs b/moli-layout/src/taffy_tree.rs index 8163fd3530..1a3aaf8e35 100644 --- a/moli-layout/src/taffy_tree.rs +++ b/moli-layout/src/taffy_tree.rs @@ -103,45 +103,44 @@ impl PreparedWorldLayout { N: Copy + Debug + Eq + Hash, { assert!(self.feedback_invalidation_worklist.is_empty()); + if viewport_changed { + for layout_box in &mut world.boxes { + layout_box.clear_layout_caches(); + } + world.viewport_layout.cache.clear(); + return world.boxes.len(); + } + if changed_boxes.is_empty() { + return 0; + } self.feedback_invalidation_marks .resize(world.boxes.len(), false); let mut invalidated = Vec::new(); - if viewport_changed { - for index in 0..world.boxes.len() { - let id = LayoutBoxId::from_index(index); - self.feedback_invalidation_marks[index] = true; - invalidated.push(id); - world.boxes[index].clear_layout_caches(); + self.feedback_invalidation_worklist + .extend(changed_boxes.iter().copied()); + while let Some(id) = self.feedback_invalidation_worklist.pop() { + if self.feedback_invalidation_marks[id.index()] { + continue; } - } else { + self.feedback_invalidation_marks[id.index()] = true; + invalidated.push(id); + world.boxes[id.index()].clear_layout_caches(); self.feedback_invalidation_worklist - .extend(changed_boxes.iter().copied()); - while let Some(id) = self.feedback_invalidation_worklist.pop() { - if self.feedback_invalidation_marks[id.index()] { - continue; - } - self.feedback_invalidation_marks[id.index()] = true; - invalidated.push(id); - world.boxes[id.index()].clear_layout_caches(); - self.feedback_invalidation_worklist - .extend(world.boxes[id.index()].layout_children.iter().copied()); - } + .extend(world.boxes[id.index()].layout_children.iter().copied()); + } - for changed in changed_boxes.iter().copied() { - let mut ancestor = world.boxes[changed.index()].layout_parent; - while let Some(id) = ancestor { - if !self.feedback_invalidation_marks[id.index()] { - self.feedback_invalidation_marks[id.index()] = true; - invalidated.push(id); - world.boxes[id.index()].clear_layout_caches(); - } - ancestor = world.boxes[id.index()].layout_parent; + for changed in changed_boxes.iter().copied() { + let mut ancestor = world.boxes[changed.index()].layout_parent; + while let Some(id) = ancestor { + if !self.feedback_invalidation_marks[id.index()] { + self.feedback_invalidation_marks[id.index()] = true; + invalidated.push(id); + world.boxes[id.index()].clear_layout_caches(); } + ancestor = world.boxes[id.index()].layout_parent; } } - if viewport_changed || !changed_boxes.is_empty() { - world.viewport_layout.cache.clear(); - } + world.viewport_layout.cache.clear(); for id in invalidated.iter().copied() { self.feedback_invalidation_marks[id.index()] = false; } diff --git a/moli-layout/tests/phase5_output_contract.rs b/moli-layout/tests/phase5_output_contract.rs index fa550f9a23..7d0b339d22 100644 --- a/moli-layout/tests/phase5_output_contract.rs +++ b/moli-layout/tests/phase5_output_contract.rs @@ -5,9 +5,9 @@ use moli_layout::{ LayoutElementSemantics, LayoutEnvironment, LayoutError, LayoutFlushReason, LayoutFragmentKind, LayoutNamespace, LayoutPaintedSurfaceHit, LayoutPassRequest, LayoutPassResult, LayoutPoint, LayoutPosition, LayoutQuery, LayoutQueryAnswer, LayoutQueryBatch, LayoutRect, - LayoutScrollbarColors, LayoutScrollbarGutter, LayoutScrollbarPart, LayoutScrollbarWidth, - LayoutSource, LayoutSourceKind, LayoutStyleResolver, LayoutTransform2D, LayoutViewport, - PaintBrush, PaintCaptureRequest, PaintColor, PaintFragment, PaintShape, + LayoutReplacedKind, LayoutScrollbarColors, LayoutScrollbarGutter, LayoutScrollbarPart, + LayoutScrollbarWidth, LayoutSource, LayoutSourceKind, LayoutStyleResolver, LayoutTransform2D, + LayoutViewport, PaintBrush, PaintCaptureRequest, PaintColor, PaintFragment, PaintShape, ResolvedLayoutElementStyles, ResolvedLayoutStyle, build_layout_pass, }; use style::Atom; @@ -91,7 +91,7 @@ impl LayoutSource for Source { LayoutNamespace::Html, self.0[node].local_name, LayoutElementCategory::Generic, - None, + (self.0[node].local_name == "img").then_some(LayoutReplacedKind::Image), ) }) } @@ -2809,6 +2809,49 @@ fn inline_output_preserves_line_text_and_utf16_source_fragments() { assert_eq!(scroll_geometry.scroll_containers.len(), 1); } +#[test] +fn collapsed_space_before_atomic_inline_keeps_its_range_geometry() { + for local_name in ["span", "img"] { + for trailing_text in ["", " "] { + let source = Source(vec![ + Node::element("root", vec![1, 2, 3]), + Node::text("before", "a "), + Node::html_element("atomic", local_name, vec![]), + Node::text("after", trailing_text), + ]); + let mut styles = Styles::default(); + styles + .0 + .insert(0, fixed_size(LayoutDisplay::Block, 200.0, 80.0)); + for node in [1, 3] { + styles + .0 + .insert(node, resolved(LayoutDisplay::Inline, Style::default())); + } + let display = if local_name == "img" { + LayoutDisplay::Inline + } else { + LayoutDisplay::InlineBlock + }; + styles.0.insert(2, fixed_size(display, 20.0, 20.0)); + let output = build(&source, &mut styles); + let space = output.text_range_rects(1, 1..2); + assert_eq!(space.len(), 1, "{local_name}, {trailing_text:?}"); + let space = space[0].bounding_rect(); + assert!( + space.width > 0.0, + "space before an atomic inline is not trailing" + ); + let atomic = output.client_rects_for_source(2)[0].bounding_rect(); + assert_close(space.x + space.width, atomic.x); + assert!( + output.text_range_rects(3, 0..1).is_empty(), + "line-end space still collapses" + ); + } + } +} + #[test] fn inline_offset_metrics_use_fragment_bounds_and_keep_empty_sibling_anchors() { let source = Source(vec![ diff --git a/moli-protocol/src/domains/network/agent.rs b/moli-protocol/src/domains/network/agent.rs index 7cc72cd4c7..dae7c92f38 100644 --- a/moli-protocol/src/domains/network/agent.rs +++ b/moli-protocol/src/domains/network/agent.rs @@ -1391,24 +1391,39 @@ impl CapturedResponseBodyStore { self.durable_sessions.insert(session, limits); } else { self.durable_sessions.remove(&session); - // Turning off durability revokes only old-document claims, not - // ordinary access to responses from the current document. - for request_id in self.retained_request_ids.clone() { - if let Some(body) = self.bodies.get_mut(&request_id) - && !body.remove_session_visibility(session_id) - { - self.bodies.remove(&request_id); - } - if let Some(body) = self.buffered_bodies.get_mut(&request_id) - && !body.remove_session_visibility(session_id) - { - self.buffered_bodies.remove(&request_id); - } + self.revoke_retained_visibility(session_id); + } + for (id, mut body) in self + .buffered_bodies + .set_limits(self.effective_durable_limits()) + { + body.mark_evicted(); + self.bodies.insert(id, body); + } + self.synchronize_durable_entries(); + } + + fn revoke_retained_visibility(&mut self, session_id: Option<&str>) { + // Revocation affects only old-document claims, not ordinary access to + // responses from the current document. + for request_id in &self.retained_request_ids { + if let Some(body) = self.bodies.get_mut(request_id) + && !body.remove_session_visibility(session_id) + { + self.bodies.remove(request_id); + } + if let Some(body) = self.buffered_bodies.get_mut(request_id) + && !body.remove_session_visibility(session_id) + { + self.buffered_bodies.remove(request_id); } } + } + + fn effective_durable_limits(&self) -> ByteLimits { // One target owns one payload store. Multiple clients cannot multiply // its budget; each requested budget is an upper bound, not a reserve. - let limits = self.durable_sessions.values().fold( + self.durable_sessions.values().fold( ByteLimits::new( RESPONSE_BODY_BUFFER_MAX_TOTAL_BYTES, RESPONSE_BODY_BUFFER_MAX_ENTRY_BYTES, @@ -1419,26 +1434,26 @@ impl CapturedResponseBodyStore { acc.max_entry_bytes.min(limits.max_entry_bytes), ) }, - ); - for (id, mut body) in self.buffered_bodies.set_limits(limits) { - body.mark_evicted(); - self.bodies.insert(id, body); - } + ) + } + + fn synchronize_durable_entries(&mut self) { if self.durable_sessions.is_empty() { self.durable_entry_order.clear(); self.retained_request_ids.clear(); - } else { - let mut untracked = self - .bodies - .keys() - .chain(self.buffered_bodies.iter().map(|(id, _)| id)) - .filter(|id| !self.durable_entry_order.contains(id)) - .cloned() - .collect::>(); - untracked.sort(); - self.durable_entry_order.extend(untracked); - self.trim_durable_entries(); + return; } + let tracked: HashSet<_> = self.durable_entry_order.iter().collect(); + let mut untracked = self + .bodies + .keys() + .chain(self.buffered_bodies.iter().map(|(id, _)| id)) + .filter(|id| !tracked.contains(id)) + .cloned() + .collect::>(); + untracked.sort(); + self.durable_entry_order.extend(untracked); + self.trim_durable_entries(); } fn track_durable_entry(&mut self, request_id: &str) { diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs b/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs index 695fc600ce..ef5e1e4182 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/fetch_xhr.rs @@ -489,10 +489,12 @@ fn spawn_repeated_synchronous_xhr_server( } Err(error) => panic!("accept repeated synchronous XHR: {error}"), }; - // Accepted sockets inherit nonblocking mode on macOS. + // macOS can inherit O_NONBLOCK from the listening socket. + // This fixture reads the complete request synchronously; the + // socket timeout alone does not turn WouldBlock into a wait. stream .set_nonblocking(false) - .expect("use blocking synchronous XHR request I/O"); + .expect("make accepted synchronous XHR stream blocking"); stream .set_read_timeout(Some(Duration::from_secs(2))) .expect("bound synchronous XHR request read");