Found while implementing #10293 / #10374, by running the showcase suite under vitest --detectAsyncLeaks (which #10374's triage grading proposed wiring as a gate). Recording only — ⛔ not fixed here, out of that card's scope.
What the detector reports
pnpm --filter @objectstack/example-showcase exec vitest run --detectAsyncLeaks on main @ b34ef8de8:
⎯⎯⎯⎯⎯⎯⎯ Async Leaks 4 ⎯⎯⎯⎯⎯⎯⎯⎯
PROMISE leaking in test/approval-resume-relation-expand.test.ts
❯ ObjectKernel.raceStartupTimeout ../../packages/core/src/kernel.ts:636:32
❯ ObjectKernel.initPluginWithTimeout ../../packages/core/src/kernel.ts:596:24
❯ ObjectKernel.bootstrap ../../packages/core/src/kernel.ts:356:28
PROMISE leaking in test/approval-resume-relation-expand.test.ts
❯ ObjectKernel.raceStartupTimeout ../../packages/core/src/kernel.ts:643:34
PROMISE leaking in test/approval-resume-relation-expand.test.ts
❯ ObjectKernel.shutdown ../../packages/core/src/kernel.ts:461:36
PROMISE leaking in test/approval-resume-relation-expand.test.ts
❯ ObjectKernel.shutdown ../../packages/core/src/kernel.ts:469:27
Test Files 24 passed (24)
Tests 364 passed (364)
Leaks 4 leaks
Exit code is 0 — --detectAsyncLeaks reports, it does not fail the run.
The shape, and the asymmetry between the two sites
Both build a reject-only timeout promise and race it. Neither ever settles the losing promise, so it is retained past the end of the file — which is what the detector names. The two sites then diverge, each doing exactly one half of the cleanup:
raceStartupTimeout (kernel.ts:629-647) clears the timer in a finally but never unrefs it.shutdown (kernel.ts:459-469) unrefs its timer but never clears it, so when performShutdown() wins the race the timer stays armed and fires later against a kernel that is already 'stopped'. That later rejection is handled — Promise.race already attached a rejection handler to that participant — so it is not an unhandled-rejection risk; it is retained work and a wakeup after teardown.
What this finding does NOT claim
⛔ These are not the trigger for the #10293 / #10374 console-teardown flake, and must not be read as its root cause. That flake needs a late onUserConsoleLog RPC, i.e. async work that emits console output after the test file ends. These four promises emit nothing. They are named here because they are what --detectAsyncLeaks actually finds in this suite today — a fact that matters for #10374's proposal to wire that flag as a gate, since its current output on the showcase is four findings that cannot cause the defect it was proposed to catch.
Measured cost of the flag, for whoever takes that decision: showcase suite 21s → 40s wall (~1.9x), same 24 files / 364 tests.
Suggested shape if it is taken
Give both sites the same treatment — settle or drop the loser, and both unref and clear the timer — and keep it in one helper rather than two hand-rolled races, so the two halves cannot drift apart again.
Generated by Claude Code
Found while implementing #10293 / #10374, by running the showcase suite under
vitest --detectAsyncLeaks(which #10374's triage grading proposed wiring as a gate). Recording only — ⛔ not fixed here, out of that card's scope.What the detector reports
pnpm --filter @objectstack/example-showcase exec vitest run --detectAsyncLeaksonmain@b34ef8de8:Exit code is 0 —
--detectAsyncLeaksreports, it does not fail the run.The shape, and the asymmetry between the two sites
Both build a reject-only timeout promise and race it. Neither ever settles the losing promise, so it is retained past the end of the file — which is what the detector names. The two sites then diverge, each doing exactly one half of the cleanup:
raceStartupTimeout(kernel.ts:629-647) clears the timer in afinallybut neverunrefs it.shutdown(kernel.ts:459-469) unrefs its timer but never clears it, so whenperformShutdown()wins the race the timer stays armed and fires later against a kernel that is already'stopped'. That later rejection is handled —Promise.racealready attached a rejection handler to that participant — so it is not an unhandled-rejection risk; it is retained work and a wakeup after teardown.What this finding does NOT claim
⛔ These are not the trigger for the #10293 / #10374 console-teardown flake, and must not be read as its root cause. That flake needs a late
onUserConsoleLogRPC, i.e. async work that emits console output after the test file ends. These four promises emit nothing. They are named here because they are what--detectAsyncLeaksactually finds in this suite today — a fact that matters for #10374's proposal to wire that flag as a gate, since its current output on the showcase is four findings that cannot cause the defect it was proposed to catch.Measured cost of the flag, for whoever takes that decision: showcase suite 21s → 40s wall (~1.9x), same 24 files / 364 tests.
Suggested shape if it is taken
Give both sites the same treatment — settle or drop the loser, and both
unrefandclearthe timer — and keep it in one helper rather than two hand-rolled races, so the two halves cannot drift apart again.Generated by Claude Code