From f4d59684386ae9f397dba26c05429a45d8b72f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 09:50:22 +0200 Subject: [PATCH 1/2] fix(async_hooks): defer queued destruction until execution scopes finish --- crates/perry-runtime/src/async_hooks.rs | 8 ++++++ .../src/async_hooks/test_support.rs | 27 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/crates/perry-runtime/src/async_hooks.rs b/crates/perry-runtime/src/async_hooks.rs index 8d1b059ff7..267d4c6eb0 100644 --- a/crates/perry-runtime/src/async_hooks.rs +++ b/crates/perry-runtime/src/async_hooks.rs @@ -1145,6 +1145,14 @@ pub(crate) fn gc_destroy_work_pending() -> bool { } pub fn drain_gc_destroy_queue() -> i32 { + // A timer's callback checkpoint runs before its `after` hook. Destruction + // queued by clearInterval (or GC) must wait until the enclosing async scope + // and all nested scopes have finished their `after` hooks. Leave the queue + // intact for the next outer checkpoint rather than destroying resources + // while their execution identities are still active. + if EXECUTION_STACK.with(|stack| !stack.borrow().is_empty()) { + return 0; + } let ids: Vec = { let mut q = GC_DESTROY_QUEUE.lock().unwrap(); q.drain(..).collect() diff --git a/crates/perry-runtime/src/async_hooks/test_support.rs b/crates/perry-runtime/src/async_hooks/test_support.rs index 34ab07ac20..5f0d451639 100644 --- a/crates/perry-runtime/src/async_hooks/test_support.rs +++ b/crates/perry-runtime/src/async_hooks/test_support.rs @@ -98,6 +98,33 @@ mod tests { assert_eq!(b.async_id, a.async_id + 1); } + #[test] + fn queued_destruction_waits_until_nested_execution_scopes_finish() { + reset_for_tests(); + let outer = init_resource("outer", TAG_UNDEFINED_F64, true); + before(outer.async_id, outer.trigger_async_id); + let inner = init_resource("inner", TAG_UNDEFINED_F64, true); + before(inner.async_id, inner.trigger_async_id); + enqueue_gc_destroy(outer.async_id); + enqueue_gc_destroy(inner.async_id); + assert_eq!(drain_gc_destroy_queue(), 0); + assert_eq!(GC_DESTROY_QUEUE.lock().unwrap().len(), 2); + assert!(RESOURCES.lock().unwrap().contains_key(&outer.async_id)); + assert!(RESOURCES.lock().unwrap().contains_key(&inner.async_id)); + after(inner.async_id); + assert_eq!(execution_async_id_u64(), outer.async_id); + assert_eq!(drain_gc_destroy_queue(), 0); + assert_eq!(GC_DESTROY_QUEUE.lock().unwrap().len(), 2); + after(outer.async_id); + assert_eq!(execution_async_id_u64(), 0); + assert_eq!(drain_gc_destroy_queue(), 2); + assert!(GC_DESTROY_QUEUE.lock().unwrap().is_empty()); + assert!(!RESOURCES.lock().unwrap().contains_key(&outer.async_id)); + assert!(!RESOURCES.lock().unwrap().contains_key(&inner.async_id)); + assert_eq!(drain_gc_destroy_queue(), 0); + reset_for_tests(); + } + #[test] fn before_after_restore_execution_ids() { reset_for_tests(); From 00ad2a4f20224df7083ce8173852c6d90fd2940f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 2 Oct 2026 12:33:22 +0200 Subject: [PATCH 2/2] changelog: record deferred async resource destruction --- changelog.d/11761-async-destroy-order.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/11761-async-destroy-order.md diff --git a/changelog.d/11761-async-destroy-order.md b/changelog.d/11761-async-destroy-order.md new file mode 100644 index 0000000000..eac029ca24 --- /dev/null +++ b/changelog.d/11761-async-destroy-order.md @@ -0,0 +1 @@ +Keep queued async resource destruction pending until all active execution scopes have emitted their after hooks. This preserves timer callback lifecycle order when an interval clears itself or a collection queues destruction during a nested scope. Add a regression that checks both nested checkpoints, resource retention, the final drain and duplicate-drain prevention.