fix: close observation lifecycle and snapshot ownership

This commit is contained in:
Kinneyzhang 2026-08-28 01:47:18 +08:00
parent 7e5f55c286
commit e9323ef700
8 changed files with 152 additions and 9 deletions

View File

@ -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

View File

@ -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`

View File

@ -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

View File

@ -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`;它们直接委托

View File

@ -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

View File

@ -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)))

View File

@ -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

View File

@ -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)))