diff --git a/README.md b/README.md index df4785c..4cd283b 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,15 @@ TP records the host baseline and each TP contribution per property interval. Ove Observers run only after a successful commit. Their failures are recorded and do not roll back an already committed transaction. A buffer killed during publication remains killed; rollback never recreates it. +ETAF integration uses the same boundary through one opaque transaction +participant. ETAF prepares its immutable generation and Ebox candidate before +TP accepts the transaction; participant publish, TP final accept, and +post-accept cleanup are ordered explicitly. A participant failure restores the +previous generation and client state, while a post-accept observer failure is +contained as a diagnostic. ETAF's runtime fixed-point guard records each +effect's input/version tuple for one flush and stops repeated or over-bound +cycles instead of spinning. + ## Property policies `tp-define-property-policy` registers generic semantics for a final Emacs text property: diff --git a/README_CN.md b/README_CN.md index 2d7547c..b2ff614 100644 --- a/README_CN.md +++ b/README_CN.md @@ -145,6 +145,13 @@ TP 为每个 property interval 保存 host baseline 和各个 TP contribution。 observer 只在成功提交之后运行,observer failure 只记录,不回滚已完成的 transaction。publication 过程中被 kill 的 buffer 保持死亡,rollback 不会把它重新创建。 +ETAF 集成复用同一事务边界,只注册一个 opaque transaction participant。ETAF +先准备 immutable generation 和 Ebox candidate,再由 TP 按固定顺序完成 +participant publish、TP final accept 和 post-accept cleanup。participant failure +会恢复旧 generation 与 client state;accept 之后的 observer failure 只保留为 +diagnostic。ETAF 的 Runtime fixed-point guard 会在一次 flush 中记录每个 +effect 的 input/version tuple,重复或超过图规模上限时停止循环,不会静默自旋。 + ## Property policy `tp-define-property-policy` 为一个最终 Emacs text property 注册通用语义: diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index 7cdce61..8788986 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -244,6 +244,11 @@ publish 在 surface publication 后、source commit 前运行,失败时按逆 rollback。tp-variable-signal 用 Emacs variable watcher 适配全局或指定 Buffer 的变量,不是旧的 $variable API。 +ETAF registers one opaque participant for its immutable generation and Ebox +client state. Its publish is paired with rollback across TP final accept; the +ETAF runtime separately records effect input/version tuples and reports a +bounded non-converging flush instead of spinning. + ## 6. Retained surface ### 6.1 Plan、result 和 object diff --git a/docs/API-SEMANTICS.md b/docs/API-SEMANTICS.md index 11f689a..83effe4 100644 --- a/docs/API-SEMANTICS.md +++ b/docs/API-SEMANTICS.md @@ -204,6 +204,13 @@ TP 为每个 interval 保存: `tp-transaction-participate` 允许 client side state 在 surfaces 发布后、source commit 前加入同一 rollback boundary。participant key 在一个 outer transaction 中必须唯一。它不是 observer;失败会回滚 transaction。Observer failure 只记录,不回滚已提交结果。 +ETAF uses this API with one opaque participant for its immutable generation and +Ebox client state. The participant is published only after candidate +preparation, and its paired rollback is still required when TP final accept +fails. This keeps ETAF generation visibility, Ebox publication, signals, and +client state on one commit boundary; ETAF's own flush guard is responsible for +detecting repeated input/version tuples and bounded non-convergence. + ## 12. Diagnostics 与 reports `tp-surface-report` 返回最近一次成功 publication 的防御性 report;equal/no-op update 不替换 report。字段包括 transaction/surface/revision、candidate source writes、binding counters、object reconcile counts、text/property operation counts、touched characters、scope/full-root 和 failure-related slots。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 158608c..15e7f21 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -200,6 +200,17 @@ Rollback restores text, properties, marker/index state, plans, producer, client If publication kills a target buffer, kill teardown is authoritative. Other surfaces and source state roll back; TP never recreates the killed buffer. +### ETAF participant contract + +ETAF is a consumer of this public boundary, not a second TP runtime. It stages +an immutable semantic generation and an Ebox candidate, then registers one +opaque participant. The participant publishes the generation only after TP +preparation succeeds; TP final accept is the last fallible operation. Failure +restores the old generation, client state, and Ebox publication together. +ETAF's outer flush records effect input/version tuples and applies a graph-size +derived step bound, so a repeated tuple or monotonic cycle becomes a +diagnostic instead of an unbounded dispatch. + ## 12. Lifecycle Surfaces are buffer-local lifecycle owners. A weak global registry supports lookup without keeping dead buffers alive. Mount installs local change/kill hooks; unmount and kill remove hooks, markers, ledger entries, objects, bindings, subscriptions, indexes, client state and weak registrations. diff --git a/tests/tp-binding-tests.el b/tests/tp-binding-tests.el index 53c1ca5..4edd1c8 100644 --- a/tests/tp-binding-tests.el +++ b/tests/tp-binding-tests.el @@ -14,6 +14,56 @@ (defvar tp-binding-test-variable nil "Variable used by scoped signal adapter tests.") +(define-error 'tp-binding-test-primary "TP binding test primary failure") + +(defvar tp-binding-test-transaction-trace nil + "Dynamic transaction phase trace used by rollback tests.") + +(defvar tp-binding-test-precommit-condition nil + "Condition signaled by the test precommit hook, or nil.") + +(defvar tp-binding-test-final-accept-function nil + "Test final-accept function installed by the test precommit hook.") + +(defun tp--binding-test-precommit-inject () + "Record precommit and signal `tp-binding-test-precommit-condition'." + (push 'precommit tp-binding-test-transaction-trace) + (when tp-binding-test-final-accept-function + (tp--transaction-install-final-accept + tp-binding-test-final-accept-function)) + (when tp-binding-test-precommit-condition + (signal (car tp-binding-test-precommit-condition) + (cdr tp-binding-test-precommit-condition)))) + +(defun tp--binding-test-foreign-precommit () + "Represent an undeclared private-looking foreign precommit function." + nil) + +(defun tp-binding-test--rollback-hook-fail () + "Record rollback hook failure and signal it." + (push 'rollback-hook-fail tp-binding-test-transaction-trace) + (error "Rollback hook failure")) + +(defun tp-binding-test--rollback-hook-after () + "Record the rollback hook following a failed hook." + (push 'rollback-hook-after tp-binding-test-transaction-trace)) + +(defun tp-binding-test--rollback-final-fail () + "Record rollback-final failure and signal it." + (push 'rollback-final-fail tp-binding-test-transaction-trace) + (error "Rollback final failure")) + +(defun tp-binding-test--rollback-final-after () + "Record the rollback-final hook following a failed hook." + (push 'rollback-final-after tp-binding-test-transaction-trace)) + +(defun tp-binding-test--capture-condition (function) + "Call FUNCTION and return its signaled error or quit condition." + (condition-case condition + (progn (funcall function) nil) + (error condition) + (quit condition))) + (defmacro tp-binding-test--isolated (&rest body) "Run BODY with an isolated reactive runtime." (declare (indent 0) (debug t)) @@ -110,6 +160,340 @@ (should (= calls 2)) (should (= (plist-get (tp-reactive-counters) :recomputed) 1))))) +(ert-deftest tp-binding-test-transaction-commits-signals-in-first-touch-order () + "Touched signals commit once in first-touch order, including net reverts." + (tp-binding-test--isolated + (let* ((first (tp-signal-create 0)) + (second (tp-signal-create 0)) + (third (tp-signal-create 0)) + (original (symbol-function 'tp--commit-signal-entry)) + commit-order final-accept-order (final-accept-count 0)) + (cl-letf (((symbol-function 'tp--commit-signal-entry) + (lambda (entry) + (push (tp-signal-id + (tp--signal-commit-entry-signal entry)) + commit-order) + (funcall original entry))) + ((symbol-function 'tp--transaction-noop-final-accept) + (lambda () + (cl-incf final-accept-count) + (setq final-accept-order + (mapcar + (lambda (entry) + (tp-signal-id + (tp--signal-commit-entry-signal entry))) + tp--transaction-signal-commit-journal))))) + (tp-with-transaction + (tp-signal-set second 1) + (tp-with-transaction + (tp-signal-set first 1) + (tp-signal-set second 0)) + (tp-signal-set third 3))) + (setq commit-order (nreverse commit-order)) + (should (equal commit-order + (mapcar #'tp-signal-id (list second first third)))) + (should (equal final-accept-order commit-order)) + (should (= final-accept-count 1)) + (should (= (tp-signal-peek first) 1)) + (should (= (tp-signal-peek second) 0)) + (should (= (tp-signal-peek third) 3)) + (dolist (signal (list first second third)) + (should (= (tp-signal-revision signal) 1)))))) + +(ert-deftest tp-binding-test-signal-commit-journal-rolls-back-every-write () + "Every signal-write injection restores values, revisions, and bindings." + (tp-binding-test--isolated + (dolist (fail-after '(0 1 2 3)) + (let* ((first (tp-signal-create 1)) + (second (tp-signal-create 2)) + (third (tp-signal-create 3)) + (binding + (tp-bind (list 'owner fail-after) '(test . sum) + (lambda () + (+ (tp-signal-read first) + (tp-signal-read second) + (tp-signal-read third))))) + (binding-revision (tp-binding-revision binding)) + (original (symbol-function 'tp--commit-signal-entry)) + (writes 0)) + (cl-letf (((symbol-function 'tp--commit-signal-entry) + (lambda (entry) + (when (and (zerop fail-after) (zerop writes)) + (error "Before first signal write")) + (funcall original entry) + (cl-incf writes) + (when (= writes fail-after) + (error "After signal write %d" writes))))) + (should-error + (tp-with-transaction + (tp-signal-set first 10) + (tp-signal-set second 20) + (tp-signal-set third 30)))) + (should (equal (mapcar #'tp-signal-peek (list first second third)) + '(1 2 3))) + (should (equal (mapcar #'tp-signal-revision + (list first second third)) + '(0 0 0))) + (should (= (tp-binding-read binding) 6)) + (should (= (tp-binding-revision binding) binding-revision)) + (should (= (tp-binding-dependency-count binding) 3)) + (dolist (signal (list first second third)) + (should (= (tp-signal-subscriber-count signal) 1))) + (tp-with-transaction + (tp-signal-set first 10) + (tp-signal-set second 20) + (tp-signal-set third 30)) + (should (equal (mapcar #'tp-signal-peek (list first second third)) + '(10 20 30))) + (should (equal (mapcar #'tp-signal-revision + (list first second third)) + '(1 1 1))) + (should (= (tp-binding-read binding) 60)))))) + +(ert-deftest tp-binding-test-precommit-and-final-accept-failures-roll-back () + "Precommit and final-accept error or quit restores pure reactive state." + (tp-binding-test--isolated + (dolist (phase '(precommit final-accept)) + (dolist (condition '((error "Injected error") (quit))) + (let* ((signal (tp-signal-create 1)) + observed-final + (tp--transaction-precommit-functions + '(tp--binding-test-precommit-inject)) + (tp--transaction-precommit-allowed-functions + '(tp--binding-test-precommit-inject)) + (tp-binding-test-precommit-condition + (and (eq phase 'precommit) condition)) + (tp-binding-test-final-accept-function + (and (eq phase 'final-accept) + (lambda () + (setq observed-final + (list (tp-signal-committed-value signal) + (tp-signal-revision signal))) + (signal (car condition) (cdr condition)))))) + (let ((failure + (tp-binding-test--capture-condition + (lambda () + (tp-with-transaction (tp-signal-set signal 2)))))) + (should (eq (car failure) (car condition)))) + (when (eq phase 'final-accept) + (should (equal observed-final '(2 1)))) + (should (= (tp-signal-peek signal) 1)) + (should (= (tp-signal-revision signal) 0)) + (setq tp--transaction-precommit-functions nil + tp-binding-test-precommit-condition nil + tp-binding-test-final-accept-function nil) + (tp-signal-set signal 2) + (should (= (tp-signal-peek signal) 2)) + (should (= (tp-signal-revision signal) 1))))))) + +(ert-deftest tp-binding-test-final-accept-pending-quit-is-contained () + "Clear final-accept pending quit before committed and after-commit actions." + (tp-binding-test--isolated + (let ((signal (tp-signal-create 1)) after-ran + (tp--transaction-precommit-functions + '(tp--binding-test-precommit-inject)) + (tp--transaction-precommit-allowed-functions + '(tp--binding-test-precommit-inject)) + (tp-binding-test-final-accept-function + (lambda () (setq quit-flag t)))) + (tp-with-transaction + (tp-signal-set signal 2) + (tp--enqueue-after-commit (lambda () (setq after-ran t)))) + (should (= 2 (tp-signal-peek signal))) + (should after-ran) + (should (eq 'final-accept + (caar tp--last-transaction-diagnostics)))))) + +(ert-deftest tp-binding-test-precommit-registry-rejects-foreign-functions () + "Only declared TP-internal symbols may enter the precommit registry." + (tp-binding-test--isolated + (let ((tp--transaction-precommit-functions nil) + (tp--transaction-precommit-allowed-functions + '(tp--binding-test-precommit-inject))) + (tp--transaction-register-precommit-function + 'tp--binding-test-precommit-inject) + (should (equal tp--transaction-precommit-functions + '(tp--binding-test-precommit-inject))) + (should-error + (tp--transaction-register-precommit-function + 'tp--binding-test-foreign-precommit) + :type 'tp-reactive-error) + (dolist (foreign + (list 'ignore 'tp--binding-test-foreign-precommit + (lambda () nil))) + (let ((signal (tp-signal-create 1)) + (tp--transaction-precommit-functions (list foreign))) + (should-error + (tp-with-transaction (tp-signal-set signal 2)) + :type 'tp-reactive-error) + (should (= (tp-signal-peek signal) 1)) + (should (= (tp-signal-revision signal) 0))))))) + +(ert-deftest tp-binding-test-production-precommit-registry-is-exact () + "The package exposes exactly its one statically declared precommit owner." + (should (equal tp--transaction-precommit-allowed-functions + '(tp--surface-precommit-transaction))) + (should (equal tp--transaction-precommit-functions + '(tp--surface-precommit-transaction)))) + +(ert-deftest tp-binding-test-condition-trailer-cannot-collide-with-primary-data () + "User condition data ending like rollback metadata is not a TP trailer." + (let ((condition '(error "Primary" :rollback-failures user-value))) + (should-not + (tp--transaction-condition-trailer condition :rollback-failures)))) + +(ert-deftest tp-binding-test-rollback-preserves-primary-and-runs-all-phases () + "Rollback failures attach in order without replacing the primary condition." + (tp-binding-test--isolated + (let* ((signal (tp-signal-create 1)) + (binding (tp-bind 'owner '(test . rollback) + (lambda () (tp-signal-read signal)))) + (tp-binding-test-transaction-trace nil) + (tp-binding-test-precommit-condition nil) + (tp--transaction-precommit-functions + '(tp--binding-test-precommit-inject)) + (tp--transaction-precommit-allowed-functions + '(tp--binding-test-precommit-inject)) + (tp-binding-test-final-accept-function + (lambda () + (signal 'tp-binding-test-primary + '(:payload 7 :rollback-failures user-value)))) + (tp--transaction-rollback-functions + '(tp-binding-test--rollback-hook-fail + tp-binding-test--rollback-hook-after)) + (tp--transaction-rollback-final-functions + '(tp-binding-test--rollback-final-fail + tp-binding-test--rollback-final-after)) + (restore-signal (symbol-function 'tp--restore-signal-entry)) + (rollback-bindings (symbol-function 'tp--rollback-bindings)) + (restore-counters + (symbol-function 'tp--restore-transaction-counters)) + signal-restore-failed + failure) + (cl-letf (((symbol-function 'tp--restore-signal-entry) + (lambda (entry) + (funcall restore-signal entry) + (push 'signal-restored tp-binding-test-transaction-trace) + (unless signal-restore-failed + (setq signal-restore-failed t) + (error "Signal restore failure")))) + ((symbol-function 'tp--rollback-bindings) + (lambda () + (funcall rollback-bindings) + (push 'bindings-restored + tp-binding-test-transaction-trace))) + ((symbol-function 'tp--restore-transaction-counters) + (lambda (snapshot) + (funcall restore-counters snapshot) + (push 'counters-restored + tp-binding-test-transaction-trace)))) + (setq failure + (condition-case condition + (tp-with-transaction + (tp-transaction-participate + 'first + (lambda () + (push 'publish-first + tp-binding-test-transaction-trace)) + (lambda () + (push 'rollback-first + tp-binding-test-transaction-trace))) + (tp-transaction-participate + 'second + (lambda () + (push 'publish-second + tp-binding-test-transaction-trace)) + (lambda () + (push 'rollback-second + tp-binding-test-transaction-trace))) + (tp-signal-set signal 2)) + (tp-binding-test-primary condition)))) + (should (eq (car failure) 'tp-binding-test-primary)) + (should (= (plist-get (cdr failure) :payload) 7)) + (should (equal (butlast failure 2) + '(tp-binding-test-primary + :payload 7 :rollback-failures user-value))) + (let ((rollback-failures + (tp--transaction-condition-trailer + failure :rollback-failures))) + (should (equal (mapcar #'car rollback-failures) + '(rollback-hooks signal-journal rollback-final)))) + (should + (equal (nreverse tp-binding-test-transaction-trace) + '(publish-first publish-second precommit + rollback-second rollback-first + rollback-hook-fail rollback-hook-after + signal-restored bindings-restored counters-restored + rollback-final-fail rollback-final-after))) + (should (= (tp-signal-peek signal) 1)) + (should (= (tp-signal-revision signal) 0)) + (should (= (tp-binding-read binding) 1))))) + +(ert-deftest tp-binding-test-rollback-preserves-quit-primary () + "A quit remains primary when rollback hooks fail and later hooks still run." + (tp-binding-test--isolated + (let* ((signal (tp-signal-create 1)) + (tp-binding-test-transaction-trace nil) + (tp--transaction-precommit-functions + '(tp--binding-test-precommit-inject)) + (tp--transaction-precommit-allowed-functions + '(tp--binding-test-precommit-inject)) + (tp-binding-test-final-accept-function + (lambda () (signal 'quit '(:payload 9)))) + (tp--transaction-rollback-functions + '(tp-binding-test--rollback-hook-fail + tp-binding-test--rollback-hook-after)) + (failure + (tp-binding-test--capture-condition + (lambda () + (tp-with-transaction (tp-signal-set signal 2)))))) + (should (eq (car failure) 'quit)) + (should (= (plist-get (cdr failure) :payload) 9)) + (should + (equal (mapcar #'car + (tp--transaction-condition-trailer + failure :rollback-failures)) + '(rollback-hooks))) + (should (equal (nreverse tp-binding-test-transaction-trace) + '(precommit rollback-hook-fail rollback-hook-after))) + (should (= (tp-signal-peek signal) 1)) + (should (= (tp-signal-revision signal) 0))))) + +(ert-deftest tp-binding-test-final-accept-throw-rolls-back-nonlocally () + "A final-accept throw preserves its tag/value after complete rollback." + (tp-binding-test--isolated + (let* ((signal (tp-signal-create 1)) + (binding (tp-bind 'owner '(test . throw) + (lambda () (tp-signal-read signal)))) + (binding-revision (tp-binding-revision binding)) + (tp-binding-test-transaction-trace nil) + (tp--transaction-precommit-functions + '(tp--binding-test-precommit-inject)) + (tp--transaction-precommit-allowed-functions + '(tp--binding-test-precommit-inject)) + (tp-binding-test-final-accept-function + (lambda () (throw 'tp-binding-test-tag 'thrown-value))) + (tp--transaction-rollback-functions + '(tp-binding-test--rollback-hook-fail + tp-binding-test--rollback-hook-after)) + (result + (catch 'tp-binding-test-tag + (tp-with-transaction (tp-signal-set signal 2)) + 'not-thrown))) + (should (eq result 'thrown-value)) + (should (equal (nreverse tp-binding-test-transaction-trace) + '(precommit rollback-hook-fail rollback-hook-after))) + (should (= (tp-signal-peek signal) 1)) + (should (= (tp-signal-revision signal) 0)) + (should (= (tp-binding-read binding) 1)) + (should (= (tp-binding-revision binding) binding-revision)) + (setq tp-binding-test-final-accept-function nil + tp--transaction-precommit-functions nil + tp--transaction-rollback-functions nil) + (tp-signal-set signal 2) + (should (= (tp-signal-peek signal) 2)) + (should (= (tp-signal-revision signal) 1))))) + (ert-deftest tp-binding-test-nested-write-queues-a-second-pass () "A compute write queues stabilization instead of recursing." (tp-binding-test--isolated diff --git a/tests/tp-surface-tests.el b/tests/tp-surface-tests.el index 944cef9..12aa3a2 100644 --- a/tests/tp-surface-tests.el +++ b/tests/tp-surface-tests.el @@ -32,6 +32,21 @@ (tp-surface-test--leaf 'root (number-to-string (tp-signal-read signal))) (list :value (tp-signal-peek signal))))) +(defun tp-surface-test--capture-condition (function) + "Call FUNCTION and return its error or quit condition." + (condition-case condition + (progn (funcall function) nil) + (error condition) + (quit condition))) + +(defun tp-surface-test--update-mode (mode surface object plan signal value) + "Update SURFACE in MODE with PLAN while touching SIGNAL to VALUE." + (tp-with-transaction + (tp-signal-set signal value) + (if (eq mode 'full) + (tp-surface-update surface plan) + (tp-surface-update-scoped surface (list object) plan)))) + (defvar-local tp-surface-test--corrupt-next nil "When non-nil, the test after-change hook corrupts one inserted range.") @@ -636,6 +651,36 @@ (should (= (plist-get report :text-operations) 1)) (should (= (plist-get report :property-operations) 1))))) +(ert-deftest tp-surface-test-incremental-content-update-never-leaves-point-at-patch () + "Incremental text publication restores point after a length-changing patch." + (tp-surface-test--with-buffer + (let ((surface + (tp-surface-mount + buffer (tp-surface-test--leaf 'root "prefix old suffix" + '(face bold)) + '(:capability content)))) + (goto-char 8) + (let ((point-before (point))) + (tp-surface-update + surface (tp-surface-test--leaf 'root "prefix much-longer suffix" + '(face italic))) + (should (= (point) point-before)))))) + +(ert-deftest tp-surface-test-scoped-content-update-preserves-point-after-patch () + "A scoped update after point does not move the current cursor." + (tp-surface-test--with-buffer + (let ((surface + (tp-surface-mount + buffer (tp-surface-test--leaf 'root "before target after" + '(face bold)) + '(:capability content)))) + (goto-char 4) + (let ((point-before (point))) + (tp-surface-update + surface (tp-surface-test--leaf 'root "before changed-target after" + '(face italic))) + (should (= (point) point-before)))))) + (ert-deftest tp-surface-test-content-publication-reapplies-hook-corruption () "A hook that changes inserted properties must be corrected before commit." (tp-surface-test--with-buffer @@ -968,6 +1013,26 @@ (should (plist-get report :full-root)) (should (plist-get report :scope-fallback))))))) +(ert-deftest tp-surface-test-scope-ignores-property-plist-order-outside-owner () + "Keep a scoped update when unchanged outside text reorders properties." + (let ((old (copy-sequence "ABC")) + (new (copy-sequence "AMC"))) + (add-text-properties 0 1 '(face bold help-echo "left") old) + (add-text-properties 2 3 '(face italic help-echo "right") old) + ;; Same semantic properties, deliberately inserted in reverse order. + (add-text-properties 0 1 '(help-echo "left" face bold) new) + (add-text-properties 2 3 '(help-echo "right" face italic) new) + (let ((analysis + (tp--scope-replacement-analysis old new '((1 . 2)) '((1 . 2))))) + (should analysis) + (should (= 1 (length (plist-get analysis :patches)))) + (should (= 1 (plist-get (car (plist-get analysis :patches)) + :old-start))) + (should (tp--text-property-semantic-equal-p + (substring old 0 1) (substring new 0 1))) + (should (tp--text-property-semantic-equal-p + (substring old 2 3) (substring new 2 3)))))) + (ert-deftest tp-surface-test-scoped-update-rejects-outside-change () "A scoped update should fail before publication when another range changes." (tp-surface-test--with-buffer @@ -1608,6 +1673,279 @@ (should (= (tp-surface-revision surface) revision)) (should (eq object (tp-object-resolve surface '(root)))))))) +(ert-deftest tp-surface-test-full-and-scoped-precommit-stages-roll-back () + "Every full and scoped precommit stage restores the complete old state." + (dolist (mode '(full scoped)) + (dolist (stage '(validate view anchors lifecycle observers cleanup)) + (tp-surface-test--with-buffer + (let* ((signal (tp-signal-create 1)) + (surface + (tp-surface-mount + buffer (tp-surface-test--leaf 'root "old") + '(:capability content))) + (object (tp-object-resolve surface '(root))) + (revision (tp-surface-revision surface)) + (transaction-id tp--surface-transaction-id) + (fail-stage stage) + (tp--surface-precommit-step-function + (lambda (current _state) + (when (eq current fail-stage) + (error "Injected precommit %S" current))))) + (goto-char (point-min)) + (should-error + (tp-surface-test--update-mode + mode surface object (tp-surface-test--leaf 'root "new") + signal 2)) + (should (equal (buffer-string) "old")) + (should (= (tp-surface-revision surface) revision)) + (should (= tp--surface-transaction-id transaction-id)) + (should (= (tp-signal-peek signal) 1)) + (should (= (tp-signal-revision signal) 0)) + (should (eq object (tp-object-resolve surface '(root)))) + (should (= (point) (point-min))) + (setq fail-stage nil) + (tp-surface-test--update-mode + mode surface object (tp-surface-test--leaf 'root "new") + signal 2) + (should (equal (buffer-string) "new")) + (should (= (tp-surface-revision surface) (1+ revision))) + (should (= (tp-signal-peek signal) 2)) + (should (= (tp-signal-revision signal) 1))))))) + +(ert-deftest tp-surface-test-full-and-scoped-final-accept-roll-back () + "Final-accept error or quit rolls full and scoped state back exactly." + (dolist (mode '(full scoped)) + (dolist (injected '((error "Final accept error") (quit))) + (tp-surface-test--with-buffer + (let* ((signal (tp-signal-create 1)) + (surface + (tp-surface-mount + buffer (tp-surface-test--leaf 'root "old") + '(:capability content))) + (object (tp-object-resolve surface '(root))) + (revision (tp-surface-revision surface)) + (transaction-id tp--surface-transaction-id) + (original (symbol-function 'accept-change-group)) + failure) + (cl-letf (((symbol-function 'accept-change-group) + (lambda (_group) + (signal (car injected) (cdr injected))))) + (setq failure + (tp-surface-test--capture-condition + (lambda () + (tp-surface-test--update-mode + mode surface object + (tp-surface-test--leaf 'root "new") signal 2))))) + (should (eq (car failure) (car injected))) + (should (equal (buffer-string) "old")) + (should (= (tp-surface-revision surface) revision)) + (should (= tp--surface-transaction-id transaction-id)) + (should (= (tp-signal-peek signal) 1)) + (should (= (tp-signal-revision signal) 0)) + (should (eq object (tp-object-resolve surface '(root)))) + (cl-letf (((symbol-function 'accept-change-group) original)) + (tp-surface-test--update-mode + mode surface object (tp-surface-test--leaf 'root "new") + signal 2)) + (should (equal (buffer-string) "new")) + (should (= (tp-surface-revision surface) (1+ revision))) + (should (= (tp-signal-revision signal) 1))))))) + +(ert-deftest tp-surface-test-final-accept-pending-quit-runs-postaccept () + "A pending quit raised by accept cannot skip committed cleanup or observers." + (tp-surface-test--with-buffer + (let* ((signal (tp-signal-create 1)) + (surface + (tp-surface-mount + buffer (tp-surface-test--leaf 'root "old") + '(:capability content))) + (old-mounts (copy-sequence (tp--surface-mounts surface))) + (original (symbol-function 'accept-change-group)) + cleanup-ran observer-ran) + (setf (tp--surface-observers surface) + (list (lambda (_surface _report) (setq observer-ran t)))) + (let ((tp--surface-cleanup-step-function + (lambda (_surface _owner) (setq cleanup-ran t)))) + (cl-letf (((symbol-function 'accept-change-group) + (lambda (group) + (funcall original group) + (setq quit-flag t)))) + (tp-with-transaction + (tp-signal-set signal 2) + (tp-surface-update + surface (tp-surface-test--leaf 'root "new"))))) + (should (equal (buffer-string) "new")) + (should (= (tp-signal-peek signal) 2)) + (should cleanup-ran) + (should observer-ran) + (dolist (mount old-mounts) + (when (eq (tp--surface-mount-capability mount) 'content) + (should-not (marker-position (tp--surface-mount-start mount))) + (should-not (marker-position (tp--surface-mount-end mount)))))))) + +(ert-deftest tp-surface-test-precommit-validates-final-object-state () + "Precommit rejects corrupted published object state and rolls it back." + (tp-surface-test--with-buffer + (let* ((surface + (tp-surface-mount + buffer (tp-surface-test--leaf 'root "old") + '(:capability content))) + (object (tp-object-resolve surface '(root))) + (corrupt t) + (tp--surface-precommit-step-function + (lambda (stage _state) + (when (and corrupt (eq stage 'validate)) + (setf (tp--surface-object-live object) nil))))) + (should-error + (tp-surface-update surface (tp-surface-test--leaf 'root "new"))) + (should (equal (buffer-string) "old")) + (should (tp--surface-object-live object)) + (setq corrupt nil) + (tp-surface-update surface (tp-surface-test--leaf 'root "new")) + (should (equal (buffer-string) "new"))))) + +(ert-deftest tp-surface-test-postaccept-failures-are-contained-and-recorded () + "Cleanup, observer, committed, and callback failures cannot undo accept." + (dolist (cleanup-condition '((error "Cleanup error") (quit))) + (tp-surface-test--with-buffer + (let* ((signal (tp-signal-create 1)) + (surface + (tp-surface-mount + buffer (tp-surface-test--leaf 'root "old") + '(:capability content))) + (old-mounts (copy-sequence (tp--surface-mounts surface))) + (cleanup-fired nil) + (tp--surface-cleanup-step-function + (lambda (_surface owner) + (when (and (eq owner 'view-markers) (not cleanup-fired)) + (setq cleanup-fired t) + (signal (car cleanup-condition) + (cdr cleanup-condition))))) + (tp--transaction-committed-functions + (append tp--transaction-committed-functions + (list (lambda () (error "Committed failure")))))) + (setf (tp--surface-observers surface) + (list (lambda (_surface _report) (error "Observer error")) + (lambda (_surface _report) (signal 'quit nil)))) + (tp-with-transaction + (tp--enqueue-after-commit (lambda () (error "Callback error"))) + (tp--enqueue-after-commit (lambda () (signal 'quit nil))) + (tp-signal-set signal 2) + (tp-surface-update surface (tp-surface-test--leaf 'root "new"))) + (should (equal (buffer-string) "new")) + (should (= (tp-signal-peek signal) 2)) + (should (= (tp-signal-revision signal) 1)) + (should (= (length (plist-get (tp-surface-report surface) + :cleanup-errors)) + 1)) + (should (= (length (plist-get (tp-surface-report surface) + :observer-errors)) + 2)) + (should (equal (mapcar #'car tp--last-transaction-diagnostics) + '(committed after-commit after-commit))) + (dolist (mount old-mounts) + (when (eq (tp--surface-mount-capability mount) 'content) + (should-not (marker-position (tp--surface-mount-start mount))) + (should-not (marker-position (tp--surface-mount-end mount))))))))) + +(ert-deftest tp-surface-test-view-capture-cleans-partial-markers () + "A partial view snapshot failure disposes every marker already allocated." + (tp-surface-test--with-buffer + (let ((original (symbol-function 'copy-marker)) + (calls 0) + created) + (cl-letf (((symbol-function 'get-buffer-window-list) + (lambda (&rest _arguments) (list (selected-window)))) + ((symbol-function 'copy-marker) + (lambda (&rest arguments) + (cl-incf calls) + (when (= calls 2) (error "Second marker failure")) + (let ((marker (apply original arguments))) + (push marker created) + marker)))) + (should-error (tp--capture-view-state (list buffer)))) + (should (= (length created) 1)) + (should-not (marker-position (car created)))))) + +(ert-deftest tp-surface-test-anchor-apply-failure-uses-complete-undo-journal () + "An anchor mutation failure restores old opaque ownership before retry." + (tp-surface-test--with-buffer + (insert "host") + (let* ((old-anchor (tp-range-anchor-create buffer 1 5)) + (new-anchor (tp-range-anchor-create buffer 1 5)) + (current old-anchor) + (producer + (lambda (context) + (let ((object (tp-object-ensure context nil 'root 'range))) + (tp-object-attach-range context object current)) + (tp-surface-plan-create + :key 'root :kind 'range :props '(help-echo "owned") + :capability 'properties))) + (surface (tp-surface-mount + buffer producer '(:capability properties))) + (original (symbol-function 'tp--apply-anchor-ownership))) + (setq current new-anchor) + (cl-letf (((symbol-function 'tp--apply-anchor-ownership) + (lambda (&rest arguments) + (apply original arguments) + (error "After anchor ownership mutation")))) + (should-error (tp-surface-update surface producer))) + (should (memq surface (tp--anchor-surfaces old-anchor))) + (should-not (memq surface (tp--anchor-surfaces new-anchor))) + (should (tp-range-anchor-live-p old-anchor)) + (tp-surface-update surface producer) + (should-not (tp-range-anchor-live-p old-anchor)) + (should (memq surface (tp--anchor-surfaces new-anchor)))))) + +(ert-deftest tp-surface-test-phase2-failure-does-not-skip-later-undo () + "A phase-2 restoration failure still runs surface, view, and anchor undo." + (tp-surface-test--with-buffer + (let* ((surface (tp-surface-mount + buffer (tp-surface-test--leaf 'root "old") + '(:capability content))) + (restore-properties + (symbol-function 'tp--restore-property-journals)) + (restore-snapshot + (symbol-function 'tp--restore-surface-snapshot)) + (restore-view (symbol-function 'tp--restore-view-state)) + (undo-anchors (symbol-function 'tp--undo-anchor-ownership)) + trace failure) + (cl-letf (((symbol-function 'accept-change-group) + (lambda (_group) + (setq trace nil) + (error "Primary accept failure"))) + ((symbol-function 'tp--restore-property-journals) + (lambda (journals) + (funcall restore-properties journals) + (push 'properties trace) + (error "Property rollback failure"))) + ((symbol-function 'tp--restore-surface-snapshot) + (lambda (&rest arguments) + (push 'surface trace) + (apply restore-snapshot arguments))) + ((symbol-function 'tp--restore-view-state) + (lambda (&rest arguments) + (push 'view trace) + (apply restore-view arguments))) + ((symbol-function 'tp--undo-anchor-ownership) + (lambda (undo) + (push 'anchors trace) + (funcall undo-anchors undo)))) + (setq failure + (tp-surface-test--capture-condition + (lambda () + (tp-surface-update + surface (tp-surface-test--leaf 'root "new")))))) + (should (eq (car failure) 'error)) + (should (equal (cadr failure) "Primary accept failure")) + (should (equal (nreverse trace) + '(properties surface view anchors))) + (should (equal (buffer-string) "old")) + (should (equal (mapcar #'car + (tp--transaction-condition-trailer + failure :rollback-failures)) + '(rollback-hooks)))))) + (ert-deftest tp-surface-test-global-signal-update-is-multi-surface-atomic () "A second-surface failure rolls back buffers, bindings, and source value." (let* ((signal (tp-signal-create 1)) @@ -1925,6 +2263,50 @@ (should (= (tp-surface-revision surface) revision)) (should (equal (buffer-string) "1"))))) +(ert-deftest tp-surface-test-output-equal-precommit-finalizes-and-rolls-back () + "Output-equal candidates finalize lifecycle without visible publication." + (tp-surface-test--with-buffer + (let* ((source (tp-signal-create 10)) + (auxiliary (tp-signal-create 1)) + (include t) + binding + (producer + (lambda (context) + (let ((object (tp-object-ensure context nil 'root 'text))) + (when include + (setq binding + (tp-bind object '(test . optional) + (lambda () (tp-signal-read auxiliary)))) + (tp-binding-read binding))) + (tp-surface-test--leaf + 'root (number-to-string (/ (tp-signal-read source) 10))))) + (surface (tp-surface-mount + buffer producer '(:capability content))) + (revision (tp-surface-revision surface)) + (transaction-id tp--surface-transaction-id) + (fail t) + (tp--surface-precommit-step-function + (lambda (step _state) + (when (and fail (eq step 'lifecycle-finalized)) + (error "After output-equal lifecycle"))))) + (setq include nil) + (should-error (tp-signal-set source 11)) + (should (tp-binding-live-p binding)) + (should (= (tp-signal-subscriber-count auxiliary) 1)) + (should (= (tp-signal-peek source) 10)) + (should (= (tp-signal-revision source) 0)) + (should (= (tp-surface-revision surface) revision)) + (should (= tp--surface-transaction-id transaction-id)) + (should (equal (buffer-string) "1")) + (setq fail nil) + (tp-signal-set source 11) + (should-not (tp-binding-live-p binding)) + (should (= (tp-signal-subscriber-count auxiliary) 0)) + (should (= (tp-signal-revision source) 1)) + (should (= (tp-surface-revision surface) revision)) + (should (= tp--surface-transaction-id transaction-id)) + (should (equal (buffer-string) "1"))))) + (ert-deftest tp-surface-test-keyed-reorder-preserves-object-handles () "Keyed children keep identity when their display order changes." (tp-surface-test--with-buffer diff --git a/tp-reactive.el b/tp-reactive.el index f3c42d4..83042a3 100644 --- a/tp-reactive.el +++ b/tp-reactive.el @@ -46,6 +46,11 @@ "One rollback-capable side-state participant in a TP transaction." key publish rollback) +(cl-defstruct (tp--signal-commit-entry + (:constructor tp--make-signal-commit-entry)) + "One transaction signal's candidate and exact committed rollback state." + signal old-committed-value old-revision candidate-value) + (defconst tp--reactive-absent (make-symbol "tp-reactive-absent")) (defvar tp--signal-id-counter 0) @@ -76,6 +81,9 @@ (defvar tp--transaction-participants nil) (defvar tp--transaction-participant-keys nil) (defvar tp--transaction-published-participants nil) +(defvar tp--transaction-signal-commit-journal nil) +(defvar tp--transaction-contained-failures nil) +(defvar tp--last-transaction-diagnostics nil) (defvar tp--current-binding nil) (defvar tp--binding-compute-stack nil) (defvar tp--collected-dependency-set nil) @@ -83,10 +91,22 @@ (defvar tp--binding-touch-function nil) (defvar tp--binding-changed-functions nil) (defvar tp--transaction-publish-functions nil) +(defvar tp--transaction-precommit-functions nil + "Internal TP-owned precommit hook symbols in deterministic order.") +(defconst tp--transaction-precommit-allowed-functions + '(tp--surface-precommit-transaction) + "Exact TP-owned symbols allowed in the internal precommit registry.") +(defvar tp--transaction-final-accept-function + #'tp--transaction-noop-final-accept + "Internal single final-accept function for the active transaction.") (defvar tp--transaction-rollback-functions nil) (defvar tp--transaction-rollback-final-functions nil) (defvar tp--transaction-committed-functions nil) +(defconst tp--transaction-condition-trailer-tag + (make-symbol "tp--transaction-condition-trailer") + "Unforgeable tag separating primary condition data from TP metadata.") + (defun tp--counter-increment (key) "Increment reactive counter KEY." (setq tp--reactive-counters @@ -587,9 +607,10 @@ transaction." (dolist (participant tp--transaction-published-participants) (condition-case failure (funcall (tp--transaction-participant-rollback participant)) - (error - (push (list :key (tp--transaction-participant-key participant) - :error failure) + ((error quit) + (push (list 'participants + (tp--transaction-participant-key participant) + failure) failures)))) (nreverse failures))) @@ -646,18 +667,178 @@ transaction." (dolist (dependency (tp-binding-dependencies binding)) (tp--subscription-add dependency binding))))))) -(defun tp--commit-signal-values () - "Commit candidate signal values after a successful flush." - (dolist (signal (nreverse tp--transaction-signals)) +(defun tp--transaction-noop-final-accept () + "Accept a pure reactive transaction with no external publication work." + nil) + +(defun tp--transaction-precommit-function-p (function) + "Return non-nil when FUNCTION is a declared TP-internal hook symbol." + (and (symbolp function) + (memq function tp--transaction-precommit-allowed-functions) + (fboundp function))) + +(defun tp--transaction-register-precommit-function (function) + "Register declared TP-internal precommit FUNCTION once." + (unless (tp--transaction-precommit-function-p function) + (signal 'tp-reactive-error + (list :invalid-precommit-function function))) + (unless (memq function tp--transaction-precommit-functions) + (setq tp--transaction-precommit-functions + (append tp--transaction-precommit-functions (list function)))) + function) + +(defun tp--transaction-install-final-accept (function) + "Install the active transaction's single final-accept FUNCTION." + (unless tp--transaction-active + (signal 'tp-reactive-error (list :final-accept-outside-transaction))) + (unless (functionp function) + (signal 'wrong-type-argument (list 'functionp function))) + (unless (eq tp--transaction-final-accept-function + #'tp--transaction-noop-final-accept) + (signal 'tp-reactive-error + (list :duplicate-final-accept-function + tp--transaction-final-accept-function function))) + (setq tp--transaction-final-accept-function function)) + +(defun tp--run-contained-transaction-functions (phase functions) + "Run postaccept PHASE FUNCTIONS and record contained failures." + (dolist (function (tp--transaction-hook-functions functions)) + (let ((inhibit-quit t) + (quit-flag nil)) + (condition-case failure + (funcall function) + ((error quit) + (push (list phase function failure) + tp--transaction-contained-failures)))))) + +(defun tp--run-transaction-precommit-functions () + "Run the internal declared precommit registry in deterministic order." + (dolist (function tp--transaction-precommit-functions) + (unless (tp--transaction-precommit-function-p function) + (signal 'tp-reactive-error + (list :invalid-precommit-function function))) + (funcall function))) + +(defun tp--prepare-signal-commit-journal () + "Capture every touched signal before any committed field is mutated." + (setq tp--transaction-signal-commit-journal + (mapcar + (lambda (signal) + (tp--make-signal-commit-entry + :signal signal + :old-committed-value (tp-signal-committed-value signal) + :old-revision (tp-signal-revision signal) + :candidate-value + (gethash signal tp--transaction-signal-values))) + (nreverse (copy-sequence tp--transaction-signals))))) + +(defun tp--commit-signal-entry (entry) + "Commit signal journal ENTRY exactly once." + (let ((signal (tp--signal-commit-entry-signal entry))) (setf (tp-signal-committed-value signal) - (gethash signal tp--transaction-signal-values) - (tp-signal-revision signal) (1+ (tp-signal-revision signal))))) + (tp--signal-commit-entry-candidate-value entry) + (tp-signal-revision signal) + (1+ (tp--signal-commit-entry-old-revision entry))))) + +(defun tp--restore-signal-entry (entry) + "Restore signal journal ENTRY's exact committed state." + (let ((signal (tp--signal-commit-entry-signal entry))) + (setf (tp-signal-committed-value signal) + (tp--signal-commit-entry-old-committed-value entry) + (tp-signal-revision signal) + (tp--signal-commit-entry-old-revision entry)))) + +(defun tp--commit-signal-values () + "Journal and commit candidate signals in stable first-touch order." + (tp--prepare-signal-commit-journal) + (dolist (entry tp--transaction-signal-commit-journal) + (tp--commit-signal-entry entry))) + +(defun tp--restore-transaction-counters (snapshot) + "Restore reactive counters from SNAPSHOT." + (setq tp--reactive-counters snapshot)) + +(defun tp--transaction-hook-functions (value) + "Return hook VALUE as one ordered function list." + (cond + ((null value) nil) + ((functionp value) (list value)) + (t value))) + +(defun tp--rollback-hook-phase (phase functions) + "Run rollback PHASE FUNCTIONS and return ordered failures." + (let (failures) + (dolist (function (tp--transaction-hook-functions functions)) + (condition-case failure + (funcall function) + ((error quit) + (push (list phase function failure) failures)))) + (nreverse failures))) + +(defun tp--rollback-signal-journal () + "Restore every signal journal entry and return ordered failures." + (let (failures) + (dolist (entry tp--transaction-signal-commit-journal) + (condition-case failure + (tp--restore-signal-entry entry) + ((error quit) + (push (list 'signal-journal + (tp-signal-id + (tp--signal-commit-entry-signal entry)) + failure) + failures)))) + (nreverse failures))) + +(defun tp--rollback-transaction-state (counter-snapshot) + "Restore COUNTER-SNAPSHOT through every rollback phase. +Return contained failures in phase order." + (let ((failures (tp--rollback-transaction-participants))) + (setq failures + (append + failures + (tp--rollback-hook-phase + 'rollback-hooks tp--transaction-rollback-functions) + (tp--rollback-signal-journal))) + (condition-case failure + (tp--rollback-bindings) + ((error quit) + (setq failures + (append failures + (list (list 'bindings 'binding-graph failure)))))) + (condition-case failure + (tp--restore-transaction-counters counter-snapshot) + ((error quit) + (setq failures + (append failures + (list (list 'counters 'reactive-counters failure)))))) + (append + failures + (tp--rollback-hook-phase + 'rollback-final tp--transaction-rollback-final-functions)))) + +(defun tp--resignal-transaction-primary (primary-condition failures) + "Re-signal PRIMARY-CONDITION with ordered rollback FAILURES attached." + (signal (car primary-condition) + (if failures + (append (cdr primary-condition) + (list tp--transaction-condition-trailer-tag + (list :rollback-failures failures))) + (cdr primary-condition)))) + +(defun tp--transaction-condition-trailer (condition property) + "Return PROPERTY from the exact transaction trailer of CONDITION. +Transaction rollback metadata is appended after, and never merged into, +the primary condition data." + (let ((trailer (last condition 2))) + (and (eq (car trailer) tp--transaction-condition-trailer-tag) + (plist-get (cadr trailer) property)))) (defun tp--call-with-transaction (function) "Call FUNCTION in one atomic signal and binding transaction." (if tp--transaction-active (funcall function) - (let (after-commit result) + (let (after-commit result + (tp--transaction-contained-failures nil)) (setq result (let ((tp--transaction-active t) (tp--transaction-signal-values @@ -675,37 +856,83 @@ transaction." (tp--transaction-participants nil) (tp--transaction-participant-keys nil) (tp--transaction-published-participants nil) + (tp--transaction-signal-commit-journal nil) + (tp--transaction-final-accept-function + #'tp--transaction-noop-final-accept) (counter-snapshot (copy-sequence tp--reactive-counters)) (tp--transaction-counter-start nil) - success transaction-result rollback-failures) + success transaction-result primary-condition + rollback-failures pending-quit) (setq tp--transaction-counter-start counter-snapshot) (unwind-protect - (progn - (setq transaction-result (funcall function)) - (tp--flush-dirty-bindings) - (run-hooks 'tp--transaction-publish-functions) - (tp--publish-transaction-participants) - (tp--commit-signal-values) - (setq success t) - transaction-result) + (condition-case condition + (progn + (setq transaction-result (funcall function)) + (tp--flush-dirty-bindings) + (run-hooks 'tp--transaction-publish-functions) + (tp--publish-transaction-participants) + (tp--run-transaction-precommit-functions) + (tp--commit-signal-values) + (condition-case deferred-quit + (progn + (let ((inhibit-quit t) + (quit-flag nil)) + (unwind-protect + (progn + (unless + (functionp + tp--transaction-final-accept-function) + (signal + 'tp-reactive-error + (list + :invalid-final-accept-function + tp--transaction-final-accept-function))) + (funcall + tp--transaction-final-accept-function) + (setq success t) + (when quit-flag + (setq pending-quit t + quit-flag nil)) + (tp--run-contained-transaction-functions + 'committed + tp--transaction-committed-functions) + (setq after-commit + (nreverse + tp--transaction-after-commit-callbacks)) + (when pending-quit + (push + (list 'final-accept + tp--transaction-final-accept-function + '(quit)) + tp--transaction-contained-failures))) + (setq quit-flag nil))) + nil) + (quit + (if success + (push + (list 'final-accept + tp--transaction-final-accept-function + deferred-quit) + tp--transaction-contained-failures) + (signal (car deferred-quit) + (cdr deferred-quit)))))) + ((error quit) + (setq primary-condition condition))) (unless success - (let ((inhibit-quit t)) - (setq rollback-failures - (tp--rollback-transaction-participants)) - (run-hooks 'tp--transaction-rollback-functions) - (tp--rollback-bindings) - (setq tp--reactive-counters counter-snapshot) - (run-hooks 'tp--transaction-rollback-final-functions) - (when rollback-failures - (signal 'tp-reactive-error - (list :participant-rollback-failed - rollback-failures)))))) - (when success - (run-hooks 'tp--transaction-committed-functions) - (setq after-commit - (nreverse tp--transaction-after-commit-callbacks))) + (let ((inhibit-quit t) + (quit-flag nil)) + (unwind-protect + (setq rollback-failures + (tp--rollback-transaction-state + counter-snapshot)) + (setq quit-flag nil))))) + (when primary-condition + (tp--resignal-transaction-primary + primary-condition rollback-failures)) transaction-result)) - (dolist (callback after-commit) (funcall callback)) + (tp--run-contained-transaction-functions 'after-commit after-commit) + (setq tp--last-transaction-diagnostics + (nreverse tp--transaction-contained-failures)) result))) ;;;###autoload diff --git a/tp-surface.el b/tp-surface.el index 310e2af..fb8ab75 100644 --- a/tp-surface.el +++ b/tp-surface.el @@ -54,6 +54,8 @@ 'tp-surface-error) (define-error 'tp-owned-result-error "Invalid TP owned producer result" 'tp-surface-error) +(define-error 'tp-surface-rollback-error "TP surface rollback phase failed" + 'tp-surface-error) (cl-defstruct (tp-surface-plan (:constructor tp--make-surface-plan)) "Pure retained node data accepted by a TP surface." @@ -68,6 +70,15 @@ PLAN-OWNED-P marks an explicit candidate-local ownership transfer. The owned result is bound to OWNER-CONTEXT and consumed once during prepare." plan client-state plan-owned-p owner-context consumed-p) +(cl-defstruct (tp-retained-content-candidate + (:constructor tp--make-retained-content-candidate)) + "Candidate for a content-only update on an unchanged TP object tree. +PLAN is the candidate's unchanged object topology with a new text leaf, +RENDERED is the already-propertized candidate text, and RANGES are the +candidate-owned content-range attachments. CONTEXT authenticates the +one-shot producer result to the active prepare transaction." + plan rendered ranges client-state context full-surface-p consumed-p) + (cl-defstruct (tp-surface (:constructor tp--make-surface) (:conc-name tp--surface-)) @@ -111,7 +122,7 @@ owned result is bound to OWNER-CONTEXT and consumed once during prepare." surface context plan rendered mount-specs ledger-specs property-operations objects client-state producer initial created removed moved reconciled scope-objects scope-patches scope-fallback live-mounts live-mount-index - live-ledger report) + live-ledger report retained-content-p) (cl-defstruct (tp--surface-snapshot (:constructor tp--make-surface-snapshot)) plan objects mounts index mount-index ledger client-state producer revision @@ -126,6 +137,8 @@ owned result is bound to OWNER-CONTEXT and consumed once during prepare." (defvar tp--surface-publishing nil) (defvar tp--surface-guarding-prepare nil) (defvar tp--surface-publication-step-function nil) +(defvar tp--surface-precommit-step-function nil) +(defvar tp--surface-cleanup-step-function nil) (defvar tp--content-published-ranges nil) (defvar-local tp--buffer-surfaces nil) @@ -247,6 +260,28 @@ Use `tp-surface-result-create' for ordinary caller-owned plans." (tp--make-surface-result (tp--validate-surface-plan-tree plan) client-state t context)) +(defun tp-surface-retained-content-result-create + (context plan rendered ranges &optional client-state full-surface-p) + "Create a one-shot retained content candidate for CONTEXT. +PLAN must preserve the committed TP object topology and contain the new text +leaf. RENDERED is the final propertized text and RANGES are candidate-local +content attachments already associated with PLAN's text leaf. This entry +point is intentionally narrow: callers must prove that object topology is +unchanged; TP still validates every object, range, scope, and publication +phase before accepting the candidate." + (tp--validate-prepare-context context) + (unless (eq context tp--current-prepare-context) + (signal 'tp-owned-result-error + (list :context-not-current context tp--current-prepare-context))) + (unless (and (tp-surface-plan-p plan) + (stringp rendered) + (proper-list-p ranges)) + (signal 'tp-invalid-surface-plan + (list :retained-content plan rendered ranges))) + (tp--make-retained-content-candidate + :plan plan :rendered rendered :ranges ranges :client-state client-state + :context context :full-surface-p full-surface-p)) + (defun tp--property-value-equal-p (property left right) "Return non-nil when PROPERTY values LEFT and RIGHT are policy-equal." (funcall (tp-property-policy-equality (tp-register-text-property property)) @@ -730,6 +765,17 @@ OWNED-P transfers candidate-local property values through the render pass." (defun tp--producer-result (value surface options context) "Normalize producer VALUE for SURFACE and active CONTEXT using OPTIONS." (cond + ((tp-retained-content-candidate-p value) + (unless (and context + (tp--context-active context) + (eq context tp--current-prepare-context) + (eq context + (tp-retained-content-candidate-context value)) + (not (tp-retained-content-candidate-consumed-p value))) + (signal 'tp-owned-result-error (list value context))) + (setf (tp-retained-content-candidate-consumed-p value) t + (tp-retained-content-candidate-context value) nil) + (list :retained-content value)) ((tp-surface-result-p value) (let ((owned-p (tp-surface-result-plan-owned-p value))) (if owned-p @@ -1054,6 +1100,55 @@ When RELATIVE is non-nil, return offsets from the surface start." (when (= (caar new-ranges) (cdar new-ranges)) (pop new-ranges)))) (nreverse pairs))) +(defun tp--text-properties-canonical-at (text position) + "Return TEXT properties at POSITION in deterministic key order." + (sort + (cl-loop for (property value) on (text-properties-at position text) + by #'cddr + collect (cons property value)) + (lambda (left right) + (string< (symbol-name (car left)) + (symbol-name (car right)))))) + +(defun tp--text-property-semantic-equal-p (left right) + "Return non-nil when propertized strings LEFT and RIGHT are policy-equal." + (when (and (stringp left) + (stringp right) + (= (length left) (length right))) + ;; Compare characters once. The property loop below visits only + ;; property-boundary positions, avoiding a substring/all-runs allocation + ;; on every scoped update. + (and (equal (substring-no-properties left) + (substring-no-properties right)) + (let ((position 0) + (limit (length left)) + equal-p) + (setq equal-p t) + (while (and equal-p (< position limit)) + (unless (equal-including-properties + (tp--text-properties-canonical-at left position) + (tp--text-properties-canonical-at right position)) + (setq equal-p nil)) + (setq position + (min (or (next-property-change position left limit) limit) + (or (next-property-change position right limit) limit)))) + equal-p)))) + +(defun tp--layout-separator-only-p (text) + "Return non-nil when TEXT contains only layout line separators." + (and (stringp text) + (cl-every (lambda (char) (= char ?\n)) + (substring-no-properties text)))) + +(defun tp--scope-outside-separator-equivalent-p (left right) + "Return non-nil when scoped outside gaps differ only by line separators. +Logical Ebox owners may absorb a newline between two owned rendered runs when +one candidate line grows or shrinks. Treating that delimiter as part of the +owner preserves scoped publication while still rejecting every non-separator +outside change." + (and (tp--layout-separator-only-p left) + (tp--layout-separator-only-p right))) + (defun tp--scope-patches-between-anchors (old new anchors) "Return patches between equal outside ANCHORS in OLD and NEW." (let ((old-position 0) (new-position 0) patches) @@ -1063,7 +1158,7 @@ When RELATIVE is non-nil, return offsets from the surface start." (let* ((old-end (nth 0 anchor)) (new-end (nth 2 anchor)) (replacement (substring new new-position new-end))) - (unless (equal-including-properties + (unless (tp--text-property-semantic-equal-p (substring old old-position old-end) replacement) (push (list :old-start old-position :old-end old-end :new-start new-position :new-end new-end @@ -1078,12 +1173,13 @@ When RELATIVE is non-nil, return offsets from the surface start." Return scoped replacement metadata, or nil on mismatch." (let* ((old-outside (tp--complement-ranges (length old) old-ranges)) (new-outside (tp--complement-ranges (length new) new-ranges))) - (when (equal-including-properties - (tp--substring-ranges old old-outside) - (tp--substring-ranges new new-outside)) - (list :patches - (tp--scope-patches-between-anchors - old new (tp--pair-outside-ranges old-outside new-outside)))))) + (let ((old-gap (tp--substring-ranges old old-outside)) + (new-gap (tp--substring-ranges new new-outside))) + (when (or (tp--text-property-semantic-equal-p old-gap new-gap) + (tp--scope-outside-separator-equivalent-p old-gap new-gap)) + (list :patches + (tp--scope-patches-between-anchors + old new (tp--pair-outside-ranges old-outside new-outside))))))) (defun tp--prepare-content-scope (surface rendered mount-specs objects options) @@ -1135,6 +1231,116 @@ MOUNT-SPECS describe the candidate object ranges and OPTIONS controls mismatch." (list :surface (tp--surface-id surface) :operation outside :ranges ranges)))))) +(defun tp--prepare-retained-content + (surface candidate context scope-objects scope-options initial) + "Prepare CANDIDATE without traversing or rendering its unchanged plan. +The candidate is valid only when its plan has the same three-level content +surface topology as the committed plan. Its text and content attachments are +still validated and published through the ordinary TP transaction phases." + (when initial + (signal 'tp-invalid-surface-plan + (list :retained-content-initial t))) + (unless (eq (tp--surface-capability surface) 'content) + (signal 'tp-capability-error (list :retained-content 'properties))) + (let* ((plan (tp-retained-content-candidate-plan candidate)) + (rendered (tp-retained-content-candidate-rendered candidate)) + (ranges (tp-retained-content-candidate-ranges candidate)) + (objects (tp--context-objects context)) + (root-path (list (tp--plan-segment plan 0))) + (fragment-plan (car (tp-surface-plan-children plan))) + (fragment-path (append root-path + (list (tp--plan-segment fragment-plan 0)))) + (text-plan (car (tp-surface-plan-children fragment-plan))) + (text-path (append fragment-path + (list (tp--plan-segment text-plan 0)))) + (surface-root (gethash root-path objects)) + (fragment-root (gethash fragment-path objects)) + (text-leaf (gethash text-path objects))) + (unless (and surface-root fragment-root text-leaf + (null (tp-surface-plan-props plan)) + (null (tp-surface-plan-props fragment-plan)) + (null (tp-surface-plan-props text-plan)) + (null (tp-surface-plan-children text-plan)) + (equal (tp-surface-plan-key plan) + (tp-surface-plan-key (tp--surface-plan surface)))) + (signal 'tp-invalid-surface-plan + (list :retained-content-topology plan))) + (tp--validate-plan-capability plan (tp--surface-capability surface)) + ;; `ebox-surface--owned-ranges' transfers freshly allocated range plists + ;; for this candidate. Install that list directly after validating the + ;; same invariants as the public attach operation; copying it again here + ;; would recreate the hot-path allocation we are removing. + (when (and ranges + (null (gethash text-leaf + (tp--context-content-range-attachments context)))) + (tp--validate-context-object context text-leaf) + (dolist (range ranges) + (let ((object (plist-get range :object)) + (start (plist-get range :start)) + (end (plist-get range :end))) + (tp--validate-context-object context object) + (tp--validate-content-range start end) + (unless (<= end (length rendered)) + (signal 'tp-invalid-content-range + (list :start start :end end + :leaf-length (length rendered))))) + (puthash text-leaf ranges + (tp--context-content-range-attachments context))) + (let* ((length (length rendered)) + (records (list (list :object text-leaf :start 0 :end length + :props nil :tags nil :leaf t) + (list :object fragment-root :start 0 :end length + :props nil :tags nil :leaf nil) + (list :object surface-root :start 0 :end length + :props nil :tags nil :leaf nil))) + (mount-specs (tp--content-mount-specs records context)) + (_ranges (tp--validate-cross-surface-ranges + surface mount-specs rendered)) + (full-surface-p + (tp-retained-content-candidate-full-surface-p candidate)) + (scope-analysis + (cond + ;; A root Ebox scroll owner is the only retained-content caller + ;; allowed to widen this proof. Its semantic root owns every + ;; rendered line, including separator characters that are not + ;; tagged by a child mount, so the full surface replacement is + ;; the exact scope rather than a fallback. + ((and full-surface-p scope-objects) + (let ((old-length (length (tp--surface-content-string surface)))) + (list :patches + (list (list :old-start 0 :old-end old-length + :new-start 0 :new-end (length rendered) + :replacement rendered)) + :fallback nil))) + (scope-objects + (tp--prepare-content-scope + surface rendered mount-specs scope-objects scope-options)))) + (scope-patches (plist-get scope-analysis :patches)) + (retained-content-p + (and full-surface-p + scope-analysis + (not (plist-get scope-analysis :fallback)))) + (old-objects (tp--context-live-objects surface)) + (candidate-objects (tp--candidate-object-list context))) + (unless (equal (sort (copy-sequence old-objects) + (lambda (left right) + (< (tp--surface-object-id left) + (tp--surface-object-id right)))) + (sort (copy-sequence candidate-objects) + (lambda (left right) + (< (tp--surface-object-id left) + (tp--surface-object-id right))))) + (signal 'tp-orphan-object (list :retained-content-object-set))) + (tp--make-prepared-surface + :surface surface :context context :plan plan :rendered rendered + :mount-specs mount-specs :objects candidate-objects + :client-state (tp-retained-content-candidate-client-state candidate) + :producer candidate :initial nil :created nil :removed nil :moved 0 + :reconciled 0 :scope-objects scope-objects + :scope-patches scope-patches + :scope-fallback (plist-get scope-analysis :fallback) + :retained-content-p retained-content-p))))) + (defun tp--prepare-surface (surface input options initial) "Prepare INPUT for SURFACE without publishing it. OPTIONS configure the mount and INITIAL is non-nil for first publication." @@ -1151,52 +1357,63 @@ OPTIONS configure the mount and INITIAL is non-nil for first publication." (success nil) result) (unwind-protect - (let* ((normalized - (tp--call-with-prepare-buffer-guard - surface - (lambda () - (tp--prepare-input surface input options context)))) - (plan (nth 0 normalized)) - (client-state (nth 1 normalized)) - (owned-p (nth 2 normalized)) - (_capability (tp--validate-plan-capability - plan (tp--surface-capability surface))) - (_tree (tp--validate-context-tree context plan)) - (render-result (tp--render-plan plan context owned-p)) - (rendered (car render-result)) - (records (cdr render-result)) - (objects (tp--candidate-object-list context)) - (old-objects (tp--context-live-objects surface)) - (created (tp--object-set-difference objects old-objects)) - (removed (tp--object-set-difference old-objects objects)) - (mount-specs (tp--plan-record-mount-specs - records (tp--surface-capability surface) context)) - (_ranges (tp--validate-cross-surface-ranges - surface mount-specs rendered)) - (property-result - (when (eq (tp--surface-capability surface) 'properties) - (tp--prepare-property-ledger surface mount-specs))) - (scope-analysis - (when scope-objects - (if (eq (tp--surface-capability surface) 'content) - (tp--prepare-content-scope - surface rendered mount-specs scope-objects - scope-options) - (tp--prepare-properties-scope - surface mount-specs (cdr property-result) scope-objects - scope-options))))) - (setq result - (tp--make-prepared-surface - :surface surface :context context :plan plan :rendered rendered - :mount-specs mount-specs :ledger-specs (car property-result) - :property-operations (cdr property-result) :objects objects - :client-state client-state :producer input :initial initial - :created created :removed removed - :moved (tp--plan-moved-count (tp--surface-plan surface) plan) - :reconciled (tp--candidate-reconciled-count context created) - :scope-objects scope-objects - :scope-patches (plist-get scope-analysis :patches) - :scope-fallback (plist-get scope-analysis :fallback))) + (let ((normalized + (tp--call-with-prepare-buffer-guard + surface + (lambda () + (tp--prepare-input surface input options context))))) + (if (eq (car normalized) :retained-content) + (setq result + (tp--prepare-retained-content + surface (cadr normalized) context scope-objects + scope-options initial)) + (let* ((plan (nth 0 normalized)) + (client-state (nth 1 normalized)) + (owned-p (nth 2 normalized)) + (_capability (tp--validate-plan-capability + plan (tp--surface-capability surface))) + (_tree (tp--validate-context-tree context plan)) + (render-result (tp--render-plan plan context owned-p)) + (rendered (car render-result)) + (records (cdr render-result)) + (objects (tp--candidate-object-list context)) + (old-objects (tp--context-live-objects surface)) + (created (tp--object-set-difference objects old-objects)) + (removed (tp--object-set-difference old-objects objects)) + (mount-specs (tp--plan-record-mount-specs + records (tp--surface-capability surface) + context)) + (_ranges (tp--validate-cross-surface-ranges + surface mount-specs rendered)) + (property-result + (when (eq (tp--surface-capability surface) 'properties) + (tp--prepare-property-ledger surface mount-specs))) + (scope-analysis + (when scope-objects + (if (eq (tp--surface-capability surface) 'content) + (tp--prepare-content-scope + surface rendered mount-specs scope-objects + scope-options) + (tp--prepare-properties-scope + surface mount-specs (cdr property-result) + scope-objects scope-options))))) + (setq result + (tp--make-prepared-surface + :surface surface :context context :plan plan + :rendered rendered :mount-specs mount-specs + :ledger-specs (car property-result) + :property-operations (cdr property-result) + :objects objects :client-state client-state + :producer input :initial initial :created created + :removed removed + :moved (tp--plan-moved-count + (tp--surface-plan surface) plan) + :reconciled + (tp--candidate-reconciled-count context created) + :scope-objects scope-objects + :scope-patches (plist-get scope-analysis :patches) + :scope-fallback + (plist-get scope-analysis :fallback))))) (setq success t) result) (unless success (tp--discard-context context))))) @@ -1798,6 +2015,15 @@ caller reads the scalar summary from the surface instead." (< (tp--surface-id (tp--prepared-surface-surface left)) (tp--surface-id (tp--prepared-surface-surface right))))))) +(defun tp--all-prepared-surfaces (table) + "Return every candidate in TABLE ordered by stable surface id." + (let (prepared) + (maphash (lambda (_surface candidate) (push candidate prepared)) table) + (sort prepared + (lambda (left right) + (< (tp--surface-id (tp--prepared-surface-surface left)) + (tp--surface-id (tp--prepared-surface-surface right))))))) + (defun tp--publication-step (step surface) "Run the internal publication failure hook for STEP and SURFACE." (when tp--surface-publication-step-function @@ -2053,6 +2279,8 @@ When SCOPED is non-nil, inspect only RANGES, including an empty set." :removed-objects (length (tp--prepared-surface-removed prepared)) :moved-objects (tp--prepared-surface-moved prepared) :text-operations text-ops :property-operations property-ops + :retained-content-p + (tp--prepared-surface-retained-content-p prepared) :touched-characters touched :full-root (or (null scoped) fallback) :scope-count (length scoped) @@ -2098,6 +2326,41 @@ Return the number of text operations." (set-marker (tp--surface-end surface) (+ base (length rendered))) count)) +(defun tp--publish-retained-content-text (prepared) + "Publish a full-surface retained content candidate in one text operation. +The candidate proof guarantees that the retained surface scope is exactly the +surface range. RENDERED already carries its final text properties, so a +delete/insert preserves the authoritative property runs without constructing +the generic property-operation ledger." + (let* ((surface (tp--prepared-surface-surface prepared)) + (buffer (tp--surface-buffer surface)) + (rendered (tp--prepared-surface-rendered prepared)) + (range (tp--surface-range surface)) + (start (car range)) + (end (cdr range)) + (changed (not (equal-including-properties + (with-current-buffer buffer + (buffer-substring start end)) + rendered)))) + (with-current-buffer buffer + (let ((inhibit-read-only + (plist-get (tp--surface-options surface) :inhibit-read-only))) + (unless (= start (point)) + (goto-char start)) + (when changed + (delete-region start end) + (goto-char start) + (insert rendered)) + (set-marker (tp--surface-start surface) start) + (set-marker (tp--surface-end surface) (+ start (length rendered))))) + (when changed + (with-current-buffer buffer + (unless (tp--validate-published-content-range buffer start rendered) + (signal 'tp-publication-mismatch + (list :surface (tp--surface-id surface) + :reason 'retained-content-properties))))) + (if changed 1 0))) + (defun tp--publish-buffer-content (prepared) "Publish PREPARED's text and properties, returning operation counts." (let* ((surface (tp--prepared-surface-surface prepared)) @@ -2110,25 +2373,33 @@ Return the number of text operations." property-operations) (let (tp--content-published-ranges) (with-current-buffer buffer - (save-restriction - (widen) - (let ((inhibit-read-only - (plist-get (tp--surface-options surface) :inhibit-read-only))) - (setq text-operations - (if (and scoped (not fallback)) - (tp--publish-scoped-content-text - surface rendered patches) - (tp--content-text-operation surface rendered))) - (tp--publication-step 'text surface) - (setq property-operations - (tp--content-property-operations - surface rendered - (and scoped (not fallback) - (tp--scoped-content-ranges patches)) - (and scoped (not fallback)))) - (tp--apply-content-property-operations buffer property-operations) - (tp--publication-step 'property surface)))) - (cons text-operations (length property-operations))))) + (let ((point-before (point))) + (unwind-protect + (save-restriction + (widen) + (let ((inhibit-read-only + (plist-get (tp--surface-options surface) :inhibit-read-only))) + (setq text-operations + (if (tp--prepared-surface-retained-content-p prepared) + (tp--publish-retained-content-text prepared) + (if (and scoped (not fallback)) + (tp--publish-scoped-content-text + surface rendered patches) + (tp--content-text-operation surface rendered)))) + (tp--publication-step 'text surface) + (unless (tp--prepared-surface-retained-content-p prepared) + (setq property-operations + (tp--content-property-operations + surface rendered + (and scoped (not fallback) + (tp--scoped-content-ranges patches)) + (and scoped (not fallback)))) + (tp--apply-content-property-operations buffer property-operations) + (tp--publication-step 'property surface)) + (when (tp--prepared-surface-retained-content-p prepared) + (tp--publication-step 'property surface)))) + (goto-char (min point-before (point-max))))) + (cons text-operations (length property-operations)))))) (defun tp--publish-buffer-properties (prepared) "Publish PREPARED's host-range property operations." @@ -2271,24 +2542,49 @@ Return the number of text operations." (defun tp--prepared-buffers (prepared-list) "Return distinct live buffers affected by PREPARED-LIST." - (delete-dups - (mapcar (lambda (prepared) - (tp--surface-buffer (tp--prepared-surface-surface prepared))) - prepared-list))) + (let ((seen (make-hash-table :test #'eq)) buffers) + (dolist (prepared prepared-list) + (let ((buffer + (tp--surface-buffer (tp--prepared-surface-surface prepared)))) + (unless (gethash buffer seen) + (puthash buffer t seen) + (push buffer buffers)))) + (nreverse buffers))) (defun tp--capture-view-state (buffers) "Capture point, windows, and modified state for BUFFERS." - (mapcar - (lambda (buffer) - (with-current-buffer buffer - (list buffer (copy-marker (point) t) (buffer-modified-p) - (mapcar (lambda (window) - (cons window (copy-marker (window-start window) nil))) - (get-buffer-window-list buffer nil t))))) - buffers)) + (let (states success) + (unwind-protect + (progn + (dolist (buffer buffers) + (with-current-buffer buffer + (let ((state + ;; Point is a cursor location, not an insertion anchor: + ;; content publication must not make it follow a patch + ;; to the end of the inserted text. + (list buffer (copy-marker (point) nil) + (buffer-modified-p) nil))) + (push state states) + (dolist (window (get-buffer-window-list buffer nil t)) + (push (cons window + (copy-marker (window-start window) nil)) + (nth 3 state))) + (setf (nth 3 state) (nreverse (nth 3 state)))))) + (setq states (nreverse states) + success t) + states) + (unless success (tp--dispose-view-state states))))) -(defun tp--restore-view-state (states) - "Restore captured point, window, and modified STATES." +(defun tp--dispose-view-state (states) + "Dispose marker storage retained by captured view STATES." + (dolist (state states) + (pcase-let ((`(,_buffer ,point-marker ,_modified ,windows) state)) + (tp--dispose-marker point-marker) + (dolist (entry windows) (tp--dispose-marker (cdr entry)))))) + +(defun tp--restore-view-state (states &optional retain-markers) + "Restore captured point, window, and modified STATES. +When RETAIN-MARKERS is non-nil, keep snapshot markers for rollback." (dolist (state states) (pcase-let ((`(,buffer ,point-marker ,modified ,windows) state)) (when (buffer-live-p buffer) @@ -2300,8 +2596,9 @@ Return the number of text operations." (when (window-live-p (car entry)) (when-let ((position (marker-position (cdr entry)))) (set-window-start (car entry) position t))))) - (tp--dispose-marker point-marker) - (dolist (entry windows) (tp--dispose-marker (cdr entry)))))) + (unless retain-markers + (tp--dispose-marker point-marker) + (dolist (entry windows) (tp--dispose-marker (cdr entry))))))) (defun tp--dispose-content-mounts (mounts) "Detach private markers owned by content MOUNTS." @@ -2383,13 +2680,17 @@ Return the number of text operations." (defun tp--prepare-change-group-for-buffers (buffers) "Prepare and activate one multi-buffer change group for BUFFERS." - (let (handle) - (dolist (buffer buffers) - (unless (buffer-live-p buffer) - (signal 'tp-dead-surface (list buffer))) - (setq handle (nconc handle (prepare-change-group buffer)))) - (activate-change-group handle) - handle)) + (let (handle success) + (unwind-protect + (progn + (dolist (buffer buffers) + (unless (buffer-live-p buffer) + (signal 'tp-dead-surface (list buffer))) + (setq handle (nconc handle (prepare-change-group buffer)))) + (activate-change-group handle) + (setq success t) + handle) + (unless success (tp--cancel-change-group-safely handle))))) (defun tp--surface-state-snapshots (prepared-list) "Return side-state snapshots for PREPARED-LIST." @@ -2408,18 +2709,200 @@ Return the number of text operations." (let* ((buffers (tp--prepared-buffers prepared)) (journals (tp--capture-property-journals prepared)) (views (tp--capture-view-state buffers)) - (snapshots (tp--surface-state-snapshots prepared)) - (group (tp--prepare-change-group-for-buffers buffers))) + snapshots group) (puthash 'changed prepared state) (puthash 'journals journals state) (puthash 'views views state) + (setq snapshots (tp--surface-state-snapshots prepared)) (puthash 'snapshots snapshots state) + (setq group (tp--prepare-change-group-for-buffers buffers)) (puthash 'change-group group state) + (puthash 'surface-transaction-id-before + tp--surface-transaction-id state) (cl-incf tp--surface-transaction-id) (let ((tp--surface-publishing t)) (dolist (candidate prepared) (tp--publish-one-surface candidate))))))) +(defun tp--surface-precommit-step (step state) + "Report precommit STEP for transaction STATE." + (when tp--surface-precommit-step-function + (funcall tp--surface-precommit-step-function step state))) + +(defun tp--validate-surface-precommit (state) + "Validate final published semantics and rollback ownership in STATE." + (unless (and (gethash 'change-group state) + (listp (gethash 'changed state)) + (listp (gethash 'snapshots state)) + (listp (gethash 'views state))) + (signal 'tp-surface-error (list :invalid-precommit-state))) + (dolist (view (gethash 'views state)) + (unless (and (bufferp (nth 0 view)) + (markerp (nth 1 view)) + (cl-every (lambda (entry) (markerp (cdr entry))) + (nth 3 view))) + (signal 'tp-surface-error (list :invalid-view-cleanup view)))) + (dolist (entry (gethash 'snapshots state)) + (unless (and (tp--prepared-surface-p (car entry)) + (tp--surface-snapshot-p (cdr entry))) + (signal 'tp-surface-error (list :invalid-surface-cleanup entry))) + (let* ((prepared (car entry)) + (surface (tp--prepared-surface-surface prepared)) + (plan (tp--surface-plan surface)) + (objects (tp--surface-objects surface)) + (mounts (tp--surface-mounts surface)) + (mount-index (tp--surface-mount-index surface)) + (capability (tp--surface-capability surface)) + (mount-set (make-hash-table :test #'eq)) + (indexed-mount-set (make-hash-table :test #'eq))) + (tp--validate-live-surface surface) + (unless (eq plan (tp--prepared-surface-plan prepared)) + (signal 'tp-surface-error (list :invalid-published-plan surface))) + (tp--validate-surface-plan-tree plan) + (tp--validate-plan-capability plan capability) + (tp--validate-context-tree (tp--prepared-surface-context prepared) plan) + (unless (and (hash-table-p objects) (hash-table-p mount-index) + (eq (tp--surface-index surface) mounts)) + (signal 'tp-surface-error (list :invalid-published-index surface))) + (maphash + (lambda (path object) + (unless (and (tp-object-p object) + (equal path (tp--surface-object-path object)) + (eq (tp--surface-object-surface object) surface) + (tp--surface-object-live object) + (not (tp--surface-object-disposed object)) + (null (tp--surface-object-candidate-context object))) + (signal 'tp-surface-error + (list :invalid-published-object path object)))) + objects) + (dolist (mount mounts) + (when (gethash mount mount-set) + (signal 'tp-surface-error + (list :duplicate-published-mount mount))) + (puthash mount t mount-set)) + (maphash + (lambda (object entries) + (unless (and (tp-object-p object) + (eq (gethash (tp--surface-object-path object) objects) + object)) + (signal 'tp-surface-error + (list :invalid-mount-index-owner object))) + (dolist (mount entries) + (unless (and (gethash mount mount-set) + (eq (tp--surface-mount-object mount) object) + (not (gethash mount indexed-mount-set))) + (signal 'tp-surface-error + (list :invalid-mount-index-entry object mount))) + (puthash mount t indexed-mount-set))) + mount-index) + (unless (= (hash-table-count mount-set) + (hash-table-count indexed-mount-set)) + (signal 'tp-surface-error (list :incomplete-mount-index surface))) + (dolist (mount mounts) + (let ((object (and (tp--surface-mount-p mount) + (tp--surface-mount-object mount)))) + (unless (and object + (eq (gethash (tp--surface-object-path object) objects) + object) + (eq (tp--surface-mount-capability mount) capability) + (gethash mount indexed-mount-set)) + (signal 'tp-surface-error + (list :invalid-published-mount mount))) + (tp--validate-buffer-range + (tp--surface-buffer surface) + (tp--surface-mount-start mount) + (tp--surface-mount-end mount)) + (if (eq capability 'properties) + (let ((anchor (tp--surface-mount-anchor mount))) + (tp--validate-anchor anchor surface) + (unless (and (eq (tp--surface-mount-start mount) + (tp--anchor-start anchor)) + (eq (tp--surface-mount-end mount) + (tp--anchor-end anchor))) + (signal 'tp-surface-error + (list :invalid-published-anchor mount)))) + (when (tp--surface-mount-anchor mount) + (signal 'tp-surface-error + (list :invalid-content-anchor mount))))))) + state)) + +(defun tp--surface-prevalidate-cleanup (state) + "Validate marker-backed cleanup lists retained in surface STATE." + (dolist (entry (gethash 'snapshots state)) + (let* ((prepared (car entry)) + (snapshot (cdr entry)) + (surface (tp--prepared-surface-surface prepared)) + (coordinate-p + (plist-get (tp--surface-options surface) :coordinate-mounts))) + (dolist (mount (tp--surface-snapshot-mounts snapshot)) + (when (eq (tp--surface-mount-capability mount) 'content) + (unless (or (and (markerp (tp--surface-mount-start mount)) + (markerp (tp--surface-mount-end mount))) + (and coordinate-p + (integerp (tp--surface-mount-start mount)) + (integerp (tp--surface-mount-end mount)))) + (signal 'tp-surface-error + (list :invalid-mount-cleanup mount))))) + (dolist (ledger (tp--surface-snapshot-ledger snapshot)) + (unless (and (markerp (tp--property-ledger-start ledger)) + (markerp (tp--property-ledger-end ledger))) + (signal 'tp-surface-error + (list :invalid-ledger-cleanup ledger)))))) + state) + +(defun tp--surface-final-accept () + "Accept the active surface transaction's prepared change group." + (when-let ((state (tp--transaction-extension tp--surface-extension-key))) + (when-let ((group (gethash 'change-group state))) + (accept-change-group group)))) + +(defun tp--surface-precommit-transaction () + "Prepare every fallible surface transition before signal commit." + (when-let ((state (tp--transaction-extension tp--surface-extension-key))) + (when-let ((table (gethash 'prepared state))) + (let ((all-prepared (tp--all-prepared-surfaces table)) + (changed (gethash 'changed state))) + (when changed + (tp--surface-precommit-step 'validate state) + (tp--validate-surface-precommit state) + (tp--surface-precommit-step 'view state) + (tp--restore-view-state (gethash 'views state) t) + (tp--surface-precommit-step 'anchors state) + (let (anchors) + (dolist (entry (gethash 'snapshots state)) + (let* ((prepared (car entry)) + (snapshot (cdr entry)) + (surface (tp--prepared-surface-surface prepared))) + (dolist (mounts + (list (tp--surface-snapshot-mounts snapshot) + (tp--surface-mounts surface))) + (dolist (mount mounts) + (when-let ((anchor (tp--surface-mount-anchor mount))) + (push anchor anchors)))))) + (puthash 'anchor-undo + (tp--capture-anchor-ownership-undo (nreverse anchors)) + state)) + (dolist (entry (gethash 'snapshots state)) + (let* ((prepared (car entry)) + (snapshot (cdr entry)) + (surface (tp--prepared-surface-surface prepared))) + (tp--apply-anchor-ownership + surface (tp--surface-snapshot-mounts snapshot) + (tp--surface-mounts surface))))) + (tp--surface-precommit-step 'lifecycle state) + (dolist (prepared all-prepared) + (tp--finalize-object-lifecycle prepared) + (tp--surface-precommit-step 'lifecycle-finalized state)) + (when changed + (tp--surface-precommit-step 'observers state) + (dolist (prepared changed) + (tp--enqueue-surface-observers + (tp--prepared-surface-surface prepared))) + (tp--surface-precommit-step 'cleanup state) + (tp--surface-prevalidate-cleanup state) + (tp--transaction-install-final-accept + #'tp--surface-final-accept)))))) + (defun tp--cancel-change-group-safely (group) "Cancel active change GROUP without allowing quit to interrupt rollback." (when group @@ -2428,51 +2911,118 @@ Return the number of text operations." (tp--surface-publishing t)) (condition-case nil (cancel-change-group group) - (error nil))))) + ((error quit) nil))))) + +(defun tp--cancel-change-group-for-rollback (group) + "Cancel active change GROUP while allowing failure aggregation." + (when group + (let ((inhibit-quit t) + (inhibit-modification-hooks t) + (tp--surface-publishing t)) + (cancel-change-group group)))) (defun tp--surface-rollback-transaction () "Rollback buffers, side state, markers, and contexts for this transaction." (when-let ((state (tp--transaction-extension tp--surface-extension-key))) - (tp--cancel-change-group-safely (gethash 'change-group state)) - (tp--restore-property-journals (gethash 'journals state)) - (dolist (entry (gethash 'snapshots state)) - (tp--restore-surface-snapshot (car entry) (cdr entry))) - (tp--restore-view-state (gethash 'views state)) - (when-let ((table (gethash 'prepared state))) - (maphash (lambda (_surface prepared) - (let ((context (tp--prepared-surface-context prepared))) - (setf (tp--context-active context) t) - (tp--discard-context context))) - table)))) + (let (failures) + (cl-labels + ((attempt (owner function) + (condition-case failure + (funcall function) + ((error quit) + (push (list owner failure) failures))))) + (attempt 'change-group + (lambda () + (tp--cancel-change-group-for-rollback + (gethash 'change-group state)))) + (attempt 'properties + (lambda () + (tp--restore-property-journals + (gethash 'journals state)))) + (dolist (entry (gethash 'snapshots state)) + (attempt + (list 'surface + (tp--surface-id + (tp--prepared-surface-surface (car entry)))) + (lambda () + (tp--restore-surface-snapshot (car entry) (cdr entry))))) + (attempt 'view + (lambda () + (tp--restore-view-state (gethash 'views state)))) + (attempt 'anchors + (lambda () + (tp--undo-anchor-ownership + (gethash 'anchor-undo state)))) + (attempt 'surface-counter + (lambda () + (when (gethash 'surface-transaction-id-before state) + (setq tp--surface-transaction-id + (gethash 'surface-transaction-id-before state)))))) + (when failures + (signal 'tp-surface-rollback-error + (list :failures (nreverse failures))))))) (defun tp--surface-finalize-killed-rollback () - "Reapply authoritative teardown after graph rollback for killed buffers." + "Dispose candidate contexts and reapply killed-buffer teardown." (when-let ((state (tp--transaction-extension tp--surface-extension-key))) - (when-let ((table (gethash 'prepared state))) - (maphash - (lambda (surface _prepared) - (unless (buffer-live-p (tp--surface-buffer surface)) - (tp--teardown-surface surface t))) - table)))) + (let (failures) + (when-let ((table (gethash 'prepared state))) + (dolist (prepared (tp--all-prepared-surfaces table)) + (let ((surface (tp--prepared-surface-surface prepared))) + (condition-case failure + (let ((context (tp--prepared-surface-context prepared))) + (setf (tp--context-active context) t) + (tp--discard-context context) + (unless (buffer-live-p (tp--surface-buffer surface)) + (tp--teardown-surface surface t))) + ((error quit) + (push (list (tp--surface-id surface) failure) + failures)))))) + (when failures + (signal 'tp-surface-rollback-error + (list :finalize-failures (nreverse failures))))))) (defun tp--anchors-in-mounts (mounts) "Return distinct range anchors referenced by MOUNTS." - (delete-dups - (delq nil (mapcar #'tp--surface-mount-anchor mounts)))) + (let ((seen (make-hash-table :test #'eq)) anchors) + (dolist (mount mounts) + (when-let ((anchor (tp--surface-mount-anchor mount))) + (unless (gethash anchor seen) + (puthash anchor t seen) + (push anchor anchors)))) + (nreverse anchors))) -(defun tp--update-anchor-ownership (surface old-mounts new-mounts) - "Update anchor ownership for SURFACE from OLD-MOUNTS to NEW-MOUNTS." +(defun tp--capture-anchor-ownership-undo (anchors) + "Capture exact ownership undo entries for opaque ANCHORS." + (let ((seen (make-hash-table :test #'eq)) undo) + (dolist (anchor anchors) + (unless (gethash anchor seen) + (puthash anchor t seen) + (push (list anchor (copy-sequence (tp--anchor-surfaces anchor)) + (tp--anchor-candidate-context anchor)) + undo))) + (nreverse undo))) + +(defun tp--apply-anchor-ownership (surface old-mounts new-mounts) + "Apply SURFACE ownership from OLD-MOUNTS to NEW-MOUNTS." (let ((old (tp--anchors-in-mounts old-mounts)) - (new (tp--anchors-in-mounts new-mounts))) + (new (tp--anchors-in-mounts new-mounts)) + (new-set (make-hash-table :test #'eq))) + (dolist (anchor new) (puthash anchor t new-set)) (dolist (anchor old) - (unless (memq anchor new) + (unless (gethash anchor new-set) (setf (tp--anchor-surfaces anchor) - (delq surface (tp--anchor-surfaces anchor))) - (unless (tp--anchor-surfaces anchor) (tp--dispose-anchor anchor)))) + (delq surface (tp--anchor-surfaces anchor))))) (dolist (anchor new) (cl-pushnew surface (tp--anchor-surfaces anchor) :test #'eq) (setf (tp--anchor-candidate-context anchor) nil)))) +(defun tp--undo-anchor-ownership (undo) + "Restore anchors from ownership UNDO entries." + (dolist (entry undo) + (setf (tp--anchor-surfaces (nth 0 entry)) (nth 1 entry) + (tp--anchor-candidate-context (nth 0 entry)) (nth 2 entry)))) + (defun tp--finalize-object-lifecycle (prepared) "Delete omitted bindings and removed objects after PREPARED commits." (let* ((context (tp--prepared-surface-context prepared)) @@ -2486,23 +3036,6 @@ Return the number of text operations." (tp--surface-object-disposed object) t)) (setf (tp--context-active context) nil))) -(defun tp--finalize-context (prepared published) - "Finalize PREPARED after commit; PUBLISHED means side state was swapped." - (let* ((surface (tp--prepared-surface-surface prepared)) - (context (tp--prepared-surface-context prepared))) - (if published - (progn - (tp--finalize-object-lifecycle prepared) - (dolist (anchor (tp--context-created-anchors context)) - (unless (tp--anchor-surfaces anchor) (tp--dispose-anchor anchor)))) - (tp--finalize-object-lifecycle prepared) - (dolist (object (tp--context-new-objects context)) - (unless (tp--surface-object-live object) - (setf (tp--surface-object-disposed object) t))) - (dolist (anchor (tp--context-created-anchors context)) - (unless (memq surface (tp--anchor-surfaces anchor)) - (tp--dispose-anchor anchor)))))) - (defun tp--record-observer-error (surface observer failure) "Record OBSERVER FAILURE in SURFACE's latest report." (let ((report (tp--copy-property-value (tp--surface-report surface)))) @@ -2512,12 +3045,24 @@ Return the number of text operations." (list (list :observer observer :error failure))))) (setf (tp--surface-report surface) report))) +(defun tp--record-cleanup-error (surface owner failure) + "Record contained cleanup OWNER FAILURE on SURFACE." + (let ((report (tp--copy-property-value (tp--surface-report surface)))) + (setq report + (plist-put report :cleanup-errors + (append (plist-get report :cleanup-errors) + (list (list :owner owner :error failure))))) + (setf (tp--surface-report surface) report))) + (defun tp--run-surface-observers (surface observers report) "Run SURFACE OBSERVERS with REPORT and record their errors." (dolist (observer observers) - (condition-case failure - (funcall observer surface report) - (error (tp--record-observer-error surface observer failure))))) + (let ((inhibit-quit t) + (quit-flag nil)) + (condition-case failure + (funcall observer surface report) + ((error quit) + (tp--record-observer-error surface observer failure)))))) (defun tp--enqueue-surface-observers (surface) "Schedule SURFACE observers outside the publishing transaction." @@ -2528,29 +3073,56 @@ Return the number of text operations." (lambda () (tp--run-surface-observers surface observers report)))))) (defun tp--surface-commit-transaction () - "Commit buffer edits and finalize every transaction surface." + "Run contained postaccept cleanup for every transaction surface." (when-let ((state (tp--transaction-extension tp--surface-extension-key))) - (when-let ((group (gethash 'change-group state))) - (accept-change-group group)) - (tp--restore-view-state (gethash 'views state)) (let ((changed (gethash 'changed state)) (snapshots (gethash 'snapshots state))) - (dolist (entry snapshots) - (let* ((prepared (car entry)) - (snapshot (cdr entry)) - (surface (tp--prepared-surface-surface prepared))) - (tp--update-anchor-ownership - surface (tp--surface-snapshot-mounts snapshot) - (tp--surface-mounts surface)) - (tp--dispose-content-mounts (tp--surface-snapshot-mounts snapshot)) - (tp--dispose-ledger (tp--surface-snapshot-ledger snapshot)))) - (when-let ((table (gethash 'prepared state))) - (maphash (lambda (_surface prepared) - (tp--finalize-context prepared (memq prepared changed))) - table)) - (dolist (prepared changed) - (tp--enqueue-surface-observers - (tp--prepared-surface-surface prepared)))))) + (cl-labels + ((cleanup (surface owner function) + (let ((inhibit-quit t) + (quit-flag nil)) + (condition-case failure + (progn + (when tp--surface-cleanup-step-function + (funcall tp--surface-cleanup-step-function + surface owner)) + (funcall function)) + ((error quit) + (tp--record-cleanup-error surface owner failure)))))) + (when-let ((prepared (car changed))) + (cleanup + (tp--prepared-surface-surface prepared) 'view-markers + (lambda () (tp--dispose-view-state (gethash 'views state))))) + (dolist (entry snapshots) + (let* ((prepared (car entry)) + (snapshot (cdr entry)) + (surface (tp--prepared-surface-surface prepared))) + (dolist (anchor + (tp--anchors-in-mounts + (tp--surface-snapshot-mounts snapshot))) + (when (null (tp--anchor-surfaces anchor)) + (cleanup + surface (list 'old-anchor (tp--anchor-id anchor)) + (lambda () (tp--dispose-anchor anchor))))) + (cleanup + surface 'old-mounts + (lambda () + (tp--dispose-content-mounts + (tp--surface-snapshot-mounts snapshot)))) + (cleanup + surface 'old-ledger + (lambda () + (tp--dispose-ledger + (tp--surface-snapshot-ledger snapshot)))))) + (when-let ((table (gethash 'prepared state))) + (dolist (prepared (tp--all-prepared-surfaces table)) + (let* ((surface (tp--prepared-surface-surface prepared)) + (context (tp--prepared-surface-context prepared))) + (dolist (anchor (tp--context-created-anchors context)) + (when (null (tp--anchor-surfaces anchor)) + (cleanup + surface (list 'candidate-anchor (tp--anchor-id anchor)) + (lambda () (tp--dispose-anchor anchor)))))))))))) (defun tp-surface-materialize-string (plan-or-producer) "Materialize PLAN-OR-PRODUCER without creating live runtime state." @@ -2842,6 +3414,8 @@ Return the underlying properties-only surface for inspection or unmounting." (add-hook 'tp--binding-changed-functions #'tp--surface-binding-changed) (add-hook 'tp--transaction-publish-functions #'tp--surface-publish-transaction) +(tp--transaction-register-precommit-function + 'tp--surface-precommit-transaction) (add-hook 'tp--transaction-rollback-functions #'tp--surface-rollback-transaction) (add-hook 'tp--transaction-rollback-final-functions #'tp--surface-finalize-killed-rollback)