Skip to content

finding: ObjectKernel's two Promise.race timeout guards leave a never-settled promise per boot and per shutdown — 4 async leaks on every showcase run #10604

Description

@os-zhuang

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 handledPromise.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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions