diff --git a/README.md b/README.md index e5bc360..00e4a87 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,8 @@ bounded by `etaf-performance-max-records`; disabling the mode only detaches the Runtime observer and never rewrites functions. `etaf-performance-summary` computes operation p50/p95/max statistics on demand, while `etaf-performance-operation-stage-summary` groups one operation's flat stages -by provider category. +by provider category. `etaf-performance-records` returns defensive operation +and stage snapshots; caller mutation cannot rewrite retained history. Use `etaf-performance-call-operation` or `etaf-performance-with-operation` to trace an arbitrary operation that has no diff --git a/README.zh-CN.md b/README.zh-CN.md index 8a323bd..79f2634 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -68,7 +68,8 @@ p50/p95/max、每次 operation、GC 增量和有序 provider 阶段;面板 hea 限制;关闭 mode 只会解除当前 Runtime 的 observer,不修改任何函数。 `etaf-performance-summary` 会按需计算 operation 的 p50/p95/max, `etaf-performance-operation-stage-summary` 则按 provider category 汇总单次 -operation 的 flat 阶段。 +operation 的 flat 阶段。`etaf-performance-records` 返回 operation/stage 的防御性 +快照,调用方修改返回值不会改写已保留的历史。 没有内置公共边界的任意操作,可以使用 `etaf-performance-call-operation` 或 `etaf-performance-with-operation` diff --git a/docs/user-guide.en.md b/docs/user-guide.en.md index def94ea..b30c5cf 100644 --- a/docs/user-guide.en.md +++ b/docs/user-guide.en.md @@ -635,7 +635,9 @@ quits are recorded and then re-signaled unchanged. `etaf-performance-summary` calculates grouped p50/p95/max statistics only when requested. `etaf-performance-operation-stage-summary` groups the flat stages -of one recorded operation by provider category. +of one recorded operation by provider category. `etaf-performance-records` +returns defensive operation and stage snapshots; caller mutation cannot alter +retained history. Use `etaf-performance-call-operation` or `etaf-performance-with-operation` for application work that does not enter through a built-in public boundary. Both diff --git a/docs/user-guide.zh.md b/docs/user-guide.zh.md index dac96df..7e3c364 100644 --- a/docs/user-guide.zh.md +++ b/docs/user-guide.zh.md @@ -625,7 +625,8 @@ Ebox、TP、Data、Resource 与 SQLite 可以贡献按 sequence 排列的 flat p `etaf-performance-summary` 只在请求时计算分组后的 p50/p95/max; `etaf-performance-operation-stage-summary` 会按 provider category 汇总某条记录 -中的 flat 阶段。 +中的 flat 阶段。`etaf-performance-records` 返回 operation/stage 的防御性快照, +调用方修改返回值不会影响保留的历史。 没有经过内置公共边界的应用操作可以使用 `etaf-performance-call-operation`/`etaf-performance-with-operation`;它们直接委托 diff --git a/etaf-performance.el b/etaf-performance.el index 936f744..c29e284 100644 --- a/etaf-performance.el +++ b/etaf-performance.el @@ -55,10 +55,68 @@ :provider :stage :status :duration-ms) "Flat report keys represented by typed recorder fields.") +(defun etaf-performance--copy-value (value) + "Return a defensive recorder snapshot of VALUE." + (cond + ((stringp value) (copy-sequence value)) + ((consp value) + (cons (etaf-performance--copy-value (car value)) + (etaf-performance--copy-value (cdr value)))) + ((vectorp value) + (apply #'vector + (mapcar #'etaf-performance--copy-value (append value nil)))) + ((hash-table-p value) + (let ((copy (copy-hash-table value))) + (clrhash copy) + (maphash (lambda (key item) + (puthash (etaf-performance--copy-value key) + (etaf-performance--copy-value item) + copy)) + value) + copy)) + (t value))) + +(defun etaf-performance--copy-stage (stage) + "Return an isolated snapshot of recorder STAGE." + (etaf-performance--stage-create + :sequence (etaf-performance-stage-sequence stage) + :provider (etaf-performance-stage-provider stage) + :category (etaf-performance-stage-category stage) + :name (etaf-performance-stage-name stage) + :duration (etaf-performance-stage-duration stage) + :status (etaf-performance-stage-status stage) + :metadata + (etaf-performance--copy-value (etaf-performance-stage-metadata stage)))) + +(defun etaf-performance--copy-operation (operation) + "Return an isolated snapshot of recorder OPERATION." + (etaf-performance--operation-create + :id (etaf-performance-operation-id operation) + :kind (etaf-performance-operation-kind operation) + :label (etaf-performance--copy-value + (etaf-performance-operation-label operation)) + :runtime-id (etaf-performance-operation-runtime-id operation) + :buffer-name (etaf-performance--copy-value + (etaf-performance-operation-buffer-name operation)) + :generation-before + (etaf-performance-operation-generation-before operation) + :generation-after + (etaf-performance-operation-generation-after operation) + :elapsed (etaf-performance-operation-elapsed operation) + :status (etaf-performance-operation-status operation) + :gc-count (etaf-performance-operation-gc-count operation) + :gc-elapsed (etaf-performance-operation-gc-elapsed operation) + :metadata + (etaf-performance--copy-value + (etaf-performance-operation-metadata operation)) + :stages (mapcar #'etaf-performance--copy-stage + (etaf-performance-operation-stages operation)))) + (defun etaf-performance-records () - "Return a newest-first copy of completed performance records." + "Return newest-first immutable snapshots of completed records." (if etaf-performance--record-ring - (ring-elements etaf-performance--record-ring) + (mapcar #'etaf-performance--copy-operation + (ring-elements etaf-performance--record-ring)) nil)) (defun etaf-performance--percentile (samples percentile) @@ -79,7 +137,9 @@ (let ((key (pop report)) (value (pop report))) (unless (memq key etaf-performance--report-context-keys) - (setq metadata (append metadata (list key (copy-tree value))))))) + (setq metadata + (append metadata + (list key (etaf-performance--copy-value value))))))) metadata)) (defun etaf-performance--stage-from-report (report) @@ -319,7 +379,9 @@ If another observer replaced the recorder sink, leave that observer intact." :stage (etaf-performance-stage-name stage) :duration-ms (etaf-performance-stage-duration stage) :status (etaf-performance-stage-status stage) - :metadata (copy-tree (etaf-performance-stage-metadata stage)))) + :metadata + (etaf-performance--copy-value + (etaf-performance-stage-metadata stage)))) (defun etaf-performance--operation-report-data (operation) "Return portable report data for OPERATION." @@ -336,7 +398,9 @@ If another observer replaced the recorder sink, leave that observer intact." :status (etaf-performance-operation-status operation) :gc-count (etaf-performance-operation-gc-count operation) :gc-duration-ms (etaf-performance-operation-gc-elapsed operation) - :metadata (copy-tree (etaf-performance-operation-metadata operation)) + :metadata + (etaf-performance--copy-value + (etaf-performance-operation-metadata operation)) :stage-summary (etaf-performance-operation-stage-summary operation) :stages diff --git a/etaf-runtime.el b/etaf-runtime.el index 8ed035d..f526dd7 100644 --- a/etaf-runtime.el +++ b/etaf-runtime.el @@ -854,6 +854,11 @@ Component owner to absorb them." (make-hash-table :test #'eql :weakness 'value) "Mount epoch -> live Runtime weak-value registry.") +(defun etaf--runtime-kill-buffer () + "Unmount the ETAF Runtime owned by the current buffer before it dies." + (when-let* ((runtime (gethash (current-buffer) etaf--runtime-table))) + (etaf-runtime-unmount runtime))) + (defun etaf--runtime-enqueue-effect (runtime effect-id) "Append EFFECT-ID once to RUNTIME's stable FIFO work queue." (unless (gethash effect-id (etaf-runtime-dirty-effect-ids runtime)) @@ -5243,6 +5248,8 @@ backend anchor proof failed; ordinary root turns keep their artifact reuse." :observer observer :root-dirty-p t))) (puthash buffer runtime etaf--runtime-table) + (with-current-buffer buffer + (add-hook 'kill-buffer-hook #'etaf--runtime-kill-buffer nil t)) (let ((route (etaf-runtime-route-create :runtime-id (etaf-runtime-mount-epoch runtime) :mount-epoch (etaf-runtime-mount-epoch runtime) @@ -5268,6 +5275,10 @@ backend anchor proof failed; ordinary root turns keep their artifact reuse." (etaf-events-enable-input buffer)) buffer) ((error quit) + (when (buffer-live-p buffer) + (with-current-buffer buffer + (remove-hook 'kill-buffer-hook + #'etaf--runtime-kill-buffer t))) (remhash buffer etaf--runtime-table) (remhash (etaf-runtime-mount-epoch runtime) etaf--runtime-route-registry) @@ -5316,6 +5327,9 @@ before the first Ebox publication." (defun etaf--runtime-unmount-now (runtime) "Unmount RUNTIME and dispose its Component scopes now." (let ((runtime (etaf-runtime-require-mounted runtime))) + (when (buffer-live-p (etaf-runtime-buffer runtime)) + (with-current-buffer (etaf-runtime-buffer runtime) + (remove-hook 'kill-buffer-hook #'etaf--runtime-kill-buffer t))) (setf (etaf-runtime-mounted-p runtime) nil) (when (fboundp 'etaf-events-disable-input) (etaf-events-disable-input (etaf-runtime-buffer runtime))) diff --git a/tests/etaf-performance-tests.el b/tests/etaf-performance-tests.el index f8ef20c..29bcd03 100644 --- a/tests/etaf-performance-tests.el +++ b/tests/etaf-performance-tests.el @@ -123,6 +123,23 @@ (when-let* ((buffer (get-buffer buffer-name))) (kill-buffer buffer)))))) +(ert-deftest etaf-performance-killed-buffer-releases-runtime-attachment () + "Direct buffer death unmounts Runtime and releases recorder ownership." + (etaf-performance-test--isolated + (let ((buffer-name (generate-new-buffer-name " *etaf-performance-kill*")) + runtime buffer) + (etaf-mount buffer-name (etaf-view (box "Kill"))) + (setq buffer (get-buffer buffer-name) + runtime (etaf-runtime-for-buffer buffer)) + (etaf-performance-start runtime) + (should (gethash runtime etaf-performance--attachments)) + (kill-buffer buffer) + (should-not (buffer-live-p buffer)) + (should-not (etaf-runtime-mounted-p runtime)) + (should-not (gethash runtime etaf-performance--attachments)) + (should-error (etaf-performance-start runtime) + :type 'etaf-runtime-error)))) + (ert-deftest etaf-performance-groups-flat-reports-in-sequence-order () "One ETAF final report retains one ordered, categorized operation." (etaf-performance-test--isolated @@ -191,6 +208,34 @@ (should (> (etaf-performance-operation-id (car records)) (etaf-performance-operation-id (cadr records))))))))) +(ert-deftest etaf-performance-records-return-deep-snapshots () + "Caller mutation cannot alter retained operations or stages." + (etaf-performance-test--isolated + (etaf-performance-test--with-runtime (runtime buffer-name) + (etaf-performance-start runtime) + (etaf-performance-call-operation + runtime 'snapshot "stable label" + (lambda () + (etaf-observer-emit + '(:provider data :stage load :duration-ms 1.0 + :detail ("stable"))) + 'result)) + (let* ((operation (car (etaf-performance-records))) + (stage (car (etaf-performance-operation-stages operation))) + (detail (car (plist-get + (etaf-performance-stage-metadata stage) :detail)))) + (setf (etaf-performance-operation-label operation) "changed" + (etaf-performance-stage-name stage) 'changed) + (aset detail 0 ?X)) + (let* ((operation (car (etaf-performance-records))) + (stage (car (etaf-performance-operation-stages operation)))) + (should (equal "stable label" + (etaf-performance-operation-label operation))) + (should (eq 'load (etaf-performance-stage-name stage))) + (should (equal '("stable") + (plist-get (etaf-performance-stage-metadata stage) + :detail))))))) + (ert-deftest etaf-performance-summary-uses-flat-duration () "Summaries expose percentiles and overlapping provider durations honestly." (etaf-performance-test--isolated diff --git a/tests/etaf-tests.el b/tests/etaf-tests.el index 5d85f53..7d58468 100644 --- a/tests/etaf-tests.el +++ b/tests/etaf-tests.el @@ -2534,6 +2534,21 @@ Event composition is a Runtime contract, not a UI-library helper contract." (when-let* ((buffer (get-buffer buffer-name))) (kill-buffer buffer))))) +(ert-deftest etaf-runtime-killed-buffer-unmounts-owned-scope () + "Killing a mounted buffer follows the ordinary Runtime unmount path." + (let* ((buffer-name (generate-new-buffer-name " *etaf-kill-runtime-test*")) + runtime scope buffer) + (etaf-mount buffer-name (etaf-view (box "Kill Runtime"))) + (setq buffer (get-buffer buffer-name) + runtime (etaf-runtime-for-buffer buffer) + scope (etaf-runtime-scope runtime)) + (should (etaf-effect-scope-active-p scope)) + (kill-buffer buffer) + (should-not (buffer-live-p buffer)) + (should-not (etaf-runtime-mounted-p runtime)) + (should-not (etaf-effect-scope-active-p scope)) + (should-not (gethash buffer etaf--runtime-table)))) + (ert-deftest etaf-pvec-put-many-preserves-values-and-shares-untouched-branches () "Batch persistent-vector updates match sequential puts and share untouched paths." (let* ((untouched-id (* 31 (expt 2 30)))