diff --git a/native/src/evaluation.rs b/native/src/evaluation.rs new file mode 100644 index 0000000..a360971 --- /dev/null +++ b/native/src/evaluation.rs @@ -0,0 +1,551 @@ +//! One immutable tree of current evaluation uses, scoped to an exact document pair. + +use std::cell::{Cell, RefCell}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use super::source_topology::SourceAddress; +use super::{ + BoxOverride, LayoutContext, LayoutNode, LayoutTape, LocalStep, Rendered, RetainedDocument, + SourceChanges, +}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) struct EvalWork { + pub(crate) lookups: u64, + pub(crate) hits: u64, + pub(crate) body_runs: u64, + pub(crate) records_created: u64, + pub(crate) caller_edges_written: u64, + pub(crate) occurrence_slots_written: u64, + pub(crate) child_slots_visited: u64, + pub(crate) source_path_steps_copied: u64, + pub(crate) use_path_steps_copied: u64, + pub(crate) dirty_seeds: u64, + pub(crate) dirty_prefixes_visited: u64, + pub(crate) dirty_addresses_inserted: u64, + pub(crate) topology_lookups: u64, + pub(crate) incoming_uses_visited: u64, + pub(crate) height_queries: u64, +} + +thread_local! { + static WORK: Cell = Cell::new(EvalWork::default()); +} + +impl EvalWork { + pub(crate) fn accumulate(&mut self, other: Self) { + macro_rules! add { + ($($field:ident),+ $(,)?) => { $(self.$field = self.$field.checked_add(other.$field) + .expect("native evaluation work counter overflow");)+ }; + } + add!( + lookups, + hits, + body_runs, + records_created, + caller_edges_written, + occurrence_slots_written, + child_slots_visited, + source_path_steps_copied, + use_path_steps_copied, + dirty_seeds, + dirty_prefixes_visited, + dirty_addresses_inserted, + topology_lookups, + incoming_uses_visited, + height_queries + ); + } +} + +pub(crate) fn reset_work() { + WORK.set(EvalWork::default()); +} +pub(crate) fn work() -> EvalWork { + WORK.get() +} + +fn record(delta: EvalWork) { + let mut work = WORK.get(); + work.accumulate(delta); + WORK.set(work); +} + +pub(super) fn record_height_query() { + record(EvalWork { + height_queries: 1, + ..EvalWork::default() + }); +} + +#[derive(Clone, Debug)] +pub(super) struct SourceView<'a> { + pub(super) node: &'a LayoutNode, + pub(super) address: SourceAddress, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum Phase { + FlexMeasure, + FlexMinWidth, + FlexAutoMinContent, + FlexMaxWidth, + FlexBasisContent, + FlexBasisWidth, + ContentIntrinsic, + FlexCrossProbe, + FlexFinal, + WindowFull, + WindowFallback, + WindowChildFull, + WindowChildPartial, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum UseStep { + Child(LocalStep), + Phase(Phase), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct UseSlot { + path: Vec, + occurrence: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct EvalRequest { + context: LayoutContext, + intrinsic: bool, + size_override: Option, +} + +#[derive(Debug)] +struct EvalRecord { + source: SourceAddress, + request: EvalRequest, + rendered: Rendered, + children: BTreeMap>, +} + +/// Owns the current document and current evaluation tree, never a previous frame. +#[derive(Debug)] +pub(crate) struct RetainedFrame { + document: Arc, + root: Arc, +} + +impl RetainedFrame { + pub(crate) fn materialize_tape(&self) -> LayoutTape { + self.root + .rendered + .clone() + .into_tape(self.document.style_count) + } +} + +#[derive(Debug)] +struct BuildingRecord { + slot: UseSlot, + source: SourceAddress, + request: EvalRequest, + previous: Option>, + children: BTreeMap>, + occurrences: BTreeMap, usize>, +} + +#[derive(Debug)] +struct RenderTxn { + previous: Option>, + dirty: BTreeSet, + stack: Vec, + root: Option>, +} + +impl RenderTxn { + fn begin( + &mut self, + source: &SourceAddress, + path: &[UseStep], + request: EvalRequest, + ) -> Option { + record(EvalWork { + lookups: 1, + ..EvalWork::default() + }); + let (slot, previous) = if let Some(parent) = self.stack.last_mut() { + let occurrence = parent.occurrences.entry(path.to_vec()).or_default(); + let slot = UseSlot { + path: path.to_vec(), + occurrence: *occurrence, + }; + *occurrence += 1; + record(EvalWork { + occurrence_slots_written: 1, + use_path_steps_copied: (path.len() * 2) as u64, + ..EvalWork::default() + }); + let previous = parent + .previous + .as_ref() + .and_then(|old| old.children.get(&slot)) + .cloned(); + (slot, previous) + } else { + ( + UseSlot { + path: Vec::new(), + occurrence: 0, + }, + self.previous.clone(), + ) + }; + if let Some(old) = previous.as_ref().filter(|old| { + old.source == *source && old.request == request && !self.dirty.contains(source) + }) { + record(EvalWork { + hits: 1, + ..EvalWork::default() + }); + let rendered = old.rendered.clone(); + self.attach(slot, Arc::clone(old)); + return Some(rendered); + } + record(EvalWork { + body_runs: 1, + ..EvalWork::default() + }); + self.stack.push(BuildingRecord { + slot, + source: source.clone(), + request, + previous, + children: BTreeMap::new(), + occurrences: BTreeMap::new(), + }); + None + } + + fn finish(&mut self, rendered: &Result) { + let current = self.stack.pop().expect("native evaluation builder stack"); + if let Ok(rendered) = rendered { + record(EvalWork { + records_created: 1, + ..EvalWork::default() + }); + self.attach( + current.slot, + Arc::new(EvalRecord { + source: current.source, + request: current.request, + rendered: rendered.clone(), + children: current.children, + }), + ); + } + } + + fn attach(&mut self, slot: UseSlot, record: Arc) { + if let Some(parent) = self.stack.last_mut() { + record_work_edge(); + assert!( + parent.children.insert(slot, record).is_none(), + "unique caller use slot" + ); + } else { + self.root = Some(record); + } + } +} + +fn record_work_edge() { + record(EvalWork { + caller_edges_written: 1, + ..EvalWork::default() + }); +} + +/// Explicit source/callsite context. Interior mutability is transaction-local; +/// no borrow of the builder stack spans a recursive renderer call. +#[derive(Debug)] +pub(super) struct RenderScope<'a> { + pub(super) view: SourceView<'a>, + pub(super) resolver: Option<&'a RetainedDocument>, + transaction: Option<&'a RefCell>, + path: Vec, +} + +impl Clone for RenderScope<'_> { + fn clone(&self) -> Self { + if self.transaction.is_some() { + record(EvalWork { + use_path_steps_copied: self.path.len() as u64, + ..EvalWork::default() + }); + } + Self { + view: self.view.clone(), + resolver: self.resolver, + transaction: self.transaction, + path: self.path.clone(), + } + } +} + +impl<'a> RenderScope<'a> { + pub(super) fn uncached(node: &'a LayoutNode, resolver: Option<&'a RetainedDocument>) -> Self { + Self { + view: SourceView { + node, + address: SourceAddress { + owner_id: node.node_id().unwrap_or(0), + path: Arc::from([]), + }, + }, + resolver, + transaction: None, + path: Vec::new(), + } + } + + pub(super) fn resolve(&self) -> Result { + let mut resolved = self.clone(); + if let LayoutNode::NodeRef { node_id } = self.view.node { + if let Some(resolver) = self.resolver { + resolved.view = SourceView { + node: resolver.resolve(self.view.node)?, + address: SourceAddress { + owner_id: *node_id, + path: Arc::from([]), + }, + }; + } + } + Ok(resolved) + } + + pub(super) fn child(&self, step: LocalStep, node: &'a LayoutNode) -> Self { + if self.transaction.is_none() { + return Self::uncached(node, self.resolver); + } + let mut address = self.view.address.path.to_vec(); + address.push(step); + let mut path = self.path.clone(); + path.push(UseStep::Child(step)); + if self.transaction.is_some() { + record(EvalWork { + child_slots_visited: 1, + source_path_steps_copied: address.len() as u64, + use_path_steps_copied: path.len() as u64, + ..EvalWork::default() + }); + } + Self { + view: SourceView { + node, + address: SourceAddress { + owner_id: self.view.address.owner_id, + path: address.into(), + }, + }, + resolver: self.resolver, + transaction: self.transaction, + path, + } + } + + pub(super) fn phase(&self, phase: Phase) -> Self { + if self.transaction.is_none() { + return self.clone(); + } + let mut scoped = self.clone(); + scoped.path.push(UseStep::Phase(phase)); + if self.transaction.is_some() { + record(EvalWork { + use_path_steps_copied: 1, + ..EvalWork::default() + }); + } + scoped + } + + pub(super) fn render( + &self, + context: LayoutContext, + intrinsic: bool, + size_override: Option, + ) -> Result { + let source = self.resolve()?; + if let Some(transaction) = source.transaction { + let request = EvalRequest { + context, + intrinsic, + size_override, + }; + if let Some(rendered) = + transaction + .borrow_mut() + .begin(&source.view.address, &source.path, request) + { + return Ok(rendered); + } + } + // Descendant slots are local to this caller, even on a parent request miss. + let body_scope = Self { + path: Vec::new(), + ..source + }; + let rendered = super::render_node_body(&body_scope, context, intrinsic, size_override); + if let Some(transaction) = body_scope.transaction { + transaction.borrow_mut().finish(&rendered); + } + rendered + } +} + +fn dirty_sources(document: &RetainedDocument, changes: &SourceChanges) -> BTreeSet { + fn mark(address: SourceAddress, dirty: &mut BTreeSet, owners: &mut Vec) { + for length in (0..=address.path.len()).rev() { + record(EvalWork { + dirty_prefixes_visited: 1, + source_path_steps_copied: length as u64, + ..EvalWork::default() + }); + let prefix = SourceAddress { + owner_id: address.owner_id, + path: Arc::from(&address.path[..length]), + }; + if dirty.insert(prefix) { + record(EvalWork { + dirty_addresses_inserted: 1, + ..EvalWork::default() + }); + if length == 0 { + owners.push(address.owner_id); + } + } + } + } + let mut dirty = BTreeSet::new(); + let mut owners = Vec::new(); + changes.visit_changed_slots(|owner_id, _, _, slot, _| { + record(EvalWork { + dirty_seeds: 1, + ..EvalWork::default() + }); + let path: Arc<[LocalStep]> = match slot { + 0 => Arc::from([]), + 1 => Arc::from([LocalStep::BoxChild]), + _ => unreachable!("validated native source slot"), + }; + mark(SourceAddress { owner_id, path }, &mut dirty, &mut owners); + }); + while let Some(owner) = owners.pop() { + record(EvalWork { + topology_lookups: 1, + ..EvalWork::default() + }); + for incoming in document.topology.incoming_uses(owner) { + record(EvalWork { + incoming_uses_visited: 1, + ..EvalWork::default() + }); + let mut path = incoming.parent.path.to_vec(); + path.push(incoming.child_slot); + record(EvalWork { + source_path_steps_copied: path.len() as u64, + ..EvalWork::default() + }); + mark( + SourceAddress { + owner_id: incoming.parent.owner_id, + path: path.into(), + }, + &mut dirty, + &mut owners, + ); + } + } + dirty +} + +impl RetainedDocument { + pub(crate) fn render_frame( + self: &Arc, + previous: Option<&RetainedFrame>, + changes: Option<&SourceChanges>, + context: LayoutContext, + root_width_override: Option, + ) -> Result, String> { + let reusable = match (previous, changes) { + (Some(previous), Some(changes)) => { + if !changes.applies_to(&previous.document, self) { + return Err( + "Native retained frame source changes do not match the document pair" + .to_owned(), + ); + } + Some(previous) + } + (Some(previous), None) if Arc::ptr_eq(&previous.document, self) => Some(previous), + (None, Some(changes)) if !Arc::ptr_eq(&changes.target, self) => { + return Err( + "Native retained frame source changes do not match the target document" + .to_owned(), + ); + } + _ => None, + }; + let dirty = match (reusable, changes) { + (Some(_), Some(changes)) => dirty_sources(self, changes), + _ => BTreeSet::new(), + }; + let transaction = RefCell::new(RenderTxn { + previous: reusable.map(|previous| Arc::clone(&previous.root)), + dirty, + stack: Vec::new(), + root: None, + }); + let root = self + .effective_node(self.root_id) + .ok_or_else(|| "Native retained document lost its root".to_owned())?; + super::RESOLVER_LOOKUP_COUNT.with(|count| count.set(count.get().saturating_add(1))); + if root_width_override.is_some() && !matches!(root, LayoutNode::Box { .. }) { + return Err("Native root width override requires a box root".to_owned()); + } + let scope = RenderScope { + view: SourceView { + node: root, + address: SourceAddress { + owner_id: self.root_id, + path: Arc::from([]), + }, + }, + resolver: Some(self), + transaction: Some(&transaction), + path: Vec::new(), + }; + scope.render( + context, + false, + root_width_override.map(|declared_width| BoxOverride { + declared_width: Some(declared_width), + ..BoxOverride::default() + }), + )?; + let root = transaction + .into_inner() + .root + .expect("native retained root evaluation"); + Ok(Arc::new(RetainedFrame { + document: Arc::clone(self), + root, + })) + } +} + +#[cfg(test)] +#[path = "evaluation_tests.rs"] +mod tests; diff --git a/native/src/evaluation_tests.rs b/native/src/evaluation_tests.rs new file mode 100644 index 0000000..9ec2206 --- /dev/null +++ b/native/src/evaluation_tests.rs @@ -0,0 +1,1028 @@ +use super::super::tests::{ + child_box, context_test_delta, identified, nonuniform_text, retained_document, test_context, +}; +use super::super::{FlexAlign, FlexDirection, FlexItem, FlexWrap, Size, WrapMode}; +use super::*; + +fn flex(nodes: Vec, direction: FlexDirection, wrap: FlexWrap) -> LayoutNode { + LayoutNode::Flex { + node_id: None, + node_revision: None, + direction, + wrap, + justify: FlexAlign::FlexStart, + align_items: FlexAlign::FlexStart, + align_content: FlexAlign::FlexStart, + width: Size::Pixels { value: 12 }, + height: Size::Auto, + row_gap: 1, + column_gap: 1, + items: Arc::new( + nodes + .into_iter() + .enumerate() + .map(|(index, node)| FlexItem { + node, + order: (index % 2) as i64, + grow: 0.0, + shrink: 1.0, + basis: Size::Pixels { value: 3 }, + align_self: FlexAlign::Auto, + }) + .collect(), + ), + } +} + +fn row_id(index: usize) -> u64 { + index as u64 * 100 + 10 +} + +fn mixed(size: usize) -> Arc { + let rows = (0..size) + .map(|index| { + let id = row_id(index); + let nodes = (0..3) + .map(|item| { + let item_id = id + 20 + item; + let mut node = child_box( + item_id as i64, + nonuniform_text(item_id as i64 + 100_000, &[1]), + None, + ); + let LayoutNode::Box { wrap_mode, .. } = &mut node else { + unreachable!() + }; + *wrap_mode = WrapMode::Word; + identified(node, item_id, 1) + }) + .collect(); + identified( + LayoutNode::Row { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + identified( + child_box( + id as i64 + 1, + nonuniform_text(id as i64 + 100_001, &[1]), + None, + ), + id + 1, + 1, + ), + identified( + flex(nodes, FlexDirection::RowReverse, FlexWrap::Wrap), + id + 2, + 1, + ), + ]), + }, + id, + 1, + ) + }) + .collect(); + let root = identified( + child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(rows), + }, + None, + ), + 1, + 1, + ); + RetainedDocument::bootstrap(retained_document(root)) + .unwrap() + .0 +} + +fn text_update(owner: u64, before: u64, slot: u8, text: &str, width: i64) -> serde_json::Value { + serde_json::json!({"node-id": owner, "expected-revision": before, "target-revision": before + 1, + "slot-patches": [{"slot": slot, "local": {"content": {"lines": [{"clusters": [{ + "text": text, "width": width, "cjk": false, "space": false, + }]}]}}}]}) +} + +fn records(root: &Arc) -> Vec<&Arc> { + let mut output = vec![root]; + for child in root.children.values() { + output.extend(records(child)); + } + output +} + +fn owner_record(root: &Arc, owner: u64) -> &Arc { + records(root) + .into_iter() + .find(|record| record.source.owner_id == owner && record.source.path.is_empty()) + .expect("fixture owner has a current evaluation") +} + +fn assert_oracle(frame: &RetainedFrame, context: LayoutContext, root_width: Option) { + assert_eq!( + frame.materialize_tape(), + frame.document.layout_tape(context, root_width).unwrap() + ); +} + +#[test] +fn instrumentation_records_common_entries_without_changing_full_semantics() { + let document = mixed(3); + reset_work(); + let frame = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let measured = work(); + assert_eq!(measured.hits, 0); + assert_eq!(measured.lookups, measured.body_runs); + assert_eq!( + measured.records_created as usize, + records(&frame.root).len() + ); + assert_eq!(measured.caller_edges_written + 1, measured.records_created); + assert_oracle(&frame, test_context(), None); + let all = records(&frame.root); + assert!(all + .iter() + .any(|record| matches!(record.source.path.as_ref(), [LocalStep::BoxChild]))); + let phases = all + .iter() + .flat_map(|record| record.children.keys()) + .flat_map(|slot| slot.path.iter()) + .filter_map(|step| match step { + UseStep::Phase(phase) => Some(*phase), + _ => None, + }) + .collect::>(); + for phase in [ + Phase::FlexMeasure, + Phase::FlexMinWidth, + Phase::FlexAutoMinContent, + Phase::FlexMaxWidth, + Phase::FlexBasisContent, + Phase::FlexBasisWidth, + Phase::ContentIntrinsic, + Phase::FlexCrossProbe, + Phase::FlexFinal, + ] { + assert!(phases.contains(&phase), "missing phase {phase:?}"); + } + let flex_record = owner_record(&frame.root, row_id(0) + 2); + let original_positions = flex_record + .children + .keys() + .filter_map(|slot| { + slot.path.iter().find_map(|step| match step { + UseStep::Child(LocalStep::FlexItem(index)) => Some(*index), + _ => None, + }) + }) + .collect::>(); + assert_eq!(original_positions, BTreeSet::from([0, 1, 2])); + // The cache-disabled oracle builds neither records nor caller/source paths. + reset_work(); + document.layout_tape(test_context(), None).unwrap(); + assert_eq!(work().records_created, 0); + assert_eq!(work().child_slots_visited, 0); + assert_eq!(work().source_path_steps_copied, 0); + assert_eq!(work().use_path_steps_copied, 0); +} + +#[test] +fn instrumentation_preserves_literal_column_paths_and_node_ref_boundaries() { + let nested = LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![ + nonuniform_text(2, &[2, 9]), + identified( + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![nonuniform_text(3, &[5]), nonuniform_text(4, &[1])]), + }, + 2, + 1, + ), + ]), + }; + let root = identified( + child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(vec![nested, nonuniform_text(5, &[8])]), + }, + None, + ), + 1, + 1, + ); + let document = RetainedDocument::bootstrap(retained_document(root)) + .unwrap() + .0; + let frame = document + .render_frame(None, None, test_context(), None) + .unwrap(); + assert_oracle(&frame, test_context(), None); + let anonymous = records(&frame.root) + .into_iter() + .find(|record| { + record.source.owner_id == 1 + && record.source.path.as_ref() + == [ + LocalStep::BoxChild, + LocalStep::ColumnChild(0), + LocalStep::ColumnChild(0), + ] + }) + .unwrap(); + assert_eq!(anonymous.rendered.height(), 2); + let identified = owner_record(&frame.root, 2); + assert_eq!(identified.children.len(), 2); + assert!(identified + .children + .values() + .all(|record| record.source.owner_id == 2)); +} + +#[test] +fn nonzero_mixed_leaf_delta_reuses_unchanged_subtree_bodies() { + let document = mixed(8); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(3) + 1, + 1, + 1, + "y", + 1, + )])) + .unwrap(); + reset_work(); + super::super::TEST_RENDER_NODE_COUNT.with(|count| count.set(Some(0))); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + let measured = work(); + let actual_bodies = + super::super::TEST_RENDER_NODE_COUNT.with(|count| count.replace(None).unwrap()); + assert_eq!(actual_bodies as u64, measured.body_runs); + assert_oracle(&target, test_context(), None); + assert!( + Arc::ptr_eq( + owner_record(&baseline.root, row_id(7)), + owner_record(&target.root, row_id(7)) + ), + "unchanged row record must be shared across the nonzero delta: {measured:?}" + ); + assert!(!Arc::ptr_eq( + owner_record(&baseline.root, row_id(3)), + owner_record(&target.root, row_id(3)) + )); + assert_eq!( + measured.body_runs, 5, + "only root Box/Column, changed Row/Box/Text bodies run" + ); + assert_eq!( + measured.hits, 8, + "seven unchanged rows plus the changed row's unchanged Flex" + ); + assert_eq!(measured.records_created, 5); +} + +#[test] +fn fixed_changed_subtree_reuses_bodies_across_sibling_sizes() { + for size in [32, 128, 512] { + let document = mixed(size); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + for (offset, text) in [(1, "y"), (20, "longer")] { + let update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(size / 2) + offset, + 1, + 1, + text, + 1, + )])) + .unwrap(); + reset_work(); + super::super::reset_line_plan_work(); + super::super::TEST_RENDER_NODE_COUNT.with(|count| count.set(Some(0))); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + let actual = + super::super::TEST_RENDER_NODE_COUNT.with(|count| count.replace(None).unwrap()); + let measured = work(); + let line_work = super::super::line_plan_work(); + assert_eq!(measured.body_runs as usize, actual); + assert!( + measured.body_runs <= 20, + "N={size}, offset={offset}: {measured:?}" + ); + assert!(measured.hits >= size as u64 - 1); + assert!( + measured.caller_edges_written >= size as u64, + "parent sibling edge writes remain N" + ); + assert!( + line_work.lines_mapped >= size as u64, + "parent line maps remain explicit N work" + ); + assert_eq!(measured.dirty_seeds, 1); + assert!(measured.dirty_addresses_inserted <= 9); + assert!(Arc::ptr_eq( + owner_record(&baseline.root, row_id(size - 1)), + owner_record(&target.root, row_id(size - 1)) + )); + assert_oracle(&target, test_context(), None); + eprintln!( + "N={size}, changed_offset={offset}, eval={measured:?}, lines_mapped={}", + line_work.lines_mapped + ); + } + } +} + +#[test] +fn parent_and_child_source_updates_share_only_clean_descendants() { + let document = mixed(4); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let update = document + .apply_delta(context_test_delta(vec![ + serde_json::json!({"node-id":1,"expected-revision":1,"target-revision":2, + "slot-patches":[{"slot":0,"local":{"width":{"kind":"pixels","value":80}}}]}), + text_update(row_id(1) + 20, 1, 1, "changed", 2), + ])) + .unwrap(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + assert_oracle(&target, test_context(), None); + assert!(Arc::ptr_eq( + owner_record(&baseline.root, row_id(3)), + owner_record(&target.root, row_id(3)) + )); + assert!(!Arc::ptr_eq( + owner_record(&baseline.root, row_id(1) + 20), + owner_record(&target.root, row_id(1) + 20) + )); +} + +#[test] +fn exact_document_pair_authority_rejects_mismatch_and_cold_renders_without_proof() { + let document = mixed(2); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(0) + 1, + 1, + 1, + "y", + 1, + )])) + .unwrap(); + reset_work(); + let cold = update + .document + .render_frame(Some(&baseline), None, test_context(), None) + .unwrap(); + assert_eq!(work().hits, 0); + assert!(!Arc::ptr_eq( + owner_record(&baseline.root, row_id(1)), + owner_record(&cold.root, row_id(1)) + )); + assert_oracle(&cold, test_context(), None); + let foreign = mixed(2); + let foreign_frame = foreign + .render_frame(None, None, test_context(), None) + .unwrap(); + for (target, previous) in [(&document, &baseline), (&update.document, &foreign_frame)] { + assert!(target + .render_frame(Some(previous), Some(&update.changes), test_context(), None) + .is_err()); + } + assert!(foreign + .render_frame(None, Some(&update.changes), test_context(), None) + .is_err()); + assert!(update + .document + .render_frame(None, Some(&update.changes), test_context(), None) + .is_ok()); +} + +#[test] +fn revision_and_registry_only_changes_reuse_output_and_release_old_document() { + let document = mixed(1); + let old_document = Arc::downgrade(&document); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let delta = serde_json::from_value(serde_json::json!({ + "style-base-count":0,"styles-append":[{"mode":"set","face":{"foreground":"red"}}], + "property-template-base-count":0,"property-template-target-count":1, + "entries":[{"node-id":1,"expected-revision":1,"target-revision":2}], + })) + .unwrap(); + let update = document.apply_delta(delta).unwrap(); + reset_work(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + assert_eq!( + (work().hits, work().body_runs, work().records_created), + (1, 0, 0) + ); + assert!(Arc::ptr_eq(&baseline.root, &target.root)); + assert_eq!(target.materialize_tape().style_count, 1); + assert_oracle(&target, test_context(), None); + drop(update); + drop(document); + assert!( + old_document.upgrade().is_some(), + "the old frame still owns its document" + ); + drop(baseline); + assert!( + old_document.upgrade().is_none(), + "shared evaluation records must not retain old documents" + ); + assert_eq!(target.document.style_count, 1); +} + +fn render_request( + document: &Arc, + previous: Option<&RetainedFrame>, + context: LayoutContext, + intrinsic: bool, + size_override: Option, +) -> Arc { + let transaction = RefCell::new(RenderTxn { + previous: previous.map(|frame| Arc::clone(&frame.root)), + dirty: BTreeSet::new(), + stack: Vec::new(), + root: None, + }); + let scope = RenderScope { + view: SourceView { + node: document.effective_node(document.root_id).unwrap(), + address: SourceAddress { + owner_id: document.root_id, + path: Arc::from([]), + }, + }, + resolver: Some(document), + transaction: Some(&transaction), + path: Vec::new(), + }; + let rendered = scope.render(context, intrinsic, size_override).unwrap(); + let oracle = super::super::render_node_with_override( + scope.view.node, + Some(document), + context, + intrinsic, + size_override, + ) + .unwrap(); + assert_eq!( + rendered.into_tape(document.style_count), + oracle.into_tape(document.style_count) + ); + Arc::new(RetainedFrame { + document: Arc::clone(document), + root: transaction.into_inner().root.unwrap(), + }) +} + +#[test] +fn requests_preserve_all_context_intrinsic_and_override_fields_exactly() { + let document = mixed(1); + let context = test_context(); + let baseline = render_request(&document, None, context, false, None); + let requests = [ + ( + LayoutContext { + viewport_width: 79, + ..context + }, + false, + None, + ), + ( + LayoutContext { + viewport_width_known: false, + ..context + }, + false, + None, + ), + ( + LayoutContext { + viewport_height: 23, + ..context + }, + false, + None, + ), + ( + LayoutContext { + inline_auto_width_intrinsic: true, + ..context + }, + false, + None, + ), + (context, true, None), + (context, false, Some(BoxOverride::default())), + ( + context, + false, + Some(BoxOverride { + content_width: Some(80), + ..BoxOverride::default() + }), + ), + ( + context, + false, + Some(BoxOverride { + content_height: Some(2), + ..BoxOverride::default() + }), + ), + ( + context, + false, + Some(BoxOverride { + declared_width: Some(80), + ..BoxOverride::default() + }), + ), + ]; + for (context, intrinsic, size_override) in requests { + let changed = render_request( + &document, + Some(&baseline), + context, + intrinsic, + size_override, + ); + assert!(!Arc::ptr_eq(&baseline.root, &changed.root)); + let repeated = render_request(&document, Some(&changed), context, intrinsic, size_override); + assert!(Arc::ptr_eq(&changed.root, &repeated.root)); + } +} + +#[test] +fn viewport_a_b_a_prunes_old_records_and_has_no_history_chain() { + let document = mixed(2); + let context_a = test_context(); + let context_b = LayoutContext { + viewport_height: 42, + ..context_a + }; + let a = document.render_frame(None, None, context_a, None).unwrap(); + let old_root = Arc::downgrade(&a.root); + let old_leaf = Arc::downgrade(owner_record(&a.root, row_id(0) + 20)); + let b = document + .render_frame(Some(&a), None, context_b, None) + .unwrap(); + let count = records(&b.root).len(); + drop(a); + assert!(old_root.upgrade().is_none()); + assert!(old_leaf.upgrade().is_none()); + let weak_b = Arc::downgrade(&b.root); + let current = document + .render_frame(Some(&b), None, context_a, None) + .unwrap(); + drop(b); + assert!(weak_b.upgrade().is_none()); + assert_eq!(records(¤t.root).len(), count); + assert_oracle(¤t, context_a, None); +} + +fn scroll_document() -> Arc { + let children = (2..6) + .map(|id| { + let mut node = child_box(id, nonuniform_text(id + 100, &[1, 1, 1]), None); + let LayoutNode::Box { height, .. } = &mut node else { + unreachable!() + }; + *height = Size::Lines { value: 3 }; + identified(node, id as u64, 1) + }) + .collect(); + let mut root = child_box( + 1, + LayoutNode::Column { + node_id: None, + node_revision: None, + children: Arc::new(children), + }, + None, + ); + let LayoutNode::Box { + width, + height, + content_width_exact, + scroll_offset, + .. + } = &mut root + else { + unreachable!() + }; + *width = Size::Viewport; + *height = Size::Lines { value: 2 }; + *content_width_exact = false; + *scroll_offset = 4; + RetainedDocument::bootstrap(retained_document(identified(root, 1, 1))) + .unwrap() + .0 +} + +#[test] +fn unrendered_height_dependencies_invalidate_scroll_and_windows_cache_only_complete_values() { + let document = scroll_document(); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + assert_oracle(&baseline, test_context(), None); + assert!(!records(&baseline.root) + .iter() + .any(|record| record.source.owner_id == 2)); + assert_eq!( + owner_record(&baseline.root, 3).rendered.height(), + 3, + "the partially visible child is retained as a complete render" + ); + let update = document + .apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id":2,"expected-revision":1,"target-revision":2, + "slot-patches":[{"slot":0,"local":{"height":{"kind":"lines","value":5}}}] + })])) + .unwrap(); + reset_work(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + assert!(work().body_runs > 0); + assert!(work().incoming_uses_visited > 0); + assert!(work().height_queries > 0); + assert_oracle(&target, test_context(), None); + assert_ne!(baseline.materialize_tape(), target.materialize_tape()); + assert!( + Arc::ptr_eq( + owner_record(&baseline.root, 3), + owner_record(&target.root, 3) + ), + "reuse a complete child while selecting a different visible slice" + ); + let expanded = update.document.apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id":1,"expected-revision":1,"target-revision":2, + "slot-patches":[{"slot":0,"local":{"height":{"kind":"lines","value":20},"scroll-offset":0}}] + })])).unwrap(); + let full = expanded + .document + .render_frame(Some(&target), Some(&expanded.changes), test_context(), None) + .unwrap(); + assert_oracle(&full, test_context(), None); + let column = full + .root + .children + .values() + .find(|record| record.source.path.as_ref() == [LocalStep::BoxChild]) + .unwrap(); + assert_eq!(column.rendered.height(), 14); + let old_column = Arc::downgrade(column); + let collapsed = expanded.document.apply_delta(context_test_delta(vec![serde_json::json!({ + "node-id":1,"expected-revision":2,"target-revision":3, + "slot-patches":[{"slot":0,"local":{"height":{"kind":"lines","value":2},"scroll-offset":4}}] + })])).unwrap(); + let window = collapsed + .document + .render_frame(Some(&full), Some(&collapsed.changes), test_context(), None) + .unwrap(); + assert_oracle(&window, test_context(), None); + assert!(!window + .root + .children + .values() + .any(|record| record.source.path.as_ref() == [LocalStep::BoxChild])); + drop(full); + assert!( + old_column.upgrade().is_none(), + "the old full-column use must be pruned" + ); +} + +#[test] +fn nested_reversed_wrapped_flex_preserves_distinct_uses_on_geometry_changes() { + let inner = flex( + (11..14) + .map(|id| { + identified( + child_box(id, nonuniform_text(id + 100, &[1]), None), + id as u64, + 1, + ) + }) + .collect(), + FlexDirection::RowReverse, + FlexWrap::WrapReverse, + ); + let outer = flex( + vec![ + identified(child_box(10, inner, None), 10, 1), + identified(child_box(20, nonuniform_text(120, &[1]), None), 20, 1), + ], + FlexDirection::ColumnReverse, + FlexWrap::WrapReverse, + ); + let document = RetainedDocument::bootstrap(retained_document(identified( + child_box(1, outer, None), + 1, + 1, + ))) + .unwrap() + .0; + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let update = document + .apply_delta(context_test_delta(vec![text_update( + 12, 1, 1, "expanded", 7, + )])) + .unwrap(); + reset_work(); + let target = update + .document + .render_frame(Some(&baseline), Some(&update.changes), test_context(), None) + .unwrap(); + assert!(work().hits > 0); + assert_oracle(&target, test_context(), None); + assert!(Arc::ptr_eq( + owner_record(&baseline.root, 20), + owner_record(&target.root, 20) + )); + assert!( + records(&target.root) + .iter() + .filter(|record| record.source.owner_id == 12 && record.source.path.is_empty()) + .count() + > 1, + "a Flex owner has multiple current evaluation uses" + ); +} + +fn repeated_calls( + document: &Arc, + previous: Option<&RetainedFrame>, + widths: &[i64], +) -> Arc { + let source = SourceAddress { + owner_id: document.root_id, + path: Arc::from([]), + }; + let transaction = RefCell::new(RenderTxn { + previous: previous.map(|frame| Arc::clone(&frame.root)), + dirty: BTreeSet::from([source.clone()]), + stack: Vec::new(), + root: None, + }); + let node = document.effective_node(document.root_id).unwrap(); + let scope = RenderScope { + view: SourceView { + node, + address: source.clone(), + }, + resolver: Some(document), + transaction: Some(&transaction), + path: Vec::new(), + }; + let request = EvalRequest { + context: test_context(), + intrinsic: false, + size_override: None, + }; + assert!(transaction + .borrow_mut() + .begin(&source, &[], request) + .is_none()); + let LayoutNode::Box { + child: Some(child), .. + } = node + else { + unreachable!() + }; + let child_scope = scope + .child(LocalStep::BoxChild, child) + .phase(Phase::ContentIntrinsic); + let parts = widths + .iter() + .map(|width| { + child_scope + .render( + LayoutContext { + viewport_width: *width, + ..test_context() + }, + true, + None, + ) + .unwrap() + }) + .collect(); + transaction + .borrow_mut() + .finish(&Ok(super::super::stack_vertical(parts))); + Arc::new(RetainedFrame { + document: Arc::clone(document), + root: transaction.into_inner().root.unwrap(), + }) +} + +#[test] +fn repeated_callsite_uses_have_local_occurrences_and_prune_unused_contexts() { + let document = RetainedDocument::bootstrap(retained_document(identified( + child_box(1, nonuniform_text(2, &[1]), None), + 1, + 1, + ))) + .unwrap() + .0; + let baseline = repeated_calls(&document, None, &[10, 20]); + let keys = baseline.root.children.keys().collect::>(); + assert_eq!(keys[0].path, keys[1].path); + assert_eq!((keys[0].occurrence, keys[1].occurrence), (0, 1)); + let removed = Arc::downgrade(&baseline.root.children[keys[1]]); + reset_work(); + let one = repeated_calls(&document, Some(&baseline), &[10]); + assert_eq!(work().hits, 1); + assert_eq!(one.root.children.len(), 1); + drop(baseline); + assert!(removed.upgrade().is_none()); + reset_work(); + let changed = repeated_calls(&document, Some(&one), &[20]); + assert_eq!( + work().hits, + 0, + "a discarded second occurrence is not a historical context cache" + ); + assert_eq!(changed.root.children.len(), 1); +} + +#[test] +fn independent_nonzero_forks_share_clean_records_without_retaining_old_documents() { + let document = mixed(3); + let old_document = Arc::downgrade(&document); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let left_update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(0) + 1, + 1, + 1, + "left", + 1, + )])) + .unwrap(); + let right_update = document + .apply_delta(context_test_delta(vec![text_update( + row_id(1) + 1, + 1, + 1, + "right", + 1, + )])) + .unwrap(); + let left = left_update + .document + .render_frame( + Some(&baseline), + Some(&left_update.changes), + test_context(), + None, + ) + .unwrap(); + let right = right_update + .document + .render_frame( + Some(&baseline), + Some(&right_update.changes), + test_context(), + None, + ) + .unwrap(); + assert_oracle(&left, test_context(), None); + assert_oracle(&right, test_context(), None); + assert!(Arc::ptr_eq( + owner_record(&left.root, row_id(2)), + owner_record(&right.root, row_id(2)) + )); + assert!(!Arc::ptr_eq( + owner_record(&left.root, row_id(0)), + owner_record(&right.root, row_id(0)) + )); + let old_changed = Arc::downgrade(owner_record(&baseline.root, row_id(0))); + drop(left_update); + drop(right_update); + drop(baseline); + drop(document); + assert!(old_document.upgrade().is_none()); + assert!( + old_changed.upgrade().is_some(), + "the independent right fork still shares this row" + ); + drop(right); + assert!(old_changed.upgrade().is_none()); + assert_oracle(&left, test_context(), None); +} + +#[test] +fn unwinding_discards_current_builder_records_without_mutating_previous_frame() { + let document = mixed(1); + let baseline = document + .render_frame(None, None, test_context(), None) + .unwrap(); + let baseline_tape = baseline.materialize_tape(); + let created = RefCell::new(None); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let source = baseline.root.source.clone(); + let transaction = RefCell::new(RenderTxn { + previous: Some(Arc::clone(&baseline.root)), + dirty: BTreeSet::from([source.clone()]), + stack: Vec::new(), + root: None, + }); + let node = document.effective_node(document.root_id).unwrap(); + let scope = RenderScope { + view: SourceView { + node, + address: source.clone(), + }, + resolver: Some(&document), + transaction: Some(&transaction), + path: Vec::new(), + }; + transaction + .borrow_mut() + .begin(&source, &[], baseline.root.request); + let LayoutNode::Box { + child: Some(child), .. + } = node + else { + unreachable!() + }; + scope + .child(LocalStep::BoxChild, child) + .render( + LayoutContext { + viewport_height: 99, + ..test_context() + }, + false, + None, + ) + .unwrap(); + *created.borrow_mut() = Some(Arc::downgrade( + transaction + .borrow() + .stack + .last() + .unwrap() + .children + .values() + .next() + .unwrap(), + )); + panic!("injected caller failure after child evaluation"); + })); + assert!(result.is_err()); + assert!(created.into_inner().unwrap().upgrade().is_none()); + assert_eq!(baseline.materialize_tape(), baseline_tape); + let retry = document + .render_frame(Some(&baseline), None, test_context(), None) + .unwrap(); + assert!(Arc::ptr_eq(&retry.root, &baseline.root)); +} diff --git a/native/src/layout.rs b/native/src/layout.rs index cecad8b..6ce1c2c 100644 --- a/native/src/layout.rs +++ b/native/src/layout.rs @@ -36,6 +36,18 @@ pub(crate) fn line_plan_work() -> LinePlanWork { line_plan::work() } +#[path = "evaluation.rs"] +mod evaluation; +pub(crate) use evaluation::{EvalWork, RetainedFrame}; +use evaluation::{Phase, RenderScope}; + +pub(crate) fn reset_eval_work() { + evaluation::reset_work(); +} +pub(crate) fn eval_work() -> EvalWork { + evaluation::work() +} + fn deserialize_arc_vec<'de, D, T>(deserializer: D) -> Result>, D::Error> where D: Deserializer<'de>, @@ -5500,6 +5512,7 @@ enum FlexMode { #[derive(Debug, Clone)] struct FlexRuntimeItem<'a> { source: &'a LayoutNode, + scope: RenderScope<'a>, grow: f64, shrink: f64, align_self: FlexAlign, @@ -5610,7 +5623,7 @@ fn box_vertical_side(node: &LayoutNode) -> Option { fn box_content_intrinsics( node: &LayoutNode, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, ) -> Result, String> { let LayoutNode::Box { @@ -5633,7 +5646,10 @@ fn box_content_intrinsics( // current inline viewport. Treating that width as unknown makes // responsive descendants collapse to their narrow intrinsic form and // produces a different automatic minimum from the visible renderer. - let rendered = render_node(child, resolver, context, true)?; + let rendered = scope + .child(LocalStep::BoxChild, child) + .phase(Phase::ContentIntrinsic) + .render(context, true, None)?; Ok(Some(( content_min_width.unwrap_or_else(|| rendered.min_content_width(*wrap_mode)), rendered.max_width(), @@ -5647,7 +5663,7 @@ fn flex_box_resolve_width( node: &LayoutNode, size: &Size, fallback: Option, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, ) -> Result, String> { let LayoutNode::Box { @@ -5662,7 +5678,7 @@ fn flex_box_resolve_width( return Ok(fallback); }; let (min_content, max_content) = - box_content_intrinsics(node, resolver, context)?.unwrap_or((0, 0)); + box_content_intrinsics(node, scope, context)?.unwrap_or((0, 0)); let side = box_horizontal_side(node).unwrap_or(0); let stretch = context .viewport_width_known @@ -5711,7 +5727,7 @@ fn flex_min_main( source: &LayoutNode, rendered: &Rendered, axis: FlexAxis, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, ) -> Result { let LayoutNode::Box { @@ -5730,17 +5746,27 @@ fn flex_min_main( Ok(match axis { FlexAxis::Row => { let side = box_horizontal_side(source).unwrap_or(0); - let declared = - flex_box_resolve_width(source, min_width, Some(0), resolver, context)?.unwrap_or(0); + let declared = flex_box_resolve_width( + source, + min_width, + Some(0), + &scope.phase(Phase::FlexMinWidth), + context, + )? + .unwrap_or(0); if *wrap_mode == WrapMode::None { rendered.max_width().max(side + declared) } else { let content_min = match content_min_width { Some(content_min_width) => *content_min_width, None => { - box_content_intrinsics(source, resolver, context)? - .unwrap_or((0, 0)) - .0 + box_content_intrinsics( + source, + &scope.phase(Phase::FlexAutoMinContent), + context, + )? + .unwrap_or((0, 0)) + .0 } }; side + declared.max(content_min) @@ -5758,7 +5784,7 @@ fn flex_min_main( fn flex_max_main( source: &LayoutNode, axis: FlexAxis, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, ) -> Result, String> { let LayoutNode::Box { @@ -5770,8 +5796,14 @@ fn flex_max_main( return Ok(None); }; Ok(match axis { - FlexAxis::Row => flex_box_resolve_width(source, max_width, None, resolver, context)? - .map(|value| value + box_horizontal_side(source).unwrap_or(0)), + FlexAxis::Row => flex_box_resolve_width( + source, + max_width, + None, + &scope.phase(Phase::FlexMaxWidth), + context, + )? + .map(|value| value + box_horizontal_side(source).unwrap_or(0)), FlexAxis::Column => flex_box_resolve_height(source, max_height, None, context) .map(|value| value + box_vertical_side(source).unwrap_or(0)), }) @@ -5798,7 +5830,7 @@ fn flex_basis_main( rendered: &Rendered, axis: FlexAxis, basis: &Size, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, ) -> Result { let rendered_main = match axis { @@ -5811,15 +5843,24 @@ fn flex_basis_main( if matches!(basis, Size::Content) { if axis == FlexAxis::Row && matches!(source, LayoutNode::Box { .. }) { let (_, content_max) = - box_content_intrinsics(source, resolver, context)?.unwrap_or((0, 0)); + box_content_intrinsics(source, &scope.phase(Phase::FlexBasisContent), context)? + .unwrap_or((0, 0)); return Ok(box_horizontal_side(source).unwrap_or(0) + content_max); } return Ok(rendered_main); } if axis == FlexAxis::Row && matches!(source, LayoutNode::Box { .. }) { - let (_, content_max) = box_content_intrinsics(source, resolver, context)?.unwrap_or((0, 0)); - let content = flex_box_resolve_width(source, basis, Some(content_max), resolver, context)? - .unwrap_or(content_max); + let (_, content_max) = + box_content_intrinsics(source, &scope.phase(Phase::FlexBasisContent), context)? + .unwrap_or((0, 0)); + let content = flex_box_resolve_width( + source, + basis, + Some(content_max), + &scope.phase(Phase::FlexBasisWidth), + context, + )? + .unwrap_or(content_max); return Ok(box_horizontal_side(source).unwrap_or(0) + content); } Ok(match axis { @@ -5852,13 +5893,11 @@ fn measure_flex_item<'a>( item: &'a FlexItem, axis: FlexAxis, inline_viewport: Option, - resolver: Option<&'a RetainedDocument>, + scope: RenderScope<'a>, context: LayoutContext, ) -> Result, String> { - let source = match resolver { - Some(resolver) => resolver.resolve(&item.node)?, - None => &item.node, - }; + let scope = scope.resolve()?; + let source = scope.view.node; let uses_inline_viewport = flex_item_uses_inline_viewport(source); let measurement_context = LayoutContext { viewport_width: if uses_inline_viewport { @@ -5870,13 +5909,17 @@ fn measure_flex_item<'a>( viewport_height: context.viewport_height, inline_auto_width_intrinsic: context.inline_auto_width_intrinsic, }; - let rendered = render_node(source, resolver, measurement_context, uses_inline_viewport)?; - let min_main = flex_min_main(source, &rendered, axis, resolver, context)?; - let max_main = flex_max_main(source, axis, resolver, context)?; - let base = flex_basis_main(source, &rendered, axis, &item.basis, resolver, context)?.max(0); + let rendered = + scope + .phase(Phase::FlexMeasure) + .render(measurement_context, uses_inline_viewport, None)?; + let min_main = flex_min_main(source, &rendered, axis, &scope, context)?; + let max_main = flex_max_main(source, axis, &scope, context)?; + let base = flex_basis_main(source, &rendered, axis, &item.basis, &scope, context)?.max(0); let hypothetical = flex_clamp_main(base, min_main, max_main); Ok(FlexRuntimeItem { source, + scope, grow: item.grow, shrink: item.shrink, align_self: item.align_self, @@ -6221,7 +6264,7 @@ fn render_flex_sized_entry( main: i64, cross: Option, container_align: FlexAlign, - resolver: Option<&RetainedDocument>, + phase: Phase, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6244,13 +6287,10 @@ fn render_flex_sized_entry( inline_auto_width_intrinsic: context.inline_auto_width_intrinsic, }; let override_size = box_override_for_flex(item.source, axis, main, cross, stretch); - let mut rendered = render_node_with_override( - item.source, - resolver, - render_context, - intrinsic, - override_size, - )?; + let mut rendered = item + .scope + .phase(phase) + .render(render_context, intrinsic, override_size)?; match axis { FlexAxis::Row => { rendered = pad_rendered_width(rendered, main, FlexAlign::FlexStart); @@ -6341,6 +6381,7 @@ fn exact_rendered_height( resolver: Option<&RetainedDocument>, context: LayoutContext, ) -> Option { + evaluation::record_height_query(); let node = resolver .and_then(|value| value.resolve(node).ok()) .unwrap_or(node); @@ -6391,19 +6432,25 @@ fn exact_rendered_height( } fn render_node_window( - node: &LayoutNode, - resolver: Option<&RetainedDocument>, + scope: &RenderScope<'_>, context: LayoutContext, intrinsic: bool, start: i64, height: i64, ) -> Option> { - let node = resolver - .and_then(|value| value.resolve(node).ok()) - .unwrap_or(node); + let scope = match scope.resolve() { + Ok(scope) => scope, + Err(error) => return Some(Err(error)), + }; + let node = scope.view.node; + let resolver = scope.resolver; let total_height = exact_rendered_height(node, resolver, context)?; if start <= 0 && height >= total_height { - return Some(render_node(node, resolver, context, intrinsic)); + return Some( + scope + .phase(Phase::WindowFull) + .render(context, intrinsic, None), + ); } match node { LayoutNode::Column { children, .. } @@ -6412,19 +6459,21 @@ fn render_node_window( && context.viewport_width_known => { Some(render_column_window( - children, resolver, context, start, height, + children, &scope, context, start, height, )) } _ => Some( - render_node(node, resolver, context, intrinsic) + scope + .phase(Phase::WindowFallback) + .render(context, intrinsic, None) .map(|rendered| slice_rendered(rendered, start, height)), ), } } -fn render_column_window( - children: &[LayoutNode], - resolver: Option<&RetainedDocument>, +fn render_column_window<'a>( + children: &'a [LayoutNode], + scope: &RenderScope<'a>, context: LayoutContext, start: i64, height: i64, @@ -6434,8 +6483,8 @@ fn render_column_window( let mut offset = 0_i64; let mut parts = Vec::new(); - for child in column_leaves(children) { - let child_height = exact_rendered_height(child, resolver, context) + for child in column_leaves(scope, children) { + let child_height = exact_rendered_height(child.view.node, child.resolver, context) .ok_or_else(|| "Native layout column window has an unbounded child".to_owned())?; let child_end = offset.saturating_add(child_height); if child_end <= start { @@ -6449,18 +6498,21 @@ fn render_column_window( let child_start = start.saturating_sub(offset); let child_window_height = (child_end.min(end) - (offset + child_start)).max(0); let mut rendered = if child_start == 0 && child_window_height >= child_height { - render_node(child, resolver, context, false)? + child + .phase(Phase::WindowChildFull) + .render(context, false, None)? } else { render_node_window( - child, - resolver, + &child.phase(Phase::WindowChildPartial), context, false, child_start, child_window_height, ) .unwrap_or_else(|| { - render_node(child, resolver, context, false) + child + .phase(Phase::WindowFallback) + .render(context, false, None) .map(|rendered| slice_rendered(rendered, child_start, child_window_height)) })? }; @@ -6488,7 +6540,6 @@ fn flex_line_cross( line: &[FlexRuntimeItem<'_>], axis: FlexAxis, container_align: FlexAlign, - resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6501,7 +6552,7 @@ fn flex_line_cross( item.target, None, container_align, - resolver, + Phase::FlexCrossProbe, context, intrinsic, )? @@ -6558,7 +6609,6 @@ fn render_flex_row_line( main_gap: i64, justify: FlexAlign, align: FlexAlign, - resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6573,7 +6623,7 @@ fn render_flex_row_line( item.target, Some(line_cross), align, - resolver, + Phase::FlexFinal, context, intrinsic, )?; @@ -6596,7 +6646,6 @@ fn render_flex_column_line( main_gap: i64, justify: FlexAlign, align: FlexAlign, - resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6618,7 +6667,7 @@ fn render_flex_column_line( item.target, Some(line_cross), align, - resolver, + Phase::FlexFinal, context, intrinsic, )? @@ -6645,7 +6694,6 @@ fn render_flex_row( align: FlexAlign, align_content: FlexAlign, single_line: bool, - resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6664,7 +6712,7 @@ fn render_flex_row( } else { lines .iter() - .map(|line| flex_line_cross(line, FlexAxis::Row, align, resolver, context, intrinsic)) + .map(|line| flex_line_cross(line, FlexAxis::Row, align, context, intrinsic)) .collect::, _>>()? }; let cross_layout = @@ -6690,7 +6738,6 @@ fn render_flex_row( main_gap, justify, align, - resolver, context, intrinsic, )?); @@ -6721,7 +6768,6 @@ fn render_flex_column( align: FlexAlign, align_content: FlexAlign, single_line: bool, - resolver: Option<&RetainedDocument>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6733,9 +6779,7 @@ fn render_flex_column( } else { lines .iter() - .map(|line| { - flex_line_cross(line, FlexAxis::Column, align, resolver, context, intrinsic) - }) + .map(|line| flex_line_cross(line, FlexAxis::Column, align, context, intrinsic)) .collect::, _>>()? }; let cross_layout = @@ -6751,7 +6795,7 @@ fn render_flex_column( .enumerate() { let rendered = render_flex_column_line( - line, line_cross, main_size, main_gap, justify, align, resolver, context, intrinsic, + line, line_cross, main_size, main_gap, justify, align, context, intrinsic, )?; parts.push((rendered, line_cross)); if index + 1 < line_count { @@ -6769,7 +6813,7 @@ fn render_flex_column( } #[allow(clippy::too_many_arguments)] -fn render_flex( +fn render_flex<'a>( direction: FlexDirection, wrap: FlexWrap, justify: FlexAlign, @@ -6779,8 +6823,8 @@ fn render_flex( height: &Size, row_gap: i64, column_gap: i64, - source_items: &[FlexItem], - resolver: Option<&RetainedDocument>, + source_items: &'a [FlexItem], + scope: &RenderScope<'a>, context: LayoutContext, intrinsic: bool, ) -> Result { @@ -6801,7 +6845,7 @@ fn render_flex( &source_items[index], axis, inline_viewport, - resolver, + scope.child(LocalStep::FlexItem(index), &source_items[index].node), context, ) }) @@ -6829,7 +6873,6 @@ fn render_flex( align_items, align_content, single_line, - resolver, context, intrinsic, ), @@ -6843,20 +6886,20 @@ fn render_flex( align_items, align_content, single_line, - resolver, context, intrinsic, ), } } -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct BoxOverride { content_width: Option, content_height: Option, declared_width: Option, } +#[cfg(test)] fn render_node( node: &LayoutNode, resolver: Option<&RetainedDocument>, @@ -6873,10 +6916,17 @@ fn render_node_with_override( intrinsic: bool, size_override: Option, ) -> Result { - let node = match resolver { - Some(resolver) => resolver.resolve(node)?, - None => node, - }; + RenderScope::uncached(node, resolver).render(context, intrinsic, size_override) +} + +fn render_node_body( + scope: &RenderScope<'_>, + context: LayoutContext, + intrinsic: bool, + size_override: Option, +) -> Result { + let node = scope.view.node; + let resolver = scope.resolver; #[cfg(test)] TEST_RENDER_NODE_COUNT.with(|count| { if let Some(value) = count.get() { @@ -7026,6 +7076,7 @@ fn render_node_with_override( let mut windowed_child_start = None; let mut windowed_child_height = None; let mut child_rendered = if let Some(child) = child { + let child_scope = scope.child(LocalStep::BoxChild, child); let child_context = LayoutContext { viewport_width: if intrinsic_child { 0 @@ -7049,8 +7100,7 @@ fn render_node_with_override( windowed_child_height = Some(total_height); Some( render_node_window( - child, - resolver, + &child_scope, child_context, intrinsic || intrinsic_child, start, @@ -7059,20 +7109,14 @@ fn render_node_with_override( .expect("exact height checked above")?, ) } else { - Some(render_node( - child, - resolver, + Some(child_scope.render( child_context, intrinsic || intrinsic_child, + None, )?) } } else { - Some(render_node( - child, - resolver, - child_context, - intrinsic || intrinsic_child, - )?) + Some(child_scope.render(child_context, intrinsic || intrinsic_child, None)?) } } else { None @@ -7425,7 +7469,14 @@ fn render_node_with_override( }; let rendered = children .iter() - .map(|child| render_node(child, resolver, child_context, intrinsic)) + .enumerate() + .map(|(index, child)| { + scope.child(LocalStep::RowChild(index), child).render( + child_context, + intrinsic, + None, + ) + }) .collect::, _>>()?; let parts = rendered .into_iter() @@ -7437,8 +7488,8 @@ fn render_node_with_override( Ok(concat_horizontal_sized(parts, 0)) } LayoutNode::Column { children, .. } => { - let rendered = column_leaves(children) - .map(|child| render_node(child, resolver, context, intrinsic)) + let rendered = column_leaves(scope, children) + .map(|child| child.render(context, intrinsic, None)) .collect::, _>>()?; let maximum = rendered .iter() @@ -7492,7 +7543,7 @@ fn render_node_with_override( *row_gap, *column_gap, items, - resolver, + scope, context, intrinsic, ), @@ -7501,12 +7552,19 @@ fn render_node_with_override( // Flatten only literal Columns. In particular, a retained NodeRef remains a // child evaluation boundary even when it resolves to an identified Column. -fn column_leaves(children: &[LayoutNode]) -> impl Iterator { - let mut stack = vec![children.iter()]; +fn column_leaves<'a>( + scope: &RenderScope<'a>, + children: &'a [LayoutNode], +) -> impl Iterator> { + let mut stack = vec![(scope.clone(), children.iter().enumerate())]; std::iter::from_fn(move || loop { - match stack.last_mut()?.next() { - Some(LayoutNode::Column { children, .. }) => stack.push(children.iter()), - Some(child) => return Some(child), + let (scope, children) = stack.last_mut()?; + match children.next() { + Some((index, child @ LayoutNode::Column { children, .. })) => { + let child_scope = scope.child(LocalStep::ColumnChild(index), child); + stack.push((child_scope, children.iter().enumerate())); + } + Some((index, child)) => return Some(scope.child(LocalStep::ColumnChild(index), child)), None => { stack.pop(); } @@ -7633,7 +7691,11 @@ mod tests { serde_json::from_str(json).unwrap() } - fn cluster(text: &str, width: i64, source_template_id: Option) -> MeasuredCluster { + pub(super) fn cluster( + text: &str, + width: i64, + source_template_id: Option, + ) -> MeasuredCluster { MeasuredCluster { text: text.to_owned(), width, @@ -7644,7 +7706,7 @@ mod tests { } } - fn measured_text(lines: Vec>) -> MeasuredText { + pub(super) fn measured_text(lines: Vec>) -> MeasuredText { MeasuredText { lines: lines .into_iter() @@ -7716,7 +7778,7 @@ mod tests { node } - fn child_box( + pub(super) fn child_box( region_id: i64, child: LayoutNode, surface_template_id: Option, @@ -7766,7 +7828,7 @@ mod tests { } } - fn identified(mut node: LayoutNode, node_id: u64, revision: u64) -> LayoutNode { + pub(super) fn identified(mut node: LayoutNode, node_id: u64, revision: u64) -> LayoutNode { match &mut node { LayoutNode::Box { node_id: id, @@ -7801,7 +7863,7 @@ mod tests { node } - fn retained_document(root: LayoutNode) -> LayoutDocument { + pub(super) fn retained_document(root: LayoutNode) -> LayoutDocument { LayoutDocument { version: LAYOUT_VERSION, space_width: 1, @@ -7863,7 +7925,7 @@ mod tests { } } - fn test_context() -> LayoutContext { + pub(super) fn test_context() -> LayoutContext { LayoutContext { viewport_width: 80, viewport_width_known: true, @@ -8607,7 +8669,7 @@ mod tests { assert_eq!(joined.lines[1].break_after, Some(AtomProperties::default())); } - fn nonuniform_text(region_id: i64, widths: &[i64]) -> LayoutNode { + pub(super) fn nonuniform_text(region_id: i64, widths: &[i64]) -> LayoutNode { LayoutNode::Text { node_id: None, node_revision: None, @@ -9624,7 +9686,7 @@ mod tests { } } - fn context_test_delta(entries: Vec) -> DocumentDelta { + pub(super) fn context_test_delta(entries: Vec) -> DocumentDelta { serde_json::from_value(serde_json::json!({ "style-base-count": 0, "styles-append": [], diff --git a/native/src/lib.rs b/native/src/lib.rs index 6fc482f..44c82ad 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -3,7 +3,8 @@ pub mod sequence; use layout::{ encode_error_tape, DocumentDelta, LayoutContext, LayoutDocument, LayoutTape, RetainedDocument, - TapeIdentity, TapeOutputOptions, MAX_LAYOUT_DIMENSION, MIN_TAPE_BYTES, + RetainedFrame, SourceChanges, TapeIdentity, TapeOutputOptions, MAX_LAYOUT_DIMENSION, + MIN_TAPE_BYTES, }; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet, VecDeque}; @@ -379,6 +380,7 @@ enum JobPayload { Echo(Vec), Layout { document: LayoutSource, + source_changes: Option, context: LayoutContext, root_width: i64, root_width_override: bool, @@ -426,6 +428,27 @@ impl LayoutSource { Self::Retained(document) => document.styles(), } } + + fn render_target( + &self, + context: LayoutContext, + root_width: Option, + previous: Option<&RetainedFrame>, + changes: Option<&SourceChanges>, + ) -> Result<(LayoutTape, Option>), String> { + match self { + Self::Full(document) => { + if changes.is_some() { + return Err("Native source changes require a retained document".to_owned()); + } + Ok((document.layout_tape(context, root_width)?, None)) + } + Self::Retained(document) => { + let frame = document.render_frame(previous, changes, context, root_width)?; + Ok((frame.materialize_tape(), Some(frame))) + } + } + } } #[derive(Clone, Debug)] @@ -444,6 +467,7 @@ struct ConfirmedBaseline { document: LayoutSource, document_revision: u64, tape: LayoutTape, + retained_frame: Option>, styles: Vec, } @@ -453,6 +477,7 @@ struct PendingBaseline { document: LayoutSource, document_revision: u64, tape: LayoutTape, + retained_frame: Option>, styles: Vec, } @@ -471,6 +496,7 @@ struct RenderedJob { resolver_lookups: u64, atom_plan_work: layout::AtomPlanWork, line_plan_work: layout::LinePlanWork, + eval_work: layout::EvalWork, } #[derive(Default)] @@ -527,6 +553,7 @@ struct RuntimeState { source_change_work: layout::SourceChangeWork, atom_plan_work: layout::AtomPlanWork, line_plan_work: layout::LinePlanWork, + eval_work: layout::EvalWork, confirmed_baseline: Option>, } @@ -607,6 +634,7 @@ struct SessionStats { source_change_work: layout::SourceChangeWork, atom_plan_work: layout::AtomPlanWork, line_plan_work: layout::LinePlanWork, + eval_work: layout::EvalWork, pending_baselines: usize, confirmed_baseline: bool, confirmed_baseline_bytes: usize, @@ -682,6 +710,7 @@ impl Session { source_change_work: layout::SourceChangeWork::default(), atom_plan_work: layout::AtomPlanWork::default(), line_plan_work: layout::LinePlanWork::default(), + eval_work: layout::EvalWork::default(), confirmed_baseline, }), readiness_channel: Mutex::new(None), @@ -807,6 +836,7 @@ impl Session { frame, document_base_revision, document_target_revision, + None, )? } else { let payload = frame @@ -955,6 +985,7 @@ impl Session { .unwrap_or_else(|poison| poison.into_inner()) .confirmed_baseline .clone(); + let mut source_changes = None; let (document, input_stats) = match (batch.document, batch.document_delta) { (Some(_), Some(_)) => { return Err( @@ -1029,9 +1060,9 @@ impl Session { "Native retained source changes document identity mismatch".to_owned() ); } - // Consume exact source facts for input accounting. The renderer - // still evaluates the target normally; no history-bearing change - // object is stored in a job, document or confirmed baseline. + // Account for exact input work, then carry these facts only for + // this render job. Documents and baselines never store the + // descriptor's references to both versions. let mut source_change_work = applied.stats.source_work; let mut previous_owner = None; applied @@ -1047,6 +1078,7 @@ impl Session { let (styles, property_templates) = applied.changes.registry_ranges(); source_change_work.styles_appended = styles.len() as u64; source_change_work.property_templates_added = property_templates.len() as u64; + source_changes = Some(applied.changes); ( LayoutSource::Retained(applied.document), DocumentInputStats { @@ -1104,6 +1136,7 @@ impl Session { frame, document_base_revision, document_target_revision, + source_changes, )?; let output = render_layout_payload( prepared.payload, @@ -1132,6 +1165,7 @@ impl Session { state.document_resolver_lookups += output.resolver_lookups; state.atom_plan_work.accumulate(output.atom_plan_work); state.line_plan_work.accumulate(output.line_plan_work); + state.eval_work.accumulate(output.eval_work); state.document_parses += input_stats.parses; state.document_validations += input_stats.validations; state.document_reuses += input_stats.reuses; @@ -1176,6 +1210,7 @@ impl Session { document: pending.document, document_revision: pending.document_revision, tape: pending.tape, + retained_frame: pending.retained_frame, styles: pending.styles, })); Ok(true) @@ -1253,6 +1288,8 @@ impl Session { .lock() .unwrap_or_else(|poison| poison.into_inner()); let pending_baselines = state.pending_baselines.len(); + // Compatibility field: the shallow tape container only, not retained + // plan allocations, shared heap memory or a cache memory bound. let confirmed_baseline_bytes = state .confirmed_baseline .as_ref() @@ -1286,6 +1323,7 @@ impl Session { source_change_work: state.source_change_work, atom_plan_work: state.atom_plan_work, line_plan_work: state.line_plan_work, + eval_work: state.eval_work, pending_baselines, confirmed_baseline: state.confirmed_baseline.is_some(), confirmed_baseline_bytes, @@ -1389,6 +1427,7 @@ fn prepare_layout_job( frame: ControlFrame, document_base_revision: u64, document_target_revision: u64, + source_changes: Option, ) -> Result { layout::reset_resolver_lookups(); if frame.payload.is_some() { @@ -1462,6 +1501,7 @@ fn prepare_layout_job( delay_ms: frame.delay_ms, payload: JobPayload::Layout { document: document.clone(), + source_changes, context, root_width, root_width_override: frame.root_width_override, @@ -1498,9 +1538,11 @@ fn render_layout_payload( resolver_lookups: 0, atom_plan_work: layout::AtomPlanWork::default(), line_plan_work: layout::LinePlanWork::default(), + eval_work: layout::EvalWork::default(), }, JobPayload::Layout { document, + source_changes, context, root_width, root_width_override, @@ -1548,6 +1590,7 @@ fn render_layout_payload( Vec, LayoutTape, Vec, + Option>, bool, u64, u64, @@ -1557,8 +1600,19 @@ fn render_layout_payload( layout::reset_resolver_lookups(); layout::reset_atom_plan_work(); layout::reset_line_plan_work(); + layout::reset_eval_work(); let result = catch_unwind(AssertUnwindSafe(|| -> LayoutRenderOutcome { let target_styles = document.styles()?; + let render_target = || { + document.render_target( + context, + root_width_override.then_some(root_width), + confirmed_baseline + .as_ref() + .and_then(|baseline| baseline.retained_frame.as_deref()), + source_changes.as_ref(), + ) + }; if let Some(base_context) = base_context { let base_identity = BaselineIdentity { context: base_context, @@ -1582,8 +1636,7 @@ fn render_layout_payload( }) .cloned(); if require_confirmed_patch_base && base_hit.is_none() { - let target = document - .layout_tape(context, root_width_override.then_some(root_width))?; + let (target, retained_frame) = render_target()?; let bytes = layout::encode_layout_tape( target.clone(), &target_styles, @@ -1591,7 +1644,7 @@ fn render_layout_payload( output.root_metadata, output.max_bytes, )?; - return Ok((bytes, target, target_styles, false, 0, 1)); + return Ok((bytes, target, target_styles, retained_frame, false, 0, 1)); } let (old, baseline_hit, base_renders) = if let Some(baseline) = base_hit { (baseline.tape.clone(), true, 0) @@ -1605,8 +1658,7 @@ fn render_layout_payload( 1, ) }; - let target = - document.layout_tape(context, root_width_override.then_some(root_width))?; + let (target, retained_frame) = render_target()?; let bytes = layout::encode_layout_patch_tape( old, target.clone(), @@ -1615,10 +1667,17 @@ fn render_layout_payload( output.root_metadata, output.max_bytes, )?; - Ok((bytes, target, target_styles, baseline_hit, base_renders, 1)) + Ok(( + bytes, + target, + target_styles, + retained_frame, + baseline_hit, + base_renders, + 1, + )) } else { - let target = - document.layout_tape(context, root_width_override.then_some(root_width))?; + let (target, retained_frame) = render_target()?; let bytes = layout::encode_layout_tape( target.clone(), &target_styles, @@ -1626,32 +1685,41 @@ fn render_layout_payload( output.root_metadata, output.max_bytes, )?; - Ok((bytes, target, target_styles, false, 0, 1)) + Ok((bytes, target, target_styles, retained_frame, false, 0, 1)) } })); let resolver_lookups = validation_resolver_lookups.saturating_add(layout::resolver_lookups()); let atom_plan_work = layout::atom_plan_work(); let line_plan_work = layout::line_plan_work(); + let eval_work = layout::eval_work(); match result { - Ok(Ok((bytes, tape, styles, baseline_hit, base_renders, target_renders))) => { - RenderedJob { - bytes, - pending: Some(PendingBaseline { - confirmed_identity: pending_identity, - document: document.clone(), - document_revision: document_target_revision, - tape, - styles, - }), - baseline_hit, - base_renders, - target_renders, - resolver_lookups, - atom_plan_work, - line_plan_work, - } - } + Ok(Ok(( + bytes, + tape, + styles, + retained_frame, + baseline_hit, + base_renders, + target_renders, + ))) => RenderedJob { + bytes, + pending: Some(PendingBaseline { + confirmed_identity: pending_identity, + document: document.clone(), + document_revision: document_target_revision, + tape, + retained_frame, + styles, + }), + baseline_hit, + base_renders, + target_renders, + resolver_lookups, + atom_plan_work, + line_plan_work, + eval_work, + }, Ok(Err(error)) => RenderedJob { bytes: encode_error_tape(identity, &error, max_result_bytes), pending: None, @@ -1661,6 +1729,7 @@ fn render_layout_payload( resolver_lookups, atom_plan_work, line_plan_work, + eval_work, }, Err(_) => RenderedJob { bytes: encode_error_tape(identity, "native layout panicked", max_result_bytes), @@ -1671,6 +1740,7 @@ fn render_layout_payload( resolver_lookups, atom_plan_work, line_plan_work, + eval_work, }, } } @@ -1706,7 +1776,7 @@ fn render_proof(payload: &[u8]) -> Result, String> { return Err("Native proof render cannot contain delay-ms".to_owned()); } let document = LayoutSource::Full(Arc::new(document)); - let prepared = prepare_layout_job(&document, frame, 0, 1)?; + let prepared = prepare_layout_job(&document, frame, 0, 1, None)?; let output = render_layout_payload( prepared.payload, SYNC_RENDER_SESSION_ID, @@ -1822,6 +1892,7 @@ fn worker_loop(shared: Arc) { state.document_resolver_lookups += output.resolver_lookups; state.atom_plan_work.accumulate(output.atom_plan_work); state.line_plan_work.accumulate(output.line_plan_work); + state.eval_work.accumulate(output.eval_work); state.results.insert( (job.generation, job.key), ResultEntry { @@ -2569,6 +2640,149 @@ mod tests { session.stop(true); } + #[test] + fn retained_layout_reuse_does_not_bypass_wire_base_proof() { + let session = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = identified_proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"root-width":80,"runtime-revision":0,"context-hash":77}]"#, + ); + session.render_sync(1, &first).unwrap(); + assert!(session.confirm(1, 1, 1).unwrap()); + let before = session.stats(); + // The source and layout request still match, but receiver context + // identity does not: a layout hit must still publish a full tape. + let second = retained_layout_payload( + 1, + r#"[{"key":2,"viewport-width":80,"viewport-height":10,"root-width":80,"patch":true,"base-viewport-width":80,"base-viewport-height":10,"base-root-width":80,"runtime-revision":1,"context-hash":78}]"#, + ); + let tape = session.render_sync(2, &second).unwrap(); + let after = session.stats(); + assert_ne!(u16::from_le_bytes(tape[6..8].try_into().unwrap()) & 1, 0); + assert!(!tape_patch_p(&tape)); + assert_eq!(after.baseline_hits, before.baseline_hits); + assert_eq!(after.base_renders, before.base_renders); + assert_eq!(after.eval_work.hits - before.eval_work.hits, 1); + assert_eq!(after.eval_work.body_runs, before.eval_work.body_runs); + assert!(session.confirm(2, 2, 2).unwrap()); + session.stop(true); + } + + #[test] + fn retained_frame_failures_preserve_the_confirmed_fork_and_allow_retry() { + // Failed encoding or confirmation must not promote source/output or + // retain the rejected candidate's evaluation tree. + let parent = Session::new(1, 4, 4, 2048).unwrap(); + let first = identified_proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"root-width":80,"runtime-revision":0,"context-hash":77}]"#, + ); + let first_tape = parent.render_sync(1, &first).unwrap(); + assert_ne!( + u16::from_le_bytes(first_tape[6..8].try_into().unwrap()) & 1, + 0 + ); + assert!(parent.confirm(1, 1, 1).unwrap()); + let child = parent.fork_confirmed().unwrap(); + let original = parent + .shared + .state + .lock() + .unwrap() + .confirmed_baseline + .clone() + .unwrap(); + let original_frame = original.retained_frame.as_ref().unwrap(); + let delta = |key, text: &str| { + serde_json::to_vec(&serde_json::json!({ + "version": 1, "document-base-revision": 1, "document-target-revision": 2, + "document-delta": { + "style-base-count": 0, "styles-append": [], + "property-template-base-count": 0, "property-template-target-count": 0, + "entries": [{"node-id": 1, "expected-revision": 7, "target-revision": 8, + "slot-patches": [{"slot": 0, "local": { + "content": {"lines": [{"clusters": [{"text": text, "width": 8, + "cjk": false, "space": false}]}]} + }}]}] + }, + "frames": [{"key": key, "viewport-width": 80, "viewport-height": 10, + "root-width": 80, "patch": true, "base-viewport-width": 80, + "base-viewport-height": 10, "base-root-width": 80, + "runtime-revision": 1, "context-hash": 77}] + })) + .unwrap() + }; + let oversized = child.render_sync(1, &delta(2, &"x".repeat(4096))).unwrap(); + assert_eq!( + u16::from_le_bytes(oversized[6..8].try_into().unwrap()) & 1, + 0 + ); + assert_eq!(child.stats().pending_baselines, 0); + assert!(!child.confirm(1, 2, 2).unwrap()); + let before = child + .shared + .state + .lock() + .unwrap() + .confirmed_baseline + .clone() + .unwrap(); + assert!(Arc::ptr_eq(&original, &before)); + + let retry = child.render_sync(2, &delta(3, "y")).unwrap(); + assert_ne!(u16::from_le_bytes(retry[6..8].try_into().unwrap()) & 1, 0); + assert_eq!(child.stats().pending_baselines, 1); + let rejected_frame = { + let state = child.shared.state.lock().unwrap(); + let candidate = state.pending_baselines.get(&(2, 3)).unwrap(); + let frame = candidate.retained_frame.as_ref().unwrap(); + assert!(!Arc::ptr_eq(original_frame, frame)); + Arc::downgrade(frame) + }; + assert!(child.confirm(2, 3, 99).is_err()); + assert_eq!(child.stats().pending_baselines, 0); + assert!(rejected_frame.upgrade().is_none()); + let after = child + .shared + .state + .lock() + .unwrap() + .confirmed_baseline + .clone() + .unwrap(); + assert!(Arc::ptr_eq(&original, &after)); + + let retry = child.render_sync(3, &delta(4, "z")).unwrap(); + assert_ne!(u16::from_le_bytes(retry[6..8].try_into().unwrap()) & 1, 0); + assert!(child.confirm(3, 4, 2).unwrap()); + let confirmed = child + .shared + .state + .lock() + .unwrap() + .confirmed_baseline + .clone() + .unwrap(); + assert!(!Arc::ptr_eq(&original, &confirmed)); + assert!(!Arc::ptr_eq( + original_frame, + confirmed.retained_frame.as_ref().unwrap() + )); + assert_eq!(confirmed.document_revision, 2); + assert_eq!(original.document_revision, 1); + assert!(Arc::ptr_eq( + &original, + parent + .shared + .state + .lock() + .unwrap() + .confirmed_baseline + .as_ref() + .unwrap() + )); + parent.stop(true); + child.stop(true); + } + #[test] fn confirmed_fork_shares_only_immutable_baseline() { let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); @@ -2857,6 +3071,30 @@ mod tests { retry.stop(true); } + #[test] + fn cancellation_releases_retained_candidate_frame() { + let session = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let payload = identified_proof_layout_payload( + r#"[{"key":1,"viewport-width":80,"viewport-height":10,"runtime-revision":0}]"#, + ); + session.render_sync(1, &payload).unwrap(); + let cancelled_frame = { + let state = session.shared.state.lock().unwrap(); + Arc::downgrade( + state.pending_baselines[&(1, 1)] + .retained_frame + .as_ref() + .unwrap(), + ) + }; + session.cancel(1); + assert!(cancelled_frame.upgrade().is_none()); + assert_eq!(session.stats().pending_baselines, 0); + assert!(!session.stats().confirmed_baseline); + assert!(!session.confirm(1, 1, 1).unwrap()); + session.stop(true); + } + #[test] fn async_omit_uses_the_latest_synchronously_confirmed_document() { let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); diff --git a/native/src/source_topology.rs b/native/src/source_topology.rs index 7e7748a..8b22954 100644 --- a/native/src/source_topology.rs +++ b/native/src/source_topology.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use std::sync::Arc; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(super) enum LocalStep { BoxChild, RowChild(usize), @@ -12,7 +12,7 @@ pub(super) enum LocalStep { } /// A local structural address stops before crossing an identified node reference. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub(super) struct SourceAddress { pub(super) owner_id: u64, pub(super) path: Arc<[LocalStep]>,