diff --git a/docs/maintainer/ebox-current-implementation-reference.en.md b/docs/maintainer/ebox-current-implementation-reference.en.md index 1385621..9f7b909 100644 --- a/docs/maintainer/ebox-current-implementation-reference.en.md +++ b/docs/maintainer/ebox-current-implementation-reference.en.md @@ -104,6 +104,14 @@ Caller-owned Source Tree - `owner-rerender` is broader than `span-patch`, which is broader than `paint-patch`. - Buffer coordinates belong to the generation that produced them and must be refreshed after mutation. - Grid uses the normal measurement and rendering pipeline. Native reflow may reject an ineligible tree and must fall back to Elisp without changing correctness. +- A confirmed retained-native frame owns an immutable layout document together + with its exact document revision. Candidate-session forks share that document. + A synchronous frame may omit the document only when its base and target + document revisions are equal and match the confirmed document, and its TP + runtime revision matches the confirmed frame. A changed IR sends a complete + replacement document with the next document revision; confirmation promotes + the candidate document atomically, while failure or cancellation leaves the + parent session unchanged. - A full declarative update continuing a committed native frame computes selector-local styles for every candidate node before stable native eligibility. The `:styles-prepared-p` certificate prevents replacement diff --git a/docs/maintainer/ebox-current-implementation-reference.zh.md b/docs/maintainer/ebox-current-implementation-reference.zh.md index da48302..5e1a47b 100644 --- a/docs/maintainer/ebox-current-implementation-reference.zh.md +++ b/docs/maintainer/ebox-current-implementation-reference.zh.md @@ -103,6 +103,11 @@ active 合同还覆盖 `Makefile`、`.github/workflows/ci.yml`、`tests/ebox-cor - `owner-rerender` 范围大于 `span-patch`,`span-patch` 大于 `paint-patch`。 - Buffer 坐标属于生成它的 generation,变更后必须重新获取。 - Grid 使用普通测量与渲染流水线;native reflow 可以拒绝不适合的树并回退到 Elisp,正确性不变。 +- 已确认的 retained-native frame 持有不可变布局文档及其精确文档 revision;候选 + session 的 fork 共享该文档。同步 frame 只有在文档 base/target revision 相等且 + 命中 confirmed 文档、同时 TP runtime revision 命中 confirmed frame 时,才可 + 省略文档。IR 变化必须携带下一 document revision 的完整 replacement document; + confirm 原子晋升候选文档,失败或取消不修改 parent session。 - 只有继续已提交 native frame 的 full declarative update,才会在 stable native eligibility 前为全部 candidate node 计算 selector-local style;生成的 `:styles-prepared-p` certificate 防止 replacement runtime 丢失 computed style, diff --git a/ebox-native-commit.el b/ebox-native-commit.el index 51684a4..5d15ff7 100644 --- a/ebox-native-commit.el +++ b/ebox-native-commit.el @@ -22,6 +22,8 @@ "ebox-native-reflow" (&rest arguments)) (declare-function ebox-native-reflow-fork-session "ebox-native-reflow" (session &rest options)) +(declare-function ebox-native-reflow-release-session + "ebox-native-reflow" (session)) (declare-function ebox-native-reflow-confirm-native-frame "ebox-native-reflow" (session generation key confirmed-revision)) @@ -637,8 +639,17 @@ publication transaction can still roll back." (defun ebox-native-commit--downgrade-candidate (state reason) "Downgrade STATE after native rendering fails for diagnostic REASON." - (dolist (key ebox-native-commit--failed-render-state-keys) - (cl-remf state key)) + (let ((session (plist-get state :native-sync-session))) + (dolist (key ebox-native-commit--failed-render-state-keys) + (cl-remf state key)) + ;; Once native execution begins STATE owns an isolated candidate session. + ;; Release it before removing the only reference, while containing cleanup + ;; faults so the ordinary semantic fallback remains the primary result. + (when session + (let ((inhibit-quit t) (quit-flag nil)) + (condition-case nil + (ebox-native-reflow-release-session session) + ((error quit) nil))))) (plist-put state :projection-kind nil) ;; Surface consumes this once into the transaction report, then removes it ;; from runtime state. It never participates in projection eligibility. diff --git a/ebox-native-reflow.el b/ebox-native-reflow.el index 877a806..43c67dc 100644 --- a/ebox-native-reflow.el +++ b/ebox-native-reflow.el @@ -95,7 +95,7 @@ (defvar ebox--flex-content-min-width-table) -(defconst ebox-native-reflow-abi-version "10:7:12" +(defconst ebox-native-reflow-abi-version "11:7:12" "Version tuple shared by the native module, layout IR, and render tape.") (defconst ebox-native-reflow--minimum-rust-version "1.82.0" @@ -2222,9 +2222,10 @@ backend has a matching two-dimensional layout contract." (and (listp node) (plist-get node :ebox-type)))))) (defun ebox-native-reflow--layout-package-from-root - (root styles &optional property-templates) + (root styles &optional property-templates document-revision) "Return one immutable native package from compiled ROOT and STYLES. -PROPERTY-TEMPLATES are Emacs-owned plists addressed by opaque native ids." +PROPERTY-TEMPLATES are Emacs-owned plists addressed by opaque native ids. +DOCUMENT-REVISION is the exact retained-input revision, defaulting to one." (list :document (list :version 2 :space-width (ebox--space-pixel-width) @@ -2246,9 +2247,45 @@ PROPERTY-TEMPLATES are Emacs-owned plists addressed by opaque native ids." (plist-get descriptor :face))))) styles)) :root root) + :document-revision (or document-revision 1) :styles (vconcat styles) :property-templates (vconcat property-templates))) +(defun ebox-native-reflow--vector-elements-eq-p (left right) + "Return non-nil when LEFT and RIGHT contain the same objects in order." + (and (vectorp left) + (vectorp right) + (= (length left) (length right)) + (cl-loop for index below (length left) + always (eq (aref left index) (aref right index))))) + +(defun ebox-native-reflow--reuse-exact-layout-package (old candidate) + "Return OLD when CANDIDATE has exactly the same immutable layout input. +Otherwise return CANDIDATE with the next document revision." + (let ((old-document (plist-get old :document)) + (candidate-document (plist-get candidate :document))) + (if (and old old-document candidate-document + (eq (plist-get old-document :root) + (plist-get candidate-document :root)) + (equal (plist-get old-document :version) + (plist-get candidate-document :version)) + (equal (plist-get old-document :space-width) + (plist-get candidate-document :space-width)) + (equal (plist-get old-document :style-count) + (plist-get candidate-document :style-count)) + (equal (plist-get old-document :property-template-count) + (plist-get candidate-document + :property-template-count)) + (ebox-native-reflow--vector-elements-eq-p + (plist-get old :styles) (plist-get candidate :styles)) + (ebox-native-reflow--vector-elements-eq-p + (plist-get old :property-templates) + (plist-get candidate :property-templates))) + old + (let ((replacement (copy-sequence candidate))) + (plist-put replacement :document-revision + (1+ (or (plist-get old :document-revision) 0))))))) + (defun ebox-native-reflow--buffer-display-signature (buffer) "Return BUFFER's live canonical Surface display capability." (with-current-buffer buffer @@ -2610,11 +2647,15 @@ per call, and no call recursively visits the captured Ebox tree." (compile-current current)))) (when retained-fast-p (seed-fragment node))) - (let ((package - (ebox-native-reflow--layout-package-from-root - (ebox-native-reflow--compiled-scene-child fragments node) - ebox-native-reflow--compile-styles - ebox-native-reflow--compile-property-templates))) + (let* ((candidate + (ebox-native-reflow--layout-package-from-root + (ebox-native-reflow--compiled-scene-child fragments node) + ebox-native-reflow--compile-styles + ebox-native-reflow--compile-property-templates)) + (package + (ebox-native-reflow--reuse-exact-layout-package + (ebox-native-reflow-session-layout-package session) + candidate))) (setf (ebox-native-reflow-session-layout-fragment-cache session) new-cache (ebox-native-reflow-session-layout-fragment-revision session) @@ -2639,8 +2680,11 @@ per call, and no call recursively visits the captured Ebox tree." (encode-coding-string (json-encode control) 'utf-8-unix))))) -(defun ebox-native-reflow--layout-control-json (document frames) - "Return strict control JSON for optional layout DOCUMENT and frame contexts." +(defun ebox-native-reflow--layout-control-json + (document frames &optional document-base-revision + document-target-revision) + "Return strict control JSON for optional layout DOCUMENT and FRAMES. +DOCUMENT-BASE-REVISION and DOCUMENT-TARGET-REVISION bind retained input." (unless (and (listp frames) frames) (error "Native reflow requires at least one layout frame")) (let ((control @@ -2738,6 +2782,14 @@ per call, and no call recursively visits the captured Ebox tree." :base-root-width-override (if base-root-width-override-p t :false)))))) frames))))) + (when document-base-revision + (setq control + (plist-put control :document-base-revision + document-base-revision))) + (when document-target-revision + (setq control + (plist-put control :document-target-revision + document-target-revision))) (when document (setq control (plist-put control :document document))) (ebox-native-reflow--serialize-layout-control control))) @@ -2748,22 +2800,29 @@ per call, and no call recursively visits the captured Ebox tree." LAYOUT-PACKAGE may reuse a caller-validated document for the same NODE." (unless (and (integerp generation) (>= generation 0)) (error "Native reflow generation must be a nonnegative integer")) - (let* ((package (or layout-package - (ebox-native-reflow--compile-layout-package - node - (ebox-native-reflow-session-styles session) - (plist-get - (ebox-native-reflow-session-layout-package session) - :property-templates)))) + (let* ((old-package (ebox-native-reflow-session-layout-package session)) + (candidate-package + (or layout-package + (ebox-native-reflow--compile-layout-package + node + (ebox-native-reflow-session-styles session) + (plist-get old-package :property-templates)))) + (package + (ebox-native-reflow--reuse-exact-layout-package + old-package candidate-package)) (register-layout-p (not (eq package - (ebox-native-reflow-session-layout-package session)))) + old-package))) + (base-revision (or (plist-get old-package :document-revision) 0)) + (target-revision (or (plist-get package :document-revision) + (1+ base-revision))) (accepted (ebox-native--module-submit (ebox-native-reflow--live-handle session) generation (ebox-native-reflow--layout-control-json - (and register-layout-p (plist-get package :document)) frames)))) + (and register-layout-p (plist-get package :document)) frames + base-revision target-revision)))) (setf (ebox-native-reflow-session-generation session) generation) (setf (ebox-native-reflow-session-styles session) (plist-get package :styles)) @@ -3896,7 +3955,9 @@ root effect metadata required by a thin host commit." (unless (and (ebox-native-reflow-session-p session) (fboundp 'ebox-native--module-render-session-frame)) (error "Native retained frame execution is unavailable")) - (let* ((package + (let* ((old-package + (ebox-native-reflow-session-layout-package session)) + (candidate-package (or layout-package (if state (ebox-native-reflow--compile-retained-layout-package @@ -3907,9 +3968,16 @@ root effect metadata required by a thin host commit." (plist-get (ebox-native-reflow-session-layout-package session) :property-templates))))) + (package + (ebox-native-reflow--reuse-exact-layout-package + old-package candidate-package)) (generation (1+ (ebox-native-reflow-session-generation session))) (control-frame (copy-sequence frame)) - (native-key (or (plist-get control-frame :key) 1))) + (native-key (or (plist-get control-frame :key) 1)) + (reuse-document-p (eq package old-package)) + (base-revision (or (plist-get old-package :document-revision) 0)) + (target-revision (or (plist-get package :document-revision) + (1+ base-revision)))) (setq control-frame (plist-put control-frame :key native-key)) (let ((result (ebox-native-reflow--materialize-module-frame @@ -3917,7 +3985,8 @@ root effect metadata required by a thin host commit." (ebox-native-reflow--live-handle session) generation (ebox-native-reflow--layout-control-json - (plist-get package :document) (list control-frame))) + (unless reuse-document-p (plist-get package :document)) + (list control-frame) base-revision target-revision)) package control-frame generation))) (setf (ebox-native-reflow-session-generation session) generation (ebox-native-reflow-session-layout-package session) package diff --git a/native/c/ebox_module.c b/native/c/ebox_module.c index 946c7aa..c4ac048 100644 --- a/native/c/ebox_module.c +++ b/native/c/ebox_module.c @@ -164,7 +164,7 @@ ebox_module_version(emacs_env *env, ptrdiff_t nargs, emacs_value *args, (void) nargs; (void) args; (void) data; - static const char version[] = "10:7:12"; + static const char version[] = "11:7:12"; return env->make_string(env, version, (ptrdiff_t) (sizeof version - 1)); } diff --git a/native/src/lib.rs b/native/src/lib.rs index b2a679a..de40b2b 100644 --- a/native/src/lib.rs +++ b/native/src/lib.rs @@ -324,6 +324,10 @@ struct ControlBatch { version: u32, #[serde(default)] document: Option, + #[serde(default, rename = "document-base-revision")] + document_base_revision: Option, + #[serde(default, rename = "document-target-revision")] + document_target_revision: Option, frames: Vec, } @@ -382,6 +386,8 @@ enum JobPayload { context_hash: i64, complete: bool, root_metadata: bool, + document_base_revision: u64, + document_target_revision: u64, }, } @@ -398,6 +404,8 @@ struct BaselineIdentity { #[derive(Clone, Debug)] struct ConfirmedBaseline { identity: BaselineIdentity, + document: Arc, + document_revision: u64, tape: LayoutTape, styles: Vec, } @@ -405,6 +413,8 @@ struct ConfirmedBaseline { #[derive(Debug)] struct PendingBaseline { confirmed_identity: BaselineIdentity, + document: Arc, + document_revision: u64, tape: LayoutTape, styles: Vec, } @@ -450,6 +460,9 @@ struct RuntimeState { baseline_misses: u64, base_renders: u64, target_renders: u64, + document_parses: u64, + document_validations: u64, + document_reuses: u64, confirmed_baseline: Option>, } @@ -500,6 +513,9 @@ struct SessionStats { baseline_misses: u64, base_renders: u64, target_renders: u64, + document_parses: u64, + document_validations: u64, + document_reuses: u64, pending_baselines: usize, confirmed_baseline: bool, confirmed_baseline_bytes: usize, @@ -562,6 +578,9 @@ impl Session { baseline_misses: 0, base_renders: 0, target_renders: 0, + document_parses: 0, + document_validations: 0, + document_reuses: 0, confirmed_baseline, }), readiness_channel: Mutex::new(None), @@ -586,10 +605,17 @@ impl Session { } } } + let layout_document = shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .confirmed_baseline + .as_ref() + .map(|baseline| Arc::clone(&baseline.document)); Ok(Box::new(Self { shared, workers: Mutex::new(Some(handles)), - layout_document: Mutex::new(None), + layout_document: Mutex::new(layout_document), worker_count, })) } @@ -623,16 +649,41 @@ impl Session { let layout_requested = batch.document.is_some() || batch.frames.iter().any(|frame| frame.payload.is_none()); let register_document = batch.document.is_some(); + let confirmed_baseline = self + .shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .confirmed_baseline + .clone(); + let confirmed_document_revision = confirmed_baseline + .as_ref() + .map_or(0, |baseline| baseline.document_revision); + let document_base_revision = batch + .document_base_revision + .unwrap_or(confirmed_document_revision); + let document_target_revision = match batch.document_target_revision { + Some(revision) => revision, + None if register_document => document_base_revision + .checked_add(1) + .ok_or_else(|| "Native layout document revision overflow".to_owned())?, + None => document_base_revision, + }; let document = match batch.document { Some(document) => { document.validate()?; Some(Arc::new(document)) } None if layout_requested => Some( - self.layout_document - .lock() - .unwrap_or_else(|poison| poison.into_inner()) - .clone() + confirmed_baseline + .as_ref() + .map(|baseline| Arc::clone(&baseline.document)) + .or_else(|| { + self.layout_document + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .clone() + }) .ok_or_else(|| { "Native layout frames require a registered document".to_owned() })?, @@ -648,7 +699,12 @@ impl Session { let mut prepared = Vec::with_capacity(batch.frames.len()); for frame in batch.frames { let job = if let Some(document) = &document { - prepare_layout_job(document, frame)? + prepare_layout_job( + document, + frame, + document_base_revision, + document_target_revision, + )? } else { let payload = frame .payload @@ -780,13 +836,75 @@ impl Session { return Err("Native reflow session is closed".to_owned()); } let batch = parse_control_batch(payload)?; - let document = batch - .document - .ok_or_else(|| "Native retained render requires an inline document".to_owned())?; - document.validate()?; if batch.frames.len() != 1 { return Err("Native retained render requires exactly one frame".to_owned()); } + let document_base_revision = batch + .document_base_revision + .ok_or_else(|| "Native retained render requires exact document revisions".to_owned())?; + let document_target_revision = batch + .document_target_revision + .ok_or_else(|| "Native retained render requires exact document revisions".to_owned())?; + let confirmed = self + .shared + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .confirmed_baseline + .clone(); + let (document, document_parses, document_validations, document_reuses) = match batch + .document + { + Some(document) => { + if document_base_revision.checked_add(1) != Some(document_target_revision) { + return Err( + "Native retained document replacement must advance one revision".to_owned(), + ); + } + match &confirmed { + Some(baseline) if baseline.document_revision != document_base_revision => { + return Err( + "Native retained document base revision does not match confirmed state" + .to_owned(), + ); + } + None if document_base_revision != 0 => { + return Err( + "Native retained document bootstrap must start at revision zero" + .to_owned(), + ); + } + _ => {} + } + document.validate()?; + (Arc::new(document), 1, 1, 0) + } + None => { + if document_base_revision != document_target_revision { + return Err( + "Native retained document reuse requires equal base and target revisions" + .to_owned(), + ); + } + let baseline = confirmed.as_ref().ok_or_else(|| { + "Native retained document reuse requires a confirmed baseline".to_owned() + })?; + if baseline.document_revision != document_target_revision { + return Err( + "Native retained document revision does not match confirmed state" + .to_owned(), + ); + } + let frame = batch.frames.first().expect("checked retained frame"); + if frame.runtime_revision != baseline.identity.runtime_revision { + return Err( + "Native retained runtime revision does not match confirmed state" + .to_owned(), + ); + } + (Arc::clone(&baseline.document), 0, 0, 1) + } + }; let frame = batch .frames .into_iter() @@ -795,15 +913,12 @@ impl Session { if !frame.complete || frame.delay_ms != 0 { return Err("Native retained render requires one immediate complete frame".to_owned()); } - let document = Arc::new(document); - let prepared = prepare_layout_job(&document, frame)?; - let confirmed = self - .shared - .state - .lock() - .unwrap_or_else(|poison| poison.into_inner()) - .confirmed_baseline - .clone(); + let prepared = prepare_layout_job( + &document, + frame, + document_base_revision, + document_target_revision, + )?; let output = render_layout_payload( prepared.payload, self.shared.id, @@ -828,6 +943,9 @@ impl Session { } state.base_renders += output.base_renders; state.target_renders += output.target_renders; + state.document_parses += document_parses; + state.document_validations += document_validations; + state.document_reuses += document_reuses; state.pending_baselines.clear(); if let Some(pending) = output.pending { state @@ -857,6 +975,8 @@ impl Session { identity.runtime_revision = confirmed_revision; state.confirmed_baseline = Some(Arc::new(ConfirmedBaseline { identity, + document: pending.document, + document_revision: pending.document_revision, tape: pending.tape, styles: pending.styles, })); @@ -955,6 +1075,9 @@ impl Session { baseline_misses: state.baseline_misses, base_renders: state.base_renders, target_renders: state.target_renders, + document_parses: state.document_parses, + document_validations: state.document_validations, + document_reuses: state.document_reuses, pending_baselines, confirmed_baseline: state.confirmed_baseline.is_some(), confirmed_baseline_bytes, @@ -1043,24 +1166,23 @@ fn checked_root_width(frame_key: i64, root_width: i64, label: &str) -> Result bool { - baseline.identity.context == context - && baseline.identity.root_width == root_width - && baseline.identity.root_width_override == root_width_override - && baseline.identity.runtime_revision == runtime_revision - && baseline.identity.context_hash == context_hash - && baseline.identity.complete == complete + baseline.document_revision == document_base_revision + && baseline.identity.context == identity.context + && baseline.identity.root_width == identity.root_width + && baseline.identity.root_width_override == identity.root_width_override + && baseline.identity.runtime_revision == identity.runtime_revision + && baseline.identity.context_hash == identity.context_hash + && baseline.identity.complete == identity.complete } fn prepare_layout_job( document: &Arc, frame: ControlFrame, + document_base_revision: u64, + document_target_revision: u64, ) -> Result { if frame.payload.is_some() { return Err(format!( @@ -1143,6 +1265,8 @@ fn prepare_layout_job( context_hash: frame.context_hash, complete: frame.complete, root_metadata: frame.root_metadata, + document_base_revision, + document_target_revision, }, }) } @@ -1176,6 +1300,8 @@ fn render_layout_payload( context_hash, complete, root_metadata, + document_base_revision, + document_target_revision, } => { let identity = TapeIdentity { session_id, @@ -1208,17 +1334,21 @@ fn render_layout_payload( type LayoutRenderOutcome = Result<(Vec, LayoutTape, bool, u64, u64), String>; let result = catch_unwind(AssertUnwindSafe(|| -> LayoutRenderOutcome { if let Some(base_context) = base_context { + let base_identity = BaselineIdentity { + context: base_context, + root_width: base_root_width, + root_width_override: base_root_width_override, + runtime_revision, + context_hash, + complete, + }; let base_hit = confirmed_baseline .as_ref() .filter(|baseline| { confirmed_baseline_matches( baseline, - base_context, - base_root_width, - base_root_width_override, - runtime_revision, - context_hash, - complete, + document_base_revision, + &base_identity, ) && baseline.styles == document.styles }) .cloned(); @@ -1275,6 +1405,8 @@ fn render_layout_payload( bytes, pending: Some(PendingBaseline { confirmed_identity: pending_identity, + document: Arc::clone(&document), + document_revision: document_target_revision, tape, styles: document.styles.clone(), }), @@ -1329,7 +1461,7 @@ fn render_proof(payload: &[u8]) -> Result, String> { if frame.delay_ms != 0 { return Err("Native proof render cannot contain delay-ms".to_owned()); } - let prepared = prepare_layout_job(&Arc::new(document), frame)?; + let prepared = prepare_layout_job(&Arc::new(document), frame, 0, 1)?; let output = render_layout_payload( prepared.payload, SYNC_RENDER_SESSION_ID, @@ -1847,14 +1979,41 @@ mod tests { fn proof_layout_payload(frames: &str) -> Vec { format!( - r#"{{"version":1,"document":{{"version":2,"space-width":8,"style-count":0,"styles":[],"root":{{"type":"box","region-id":1,"content":{{"lines":[{{"clusters":[{{"text":"x","width":8,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"viewport"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","padding-left":0,"padding-right":0,"padding-top":0,"padding-bottom":0,"margin-left":0,"margin-right":0,"margin-top":0,"margin-bottom":0,"border-left":0,"border-right":0,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}}}},"frames":{frames}}}"# + r#"{{"version":1,"document-base-revision":0,"document-target-revision":1,"document":{{"version":2,"space-width":8,"style-count":0,"styles":[],"root":{{"type":"box","region-id":1,"content":{{"lines":[{{"clusters":[{{"text":"x","width":8,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"viewport"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","padding-left":0,"padding-right":0,"padding-top":0,"padding-bottom":0,"margin-left":0,"margin-right":0,"margin-top":0,"margin-bottom":0,"border-left":0,"border-right":0,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}}}},"frames":{frames}}}"# ) .into_bytes() } + fn retained_layout_payload(document_revision: u64, frames: &str) -> Vec { + format!( + r#"{{"version":1,"document-base-revision":{document_revision},"document-target-revision":{document_revision},"frames":{frames}}}"# + ) + .into_bytes() + } + + fn replacement_layout_payload( + document_base_revision: u64, + document_target_revision: u64, + frames: &str, + ) -> Vec { + let payload = String::from_utf8(proof_layout_payload(frames)).unwrap(); + payload + .replacen( + r#""document-base-revision":0"#, + &format!(r#""document-base-revision":{document_base_revision}"#), + 1, + ) + .replacen( + r#""document-target-revision":1"#, + &format!(r#""document-target-revision":{document_target_revision}"#), + 1, + ) + .into_bytes() + } + fn column_proof_layout_payload(frames: &str) -> Vec { format!( - r#"{{"version":1,"document":{{"version":2,"space-width":8,"style-count":0,"styles":[],"root":{{"type":"column","children":[{{"type":"box","region-id":1,"content":{{"lines":[{{"clusters":[{{"text":"x","width":8,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"content"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","padding-left":0,"padding-right":0,"padding-top":0,"padding-bottom":0,"margin-left":0,"margin-right":0,"margin-top":0,"margin-bottom":0,"border-left":0,"border-right":0,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}},{{"type":"box","region-id":2,"content":{{"lines":[{{"clusters":[{{"text":"yy","width":16,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"content"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","padding-left":0,"padding-right":0,"padding-top":0,"padding-bottom":0,"margin-left":0,"margin-right":0,"margin-top":0,"margin-bottom":0,"border-left":0,"border-right":0,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}}]}}}},"frames":{frames}}}"# + r#"{{"version":1,"document-base-revision":0,"document-target-revision":1,"document":{{"version":2,"space-width":8,"style-count":0,"styles":[],"root":{{"type":"column","children":[{{"type":"box","region-id":1,"content":{{"lines":[{{"clusters":[{{"text":"x","width":8,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"content"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","padding-left":0,"padding-right":0,"padding-top":0,"padding-bottom":0,"margin-left":0,"margin-right":0,"margin-top":0,"margin-bottom":0,"border-left":0,"border-right":0,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}},{{"type":"box","region-id":2,"content":{{"lines":[{{"clusters":[{{"text":"yy","width":16,"cjk":false,"space":false}}]}}]}},"child":null,"content-width-exact":false,"width":{{"kind":"content"}},"min-width":{{"kind":"pixels","value":0}},"max-width":{{"kind":"none"}},"height":{{"kind":"auto"}},"min-height":{{"kind":"lines","value":0}},"max-height":{{"kind":"none"}},"box-sizing":"border-box","padding-left":0,"padding-right":0,"padding-top":0,"padding-bottom":0,"margin-left":0,"margin-right":0,"margin-top":0,"margin-bottom":0,"border-left":0,"border-right":0,"foreground-style":null,"background-style":null,"border-left-style":null,"border-right-style":null,"border-top-style":null,"border-bottom-style":null,"text-align":"left","vertical-align":"top","overflow":"scroll","wrap-mode":"word","scroll-offset":0}}]}}}},"frames":{frames}}}"# ) .into_bytes() } @@ -2083,10 +2242,11 @@ mod tests { assert_eq!(child_initial.ready_results, 0); assert_eq!(child_initial.pending_baselines, 0); assert!(child_initial.confirmed_baseline); - assert!(!child_initial.layout_registered); + assert!(child_initial.layout_registered); parent.stop(true); - let second = proof_layout_payload( + let second = retained_layout_payload( + 1, r#"[{"key":2,"viewport-width":80,"viewport-height":10,"root-width":80,"patch":true,"base-viewport-width":120,"base-viewport-height":10,"base-root-width":120,"runtime-revision":1,"context-hash":77}]"#, ); let second_tape = child.render_sync(1, &second).unwrap(); @@ -2110,6 +2270,152 @@ mod tests { child.stop(true); } + #[test] + fn confirmed_fork_reuses_retained_document_without_reparse_or_revalidation() { + let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = proof_layout_payload( + r#"[{"key":1,"viewport-width":120,"viewport-height":10,"root-width":120,"runtime-revision":0,"context-hash":77}]"#, + ); + assert!(!parent.render_sync(1, &first).unwrap().is_empty()); + assert!(parent.confirm(1, 1, 1).unwrap()); + + let child = parent.fork_confirmed().unwrap(); + let retained = retained_layout_payload( + 1, + r#"[{"key":2,"viewport-width":80,"viewport-height":10,"root-width":80,"patch":true,"base-viewport-width":120,"base-viewport-height":10,"base-root-width":120,"runtime-revision":1,"context-hash":77}]"#, + ); + let tape = child.render_sync(1, &retained).unwrap(); + assert!(tape_patch_p(&tape)); + let stats = child.stats(); + assert_eq!(stats.document_parses, 0); + assert_eq!(stats.document_validations, 0); + assert_eq!(stats.document_reuses, 1); + assert!(child.confirm(1, 2, 2).unwrap()); + + let grandchild = child.fork_confirmed().unwrap(); + let next = retained_layout_payload( + 1, + r#"[{"key":3,"viewport-width":100,"viewport-height":10,"root-width":100,"patch":true,"base-viewport-width":80,"base-viewport-height":10,"base-root-width":80,"runtime-revision":2,"context-hash":77}]"#, + ); + assert!(tape_patch_p(&grandchild.render_sync(1, &next).unwrap())); + assert_eq!(grandchild.stats().document_reuses, 1); + parent.stop(true); + child.stop(true); + grandchild.stop(true); + } + + #[test] + fn retained_document_reuse_rejects_wrong_or_ambiguous_revision() { + let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = proof_layout_payload( + r#"[{"key":1,"viewport-width":120,"viewport-height":10,"runtime-revision":0}]"#, + ); + parent.render_sync(1, &first).unwrap(); + assert!(parent.confirm(1, 1, 1).unwrap()); + let child = parent.fork_confirmed().unwrap(); + + let wrong_document = retained_layout_payload( + 2, + r#"[{"key":2,"viewport-width":80,"viewport-height":10,"runtime-revision":1}]"#, + ); + assert!(child + .render_sync(1, &wrong_document) + .unwrap_err() + .contains("document revision")); + let wrong_runtime = retained_layout_payload( + 1, + r#"[{"key":2,"viewport-width":80,"viewport-height":10,"runtime-revision":0}]"#, + ); + assert!(child + .render_sync(1, &wrong_runtime) + .unwrap_err() + .contains("runtime revision")); + let ambiguous = br#"{"version":1,"frames":[{"key":2,"viewport-width":80,"viewport-height":10,"runtime-revision":1}]}"#; + assert!(child + .render_sync(1, ambiguous) + .unwrap_err() + .contains("document revisions")); + assert_eq!(child.stats().document_reuses, 0); + assert!(parent.stats().confirmed_baseline); + parent.stop(true); + child.stop(true); + } + + #[test] + fn cancelled_replacement_does_not_promote_or_contaminate_its_parent() { + let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = proof_layout_payload( + r#"[{"key":1,"viewport-width":120,"viewport-height":10,"runtime-revision":0}]"#, + ); + parent.render_sync(1, &first).unwrap(); + assert!(parent.confirm(1, 1, 1).unwrap()); + let child = parent.fork_confirmed().unwrap(); + let replacement = replacement_layout_payload( + 1, + 2, + r#"[{"key":2,"viewport-width":100,"viewport-height":10,"runtime-revision":1}]"#, + ); + assert!(!child.render_sync(1, &replacement).unwrap().is_empty()); + assert_eq!(child.stats().pending_baselines, 1); + child.cancel(1); + assert_eq!(child.stats().pending_baselines, 0); + + let retry = child.fork_confirmed().unwrap(); + let retained = retained_layout_payload( + 1, + r#"[{"key":3,"viewport-width":80,"viewport-height":10,"runtime-revision":1}]"#, + ); + assert!(!retry.render_sync(1, &retained).unwrap().is_empty()); + assert_eq!(retry.stats().document_reuses, 1); + assert_eq!(parent.stats().document_parses, 1); + assert_eq!(parent.stats().document_reuses, 0); + parent.stop(true); + child.stop(true); + retry.stop(true); + } + + #[test] + fn async_omit_uses_the_latest_synchronously_confirmed_document() { + let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); + let first = proof_layout_payload( + r#"[{"key":1,"viewport-width":120,"viewport-height":10,"runtime-revision":0}]"#, + ); + let first_count = full_tape_character_count(&parent.render_sync(1, &first).unwrap()); + assert!(parent.confirm(1, 1, 1).unwrap()); + let parent_omit = + batch(r#"[{"key":2,"viewport-width":120,"viewport-height":10,"runtime-revision":1}]"#); + assert_eq!(parent.submit(2, &parent_omit).unwrap(), 1); + wait_until(|| parent.ready(2, 2)); + assert_eq!( + full_tape_character_count(&parent.take(2, 2).unwrap()), + first_count + ); + + let child = parent.fork_confirmed().unwrap(); + let replacement = String::from_utf8(replacement_layout_payload( + 1, + 2, + r#"[{"key":3,"viewport-width":120,"viewport-height":10,"runtime-revision":1}]"#, + )) + .unwrap() + .replacen(r#""text":"x","width":8"#, r#""text":"yy","width":16"#, 1) + .into_bytes(); + let replacement_count = + full_tape_character_count(&child.render_sync(1, &replacement).unwrap()); + assert_ne!(replacement_count, first_count); + assert!(child.confirm(1, 3, 2).unwrap()); + let child_omit = + batch(r#"[{"key":4,"viewport-width":120,"viewport-height":10,"runtime-revision":2}]"#); + assert_eq!(child.submit(2, &child_omit).unwrap(), 1); + wait_until(|| child.ready(2, 4)); + assert_eq!( + full_tape_character_count(&child.take(2, 4).unwrap()), + replacement_count + ); + parent.stop(true); + child.stop(true); + } + #[test] fn confirmed_fork_falls_back_to_full_frame_on_identity_mismatch() { let parent = Session::new(1, 4, 4, 64 * 1024).unwrap(); @@ -2120,7 +2426,9 @@ mod tests { assert!(parent.confirm(1, 1, 1).unwrap()); let child = parent.fork_confirmed().unwrap(); - let mismatched = proof_layout_payload( + let mismatched = replacement_layout_payload( + 1, + 2, r#"[{"key":2,"viewport-width":80,"viewport-height":10,"root-width":80,"patch":true,"base-viewport-width":120,"base-viewport-height":10,"base-root-width":120,"runtime-revision":2,"context-hash":77}]"#, ); let tape = child.render_sync(1, &mismatched).unwrap(); diff --git a/tests/ebox-commit-tests.el b/tests/ebox-commit-tests.el index 73f8891..3da1c0e 100644 --- a/tests/ebox-commit-tests.el +++ b/tests/ebox-commit-tests.el @@ -2399,6 +2399,174 @@ remain retained identities." (equal '(:foreground "#222222") (plist-get (aref styles style-id) :face)))))) +(ert-deftest ebox-native-retained-package-reuse-requires-exact-ir-identity () + "Only an identity-preserving compile may reuse the confirmed package." + (let* ((root (list :type "row" :children [])) + (style (list :mode 'face :face '(:foreground "red"))) + (template (list :mouse-face 'highlight)) + (old + (list :document + (list :version 2 :space-width 8 :style-count 1 + :property-template-count 1 + :styles (vector style) :root root) + :document-revision 7 + :styles (vector style) + :property-templates (vector template))) + (same + (list :document + (list :version 2 :space-width 8 :style-count 1 + :property-template-count 1 + :styles (vector style) :root root) + :styles (vector style) + :property-templates (vector template)))) + (should (eq old + (ebox-native-reflow--reuse-exact-layout-package old same))) + (dolist (candidate + (list + (copy-tree same) + (let ((copy (copy-tree same))) + (plist-put (plist-get copy :document) :space-width 9) + copy) + (let ((copy (copy-tree same))) + (plist-put (plist-get copy :document) :root (copy-tree root)) + copy) + (let ((copy (copy-tree same))) + (plist-put copy :styles + (vector (copy-tree style))) + copy) + (let ((copy (copy-tree same))) + (plist-put copy :property-templates + (vector (copy-tree template))) + copy))) + ;; COPY-TREE deliberately destroys the required root/style/template + ;; object identity even when values remain equal. + (should-not + (eq old (ebox-native-reflow--reuse-exact-layout-package old candidate)))))) + +(ert-deftest ebox-native-retained-sync-omits-an-exactly-reused-document () + "A retained frame sends only revisions and context for unchanged IR." + (let* ((document (list :version 2 :space-width 8 :style-count 0 + :property-template-count 0 :styles [] + :root (list :type "row" :children []))) + (package (list :document document :document-revision 4 + :styles [] :property-templates [])) + (session + (ebox-native-reflow--make-session + :handle 'test :generation 3 :styles [] :layout-package package + :layout-fragment-cache (make-hash-table :test 'equal) + :layout-fragment-revision 0)) + control) + (cl-letf (((symbol-function + 'ebox-native-reflow--compile-retained-layout-package) + (lambda (&rest _) package)) + ((symbol-function 'ebox-native--module-render-session-frame) + (lambda (_handle _generation payload) + (setq control + (json-parse-string payload :object-type 'plist + :array-type 'array)) + 'native-frame)) + ((symbol-function 'ebox-native-reflow--materialize-module-frame) + (lambda (&rest _) '(:rendered "ok")))) + (should + (equal + '(:rendered "ok") + (ebox-native-reflow-execute-session-sync + session 'node + '(:key 1 :viewport-width 80 :viewport-height 10 + :runtime-revision 9) + nil 'state))) + (should-not (plist-member control :document)) + (should (= (plist-get control :document-base-revision) 4)) + (should (= (plist-get control :document-target-revision) 4))))) + +(ert-deftest ebox-native-session-input-normalizes-every-replacement-revision () + "Compiled and explicit replacement packages both advance at the boundary." + (let* ((styles []) + (templates []) + (old + (list :document + (list :version 2 :space-width 8 :style-count 0 + :property-template-count 0 :styles [] + :root (list :type "row" :children [])) + :document-revision 4 + :styles styles :property-templates templates)) + (compiled + (list :document + (list :version 2 :space-width 8 :style-count 0 + :property-template-count 0 :styles [] + :root (list :type "column" :children [])) + :document-revision 1 + :styles styles :property-templates templates)) + (explicit + (list :document + (list :version 2 :space-width 8 :style-count 0 + :property-template-count 0 :styles [] + :root (list :type "box")) + :document-revision 1 + :styles styles :property-templates templates)) + (session + (ebox-native-reflow--make-session + :handle 'test :generation 0 :styles styles :layout-package old + :layout-fragment-cache (make-hash-table :test 'equal) + :layout-fragment-revision 0)) + (other-old (copy-tree old)) + (other-session + (progn + (plist-put other-old :document-revision 10) + (ebox-native-reflow--make-session + :handle 'other :generation 0 :styles styles + :layout-package other-old + :layout-fragment-cache (make-hash-table :test 'equal) + :layout-fragment-revision 0))) + controls) + (cl-letf (((symbol-function 'ebox-native-reflow--compile-layout-package) + (lambda (&rest _) compiled)) + ((symbol-function 'ebox-native--module-render-session-frame) + (lambda (_handle _generation payload) + (push (json-parse-string payload :object-type 'plist + :array-type 'array) + controls) + 'native-frame)) + ((symbol-function 'ebox-native-reflow--materialize-module-frame) + (lambda (&rest _) '(:rendered "ok")))) + (ebox-native-reflow-execute-session-sync + session 'node + '(:key 1 :viewport-width 80 :viewport-height 10 + :runtime-revision 9)) + (should (= (plist-get (ebox-native-reflow-session-layout-package session) + :document-revision) + 5)) + (ebox-native-reflow-execute-session-sync + session 'node + '(:key 1 :viewport-width 90 :viewport-height 10 + :runtime-revision 10) + explicit) + (setq controls (nreverse controls)) + (should (plist-member (car controls) :document)) + (should (= (plist-get (car controls) :document-base-revision) 4)) + (should (= (plist-get (car controls) :document-target-revision) 5)) + (should (plist-member (cadr controls) :document)) + (should (= (plist-get (cadr controls) :document-base-revision) 5)) + (should (= (plist-get (cadr controls) :document-target-revision) 6)) + (should (= (plist-get (ebox-native-reflow-session-layout-package session) + :document-revision) + 6)) + (ebox-native-reflow-execute-session-sync + other-session 'node + '(:key 1 :viewport-width 100 :viewport-height 10 + :runtime-revision 20) + explicit) + (should (= (plist-get explicit :document-revision) 1)) + (should (= (plist-get (ebox-native-reflow-session-layout-package session) + :document-revision) + 6)) + (should (= (plist-get + (ebox-native-reflow-session-layout-package other-session) + :document-revision) + 11)) + (should (= (plist-get (car controls) :document-base-revision) 10)) + (should (= (plist-get (car controls) :document-target-revision) 11))))) + (provide 'ebox-commit-tests) ;;; ebox-commit-tests.el ends here diff --git a/tests/ebox-surface-tests.el b/tests/ebox-surface-tests.el index 7b918d8..3c228fc 100644 --- a/tests/ebox-surface-tests.el +++ b/tests/ebox-surface-tests.el @@ -1500,6 +1500,11 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (should (ebox-native-reflow-session-released-p session)) (should-not (ebox-native-reflow-session-released-p committed-session)) + (let ((stats + (ebox-native-reflow-stats committed-session))) + (should (= (plist-get stats :document-parses) 0)) + (should (= (plist-get stats :document-validations) 0)) + (should (= (plist-get stats :document-reuses) 1))) (should (plist-get (ebox--buffer-render-state buffer) :native-sync-confirmed-p)) (should-not (plist-get (ebox--buffer-render-state buffer) @@ -1640,6 +1645,10 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (should (stringp (plist-get (tp-surface-client-state surface) :native-committed-rendered))) + (let ((stats (ebox-native-reflow-stats winning-session))) + (should (= (plist-get stats :document-parses) 1)) + (should (= (plist-get stats :document-validations) 1)) + (should (= (plist-get stats :document-reuses) 0))) (should-not (ebox-native-reflow-session-released-p winning-session))) (let ((report (ebox-commit buffer updated))) @@ -1654,7 +1663,11 @@ candidate cannot hide mutations by restoring the old hash-table pointer." (should-not (eq next-session winning-session)) (should (ebox-native-reflow-session-released-p winning-session)) (should-not (ebox-native-reflow-session-released-p - next-session))))) + next-session)) + (let ((stats (ebox-native-reflow-stats next-session))) + (should (= (plist-get stats :document-parses) 1)) + (should (= (plist-get stats :document-validations) 1)) + (should (= (plist-get stats :document-reuses) 0)))))) (when (buffer-live-p buffer) (kill-buffer buffer)) (when next-session