fix(runtime): preserve scheduler work and selected publication

This commit is contained in:
Kinneyzhang 2026-09-01 08:11:51 +08:00
parent de622c263f
commit b82f09aa79
4 changed files with 182 additions and 17 deletions

View File

@ -18,6 +18,7 @@
(require 'etaf-component) (require 'etaf-component)
(require 'etaf-context) (require 'etaf-context)
(require 'etaf-behavior) (require 'etaf-behavior)
(require 'etaf-render-port)
(declare-function etaf--runtime-render-component "etaf-runtime" (runtime call path)) (declare-function etaf--runtime-render-component "etaf-runtime" (runtime call path))
@ -843,7 +844,10 @@ OPTIONS is forwarded to `etaf-runtime-mount' when the Runtime is loaded."
(etaf-runtime-mount buffer-or-name view options) (etaf-runtime-mount buffer-or-name view options)
(let ((ebox-viewport-width (plist-get options :viewport-width)) (let ((ebox-viewport-width (plist-get options :viewport-width))
(ebox-viewport-height (plist-get options :viewport-height))) (ebox-viewport-height (plist-get options :viewport-height)))
(ebox-render-to-buffer buffer-or-name (etaf-render view))))) (let ((input (etaf-render view))
(buffer (get-buffer-create buffer-or-name)))
(etaf-render-port-initial buffer input #'ignore #'ignore)
buffer))))
(provide 'etaf-renderer) (provide 'etaf-renderer)

View File

@ -427,13 +427,16 @@ PROJECTION-ID identifies the bounded projection owning the detached turn."
first-condition)) first-condition))
(defun etaf-scheduler--record-fault (defun etaf-scheduler--record-fault
(context projection-id condition &optional phase) (context projection-id condition &optional phase turn-id)
"Record CONTEXT failure CONDITION for PROJECTION-ID and optional PHASE." "Record CONTEXT failure CONDITION for PROJECTION-ID.
PHASE is optional diagnostic metadata. TURN-ID, when non-nil, preserves the
turn where a contained condition originated before delayed quarantine."
(setf (etaf-scheduler-context-fault-state context) (copy-tree condition)) (setf (etaf-scheduler-context-fault-state context) (copy-tree condition))
(cl-incf (etaf-scheduler-context-projection-fault-count context)) (cl-incf (etaf-scheduler-context-projection-fault-count context))
(push (append (list :projection-epoch projection-id (push (append (list :projection-epoch projection-id
:turn-id :turn-id
(etaf-scheduler-context-active-turn-id context)) (or turn-id
(etaf-scheduler-context-active-turn-id context)))
(and phase (list :phase phase)) (and phase (list :phase phase))
(list :condition (copy-tree condition))) (list :condition (copy-tree condition)))
(etaf-scheduler-context-diagnostics context)) (etaf-scheduler-context-diagnostics context))
@ -459,8 +462,9 @@ PROJECTION-ID identifies the bounded projection owning the detached turn."
"Drain PROJECTION source-first and re-signal its first context fault." "Drain PROJECTION source-first and re-signal its first context fault."
(let ((projection-id (etaf-scheduler--projection-id projection)) (let ((projection-id (etaf-scheduler--projection-id projection))
(failed-contexts (make-hash-table :test #'eq)) (failed-contexts (make-hash-table :test #'eq))
(contained-context-conditions (make-hash-table :test #'eq))
(context-steps (make-hash-table :test #'eq)) (context-steps (make-hash-table :test #'eq))
first-condition) first-condition fatal-condition)
(while (etaf-scheduler--projection-work-p projection) (while (etaf-scheduler--projection-work-p projection)
(let ((marked-contexts (make-hash-table :test #'eq))) (let ((marked-contexts (make-hash-table :test #'eq)))
;; All source propagation across every context settles before any ;; All source propagation across every context settles before any
@ -482,11 +486,19 @@ PROJECTION-ID identifies the bounded projection owning the detached turn."
(when-let* ((source-condition (when-let* ((source-condition
(etaf-scheduler--drain-context-sources (etaf-scheduler--drain-context-sources
context projection-id))) context projection-id)))
(signal (car source-condition) (unless (gethash context contained-context-conditions)
(cdr source-condition)))) (puthash context
(list
:condition source-condition
:turn-id
(etaf-scheduler-context-active-turn-id
context))
contained-context-conditions))
(unless first-condition
(setq first-condition source-condition))))
((error quit) ((error quit)
(puthash context t failed-contexts) (puthash context t failed-contexts)
(unless first-condition (setq first-condition condition)) (unless fatal-condition (setq fatal-condition condition))
(etaf-scheduler--record-fault (etaf-scheduler--record-fault
context projection-id condition) context projection-id condition)
(etaf-scheduler--reset-pending context))))))) (etaf-scheduler--reset-pending context)))))))
@ -502,14 +514,35 @@ PROJECTION-ID identifies the bounded projection owning the detached turn."
(when-let* ((runtime-condition (when-let* ((runtime-condition
(etaf-scheduler--run-runtime-turn (etaf-scheduler--run-runtime-turn
context turn))) context turn)))
(signal (car runtime-condition) (unless (gethash context contained-context-conditions)
(cdr runtime-condition)))) (puthash context
(list
:condition runtime-condition
:turn-id
(etaf-scheduler-context-active-turn-id
context))
contained-context-conditions))
(unless first-condition
(setq first-condition runtime-condition))))
((error quit) ((error quit)
(puthash context t failed-contexts) (puthash context t failed-contexts)
(unless first-condition (setq first-condition condition)) (unless fatal-condition (setq fatal-condition condition))
(etaf-scheduler--record-fault (etaf-scheduler--record-fault
context projection-id condition) context projection-id condition)
(etaf-scheduler--reset-pending context)))))) (etaf-scheduler--reset-pending context))))))
;; Item-level source/subscriber/Runtime faults are reported only after
;; the detached Runtime turn has consumed work enqueued by successful
;; tail callbacks. Scheduler-control faults above still reset at once.
(dolist (context (etaf-scheduler--projection-contexts projection))
(when-let* ((contained
(gethash context contained-context-conditions))
(condition (plist-get contained :condition)))
(unless (gethash context failed-contexts)
(puthash context t failed-contexts)
(etaf-scheduler--record-fault
context projection-id condition nil
(plist-get contained :turn-id))
(etaf-scheduler--reset-pending context))))
(dolist (context (etaf-scheduler--projection-contexts projection)) (dolist (context (etaf-scheduler--projection-contexts projection))
(setf (etaf-scheduler-context-phase context) nil) (setf (etaf-scheduler-context-phase context) nil)
(unless (gethash context failed-contexts) (unless (gethash context failed-contexts)
@ -519,8 +552,8 @@ PROJECTION-ID identifies the bounded projection owning the detached turn."
(setf (etaf-scheduler-context-completed-projection-epoch context) (setf (etaf-scheduler-context-completed-projection-epoch context)
projection-id projection-id
(etaf-scheduler-context-fault-state context) nil))) (etaf-scheduler-context-fault-state context) nil)))
(when first-condition (when-let* ((condition (or fatal-condition first-condition)))
(signal (car first-condition) (cdr first-condition))))) (signal (car condition) (cdr condition)))))
(defun etaf-scheduler--finish-projection (projection) (defun etaf-scheduler--finish-projection (projection)
"Restore PROJECTION invariants and return contained finalizer errors." "Restore PROJECTION invariants and return contained finalizer errors."

View File

@ -459,6 +459,56 @@
(should (string-match-p "(etaf-render-port-update" source)) (should (string-match-p "(etaf-render-port-update" source))
(should-not (string-match-p "(ebox-commit" source))))) (should-not (string-match-p "(ebox-commit" source)))))
(ert-deftest etaf-render-port-owns-all-live-publication-routing ()
"No ETAF module except the selected-port owner publishes directly to Ebox."
(dolist (file (directory-files etaf-render-port-test--root t "\\.el\\'"))
(unless (string= (file-name-nondirectory file) "etaf-render-port.el")
(with-temp-buffer
(insert-file-contents file)
(let ((source (buffer-string)))
(dolist (call '("(ebox-render-to-buffer"
"(ebox-commit"
"(ebox-surface-"))
(should-not (string-match-p (regexp-quote call) source))))))))
(ert-deftest etaf-render-port-routes-standalone-renderer-mount ()
"The no-Runtime compatibility mount uses the immutable selected port."
(require 'etaf-renderer)
(let ((buffer (generate-new-buffer " *etaf-standalone-port-mount*"))
seen-input seen-viewport)
(unwind-protect
(cl-letf (((symbol-function 'etaf-runtime-mount) nil)
((symbol-function 'etaf-render-port-initial)
(lambda (target input framework-stage framework-rollback
&optional _observer)
(setq seen-input input
seen-viewport
(list ebox-viewport-width ebox-viewport-height))
(funcall framework-stage nil)
(ignore framework-rollback)
(list :status 'success :buffer target))))
(should
(eq (etaf-mount
buffer (etaf-view (text "standalone"))
'(:viewport-width 91 :viewport-height 17))
buffer))
(should (ebox-canonical-input-p seen-input))
(should (equal seen-viewport '(91 17))))
(when (and (buffer-live-p buffer)
(ebox-surface-buffer-mounted-p buffer))
(ebox-unmount-buffer buffer))
(when (buffer-live-p buffer) (kill-buffer buffer)))))
(ert-deftest etaf-render-port-standalone-render-failure-creates-no-buffer ()
"Pure lowering must fail before standalone mount allocates its target buffer."
(require 'etaf-renderer)
(let ((name " *etaf-standalone-render-failure*"))
(when-let* ((buffer (get-buffer name))) (kill-buffer buffer))
(cl-letf (((symbol-function 'etaf-runtime-mount) nil))
(should-error (etaf-mount name 'invalid-etaf-view)
:type 'etaf-renderer-error))
(should-not (get-buffer name))))
(ert-deftest etaf-render-port-selected-port-is-process-stable () (ert-deftest etaf-render-port-selected-port-is-process-stable ()
"Every downstream read returns the one bootstrap-selected port identity." "Every downstream read returns the one bootstrap-selected port identity."
(should (eq (etaf-render-port-selected) (should (eq (etaf-render-port-selected)

View File

@ -269,7 +269,7 @@
(ert-deftest etaf-scheduler-source-fault-does-not-suppress-context-tail () (ert-deftest etaf-scheduler-source-fault-does-not-suppress-context-tail ()
"A failing source delivery cannot discard later sources in the same turn." "A failing source delivery cannot discard later sources in the same turn."
(let ((context (etaf-scheduler-context-create :name 'source-fault-tail)) (let ((context (etaf-scheduler-context-create :name 'source-fault-tail))
second-ran captured) second-ran runtime-ran captured)
(condition-case condition (condition-case condition
(etaf-scheduler-call-with-projection (etaf-scheduler-call-with-projection
(lambda () (lambda ()
@ -278,14 +278,84 @@
(lambda (_context _source _projection-id) (lambda (_context _source _projection-id)
(signal 'etaf-scheduler-test-source-condition '("first")))) (signal 'etaf-scheduler-test-source-condition '("first"))))
(etaf-scheduler-enqueue-source (etaf-scheduler-enqueue-source
context 'second context 'second
(lambda (_context _source _projection-id) (lambda (_context _source _projection-id)
(setq second-ran t))))) (setq second-ran t)
(etaf-scheduler-enqueue-runtime
context 'second-runtime
(lambda () (setq runtime-ran t)))))))
(etaf-scheduler-test-source-condition (setq captured condition))) (etaf-scheduler-test-source-condition (setq captured condition)))
(should (equal captured (should (equal captured
'(etaf-scheduler-test-source-condition "first"))) '(etaf-scheduler-test-source-condition "first")))
(should second-ran) (should second-ran)
(should runtime-ran)
(should (= 2 (etaf-scheduler-test--metric context :source-deliveries))) (should (= 2 (etaf-scheduler-test--metric context :source-deliveries)))
(should (= 1 (etaf-scheduler-test--metric context :runtime-executions)))
(should (etaf-scheduler-context-idle-p context))))
(ert-deftest etaf-scheduler-control-fault-precedes-contained-item-fault ()
"A later fatal budget fault outranks an earlier contained source condition."
(let ((context
(etaf-scheduler-context-create
:name 'control-fault-precedence :fixed-point-step-budget 1))
runtime-ran captured summary)
(let ((etaf-scheduler-projection-observer
(lambda (value) (setq summary value))))
(condition-case condition
(etaf-scheduler-call-with-projection
(lambda ()
(etaf-scheduler-enqueue-source
context 'item-fault
(lambda (_context _source _projection-id)
(signal 'etaf-scheduler-test-source-condition '("item"))))
(cl-labels
((deliver (_context source _projection-id)
(etaf-scheduler-enqueue-source
context (1+ source) #'deliver)))
(etaf-scheduler-enqueue-source context 0 #'deliver))
(etaf-scheduler-enqueue-runtime
context 'runtime (lambda () (setq runtime-ran t)))))
((error quit) (setq captured condition))))
(should (eq (car captured) 'etaf-scheduler-error))
(should (eq (plist-get (cdr captured) :kind)
'fixed-point-step-budget))
(should (= (plist-get (cdr captured) :steps) 2))
(should (= (plist-get (cdr captured) :budget) 1))
(should-not runtime-ran)
(should (equal (etaf-scheduler-context-fault-state context) captured))
(should (equal (plist-get summary :condition) captured))
(should (etaf-scheduler-context-idle-p context))))
(ert-deftest etaf-scheduler-contained-fault-keeps-originating-turn-id ()
"Delayed item-fault quarantine records the source turn, not Runtime turn."
(let ((context (etaf-scheduler-context-create :name 'fault-origin-turn))
runtime-ran captured)
(condition-case condition
(etaf-scheduler-call-with-projection
(lambda ()
(etaf-scheduler-enqueue-source
context 'item-fault
(lambda (_context _source _projection-id)
(signal 'etaf-scheduler-test-source-condition '("origin"))))
(cl-labels
((deliver (_context source _projection-id)
(when (zerop source)
(etaf-scheduler-enqueue-source
context 1 #'deliver))))
(etaf-scheduler-enqueue-source context 0 #'deliver))
(etaf-scheduler-enqueue-runtime
context 'runtime (lambda () (setq runtime-ran t)))))
(etaf-scheduler-test-source-condition (setq captured condition)))
(should (equal captured
'(etaf-scheduler-test-source-condition "origin")))
(should runtime-ran)
(should (= (etaf-scheduler-context-active-turn-id context) 2))
(let ((diagnostic
(cl-find captured (etaf-scheduler-context-diagnostics context)
:key (lambda (entry) (plist-get entry :condition))
:test #'equal)))
(should diagnostic)
(should (= (plist-get diagnostic :turn-id) 1)))
(should (etaf-scheduler-context-idle-p context)))) (should (etaf-scheduler-context-idle-p context))))
(ert-deftest etaf-scheduler-subscriber-fault-does-not-suppress-group-tail () (ert-deftest etaf-scheduler-subscriber-fault-does-not-suppress-group-tail ()
@ -293,6 +363,7 @@
(let* ((context (let* ((context
(etaf-scheduler-context-create :name 'subscriber-fault-tail)) (etaf-scheduler-context-create :name 'subscriber-fault-tail))
second-ran second-ran
runtime-ran
(first (first
(etaf-runtime-route-create (etaf-runtime-route-create
:runtime-id 1 :mount-epoch 1 :authority-token 'first :runtime-id 1 :mount-epoch 1 :authority-token 'first
@ -305,7 +376,12 @@
(etaf-runtime-route-create (etaf-runtime-route-create
:runtime-id 2 :mount-epoch 1 :authority-token 'second :runtime-id 2 :mount-epoch 1 :authority-token 'second
:scheduler-context context :scheduler-context context
:scheduler (lambda (_route _source) (setq second-ran t)))) :scheduler
(lambda (_route _source)
(setq second-ran t)
(etaf-scheduler-enqueue-runtime
context 'second-runtime
(lambda () (setq runtime-ran t))))))
captured) captured)
(condition-case condition (condition-case condition
(etaf-scheduler-call-with-projection (etaf-scheduler-call-with-projection
@ -319,7 +395,9 @@
(should (equal captured (should (equal captured
'(etaf-scheduler-test-source-condition "subscriber"))) '(etaf-scheduler-test-source-condition "subscriber")))
(should second-ran) (should second-ran)
(should runtime-ran)
(should (= 2 (etaf-scheduler-test--metric context :subscriber-visits))) (should (= 2 (etaf-scheduler-test--metric context :subscriber-visits)))
(should (= 1 (etaf-scheduler-test--metric context :runtime-executions)))
(should (etaf-scheduler-context-idle-p context)))) (should (etaf-scheduler-context-idle-p context))))
(ert-deftest etaf-scheduler-authority-predicate-fault-is-diagnostic () (ert-deftest etaf-scheduler-authority-predicate-fault-is-diagnostic ()