diff --git a/etaf-renderer.el b/etaf-renderer.el index 74e602e..929ac27 100644 --- a/etaf-renderer.el +++ b/etaf-renderer.el @@ -18,6 +18,7 @@ (require 'etaf-component) (require 'etaf-context) (require 'etaf-behavior) +(require 'etaf-render-port) (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) (let ((ebox-viewport-width (plist-get options :viewport-width)) (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) diff --git a/etaf-scheduler.el b/etaf-scheduler.el index b2fa132..d159005 100644 --- a/etaf-scheduler.el +++ b/etaf-scheduler.el @@ -427,13 +427,16 @@ PROJECTION-ID identifies the bounded projection owning the detached turn." first-condition)) (defun etaf-scheduler--record-fault - (context projection-id condition &optional phase) - "Record CONTEXT failure CONDITION for PROJECTION-ID and optional PHASE." + (context projection-id condition &optional phase turn-id) + "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)) (cl-incf (etaf-scheduler-context-projection-fault-count context)) (push (append (list :projection-epoch projection-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)) (list :condition (copy-tree condition))) (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." (let ((projection-id (etaf-scheduler--projection-id projection)) (failed-contexts (make-hash-table :test #'eq)) + (contained-context-conditions (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) (let ((marked-contexts (make-hash-table :test #'eq))) ;; 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 (etaf-scheduler--drain-context-sources context projection-id))) - (signal (car source-condition) - (cdr source-condition)))) + (unless (gethash context contained-context-conditions) + (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) (puthash context t failed-contexts) - (unless first-condition (setq first-condition condition)) + (unless fatal-condition (setq fatal-condition condition)) (etaf-scheduler--record-fault context projection-id condition) (etaf-scheduler--reset-pending context))))))) @@ -502,14 +514,35 @@ PROJECTION-ID identifies the bounded projection owning the detached turn." (when-let* ((runtime-condition (etaf-scheduler--run-runtime-turn context turn))) - (signal (car runtime-condition) - (cdr runtime-condition)))) + (unless (gethash context contained-context-conditions) + (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) (puthash context t failed-contexts) - (unless first-condition (setq first-condition condition)) + (unless fatal-condition (setq fatal-condition condition)) (etaf-scheduler--record-fault context projection-id condition) (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)) (setf (etaf-scheduler-context-phase context) nil) (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) projection-id (etaf-scheduler-context-fault-state context) nil))) - (when first-condition - (signal (car first-condition) (cdr first-condition))))) + (when-let* ((condition (or fatal-condition first-condition))) + (signal (car condition) (cdr condition))))) (defun etaf-scheduler--finish-projection (projection) "Restore PROJECTION invariants and return contained finalizer errors." diff --git a/tests/etaf-render-port-tests.el b/tests/etaf-render-port-tests.el index fa2749e..444fce4 100644 --- a/tests/etaf-render-port-tests.el +++ b/tests/etaf-render-port-tests.el @@ -459,6 +459,56 @@ (should (string-match-p "(etaf-render-port-update" 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 () "Every downstream read returns the one bootstrap-selected port identity." (should (eq (etaf-render-port-selected) diff --git a/tests/etaf-scheduler-tests.el b/tests/etaf-scheduler-tests.el index a6757b4..9c98e08 100644 --- a/tests/etaf-scheduler-tests.el +++ b/tests/etaf-scheduler-tests.el @@ -269,7 +269,7 @@ (ert-deftest etaf-scheduler-source-fault-does-not-suppress-context-tail () "A failing source delivery cannot discard later sources in the same turn." (let ((context (etaf-scheduler-context-create :name 'source-fault-tail)) - second-ran captured) + second-ran runtime-ran captured) (condition-case condition (etaf-scheduler-call-with-projection (lambda () @@ -278,14 +278,84 @@ (lambda (_context _source _projection-id) (signal 'etaf-scheduler-test-source-condition '("first")))) (etaf-scheduler-enqueue-source - context 'second + context 'second (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))) (should (equal captured '(etaf-scheduler-test-source-condition "first"))) (should second-ran) + (should runtime-ran) (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)))) (ert-deftest etaf-scheduler-subscriber-fault-does-not-suppress-group-tail () @@ -293,6 +363,7 @@ (let* ((context (etaf-scheduler-context-create :name 'subscriber-fault-tail)) second-ran + runtime-ran (first (etaf-runtime-route-create :runtime-id 1 :mount-epoch 1 :authority-token 'first @@ -305,7 +376,12 @@ (etaf-runtime-route-create :runtime-id 2 :mount-epoch 1 :authority-token 'second :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) (condition-case condition (etaf-scheduler-call-with-projection @@ -319,7 +395,9 @@ (should (equal captured '(etaf-scheduler-test-source-condition "subscriber"))) (should second-ran) + (should runtime-ran) (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)))) (ert-deftest etaf-scheduler-authority-predicate-fault-is-diagnostic ()