diff --git a/changelog.d/13536-async-destroy-defer.md b/changelog.d/13536-async-destroy-defer.md new file mode 100644 index 00000000000..b44a88ab446 --- /dev/null +++ b/changelog.d/13536-async-destroy-defer.md @@ -0,0 +1 @@ +**A queued async-resource `destroy` now waits for the enclosing execution scopes to emit their `after` hook, and only for the resources those scopes actually own.** A timer's callback checkpoint can run before that callback's own `after`, so an interval that cleared itself from inside its callback — or any collection that queued destruction during a nested scope — could deliver `destroy` for a resource whose `before`/`after` pair had not closed yet, and a nested scope could observe a resource destroyed while it was still executing. `drain_gc_destroy_queue` no longer drains the queue wholesale inside an active scope: it retains the queued entries whose serial is still on the execution stack (plus those whose async job domain is not eligible yet) and delivers the rest, so ready resources unrelated to the active scope are not held behind it. Regression coverage is the node-suite case `test-parity/node-suite/async_hooks/hooks/interval-cancel-checkpoint-lifecycle.ts`: the interval clears itself inside its own callback, and `process.nextTick` and `queueMicrotask` running inside that scope both observe the interval's `destroy` count still at 0; the lifecycle is exactly `before>after>destroy`, no resource destroys twice, and a sibling `setTimeout` cancelled in the same scope still destroys exactly once.