Uh oh!
There was an error while loading. Please reload this page.
fix(service-messaging): stop the delivery dispatchers on the kernel's own teardown hook (#9371) - #10375
Conversation
… own teardown hook MessagingServicePlugin starts two setInterval dispatchers at kernel:ready and released them from stop(). The kernel's plugin teardown hook is destroy(), so nothing ever called it and both dispatchers kept ticking after await kernel.shutdown() resolved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
…dispatchers Adds the #9371 contract pin and the changeset for the teardown fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
…alias core to source Satisfies check:slot-lookup (no `any` erasure of a service lookup) and check:test-source-alias (the kernel teardown contract under test must be the source in this checkout, not a dist artifact). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 1 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 44602499ebd51e7880e30ab52029c1e3221ac5a4 && git checkout 44602499ebd51e7880e30ab52029c1e3221ac5a4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f3996182be0b3a75c53c6cb985955d66964071f6 bd5293d8365ff5fd8bbe4899868f20c1bac74f66 && git checkout -B drift-repro f3996182be0b3a75c53c6cb985955d66964071f6 && git merge --no-ff bd5293d8365ff5fd8bbe4899868f20c1bac74f66
node scripts/docs-audit/affected-docs.mjs --json f3996182be0b3a75c53c6cb985955d66964071f6
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9371
Root-causes the
EnvironmentTeardownError: [vitest-worker]: Closing rpc while "onUserConsoleLog" was pendingteardown race that evicted #9365 and #9775 from the merge queue with every test passing (334/334, then 337/337).The fault is not in
test/approval-resume-relation-expand.test.ts, not a showcase fixture, and not vitest configuration. It is a plugin teardown that the kernel never reaches.⛔ Nothing is skipped, quarantined, retry-wrapped or
--reporter-silenced, and no assertion is weakened.The defect
MessagingServicePluginstarts twosetIntervaldispatchers atkernel:ready—NotificationDispatcheroversys_notification_deliveryandHttpDispatcheroversys_http_delivery— and released them from a method namedstop():The intent is explicit and the body was correct. Only the name was wrong.
Plugin(packages/core/src/types.ts:170) declares exactly one teardown hook — the optional memberdestroy?(), returning a promise or nothing — and that is the only oneObjectKernel.performShutdown()andLiteKernel.destroy()invoke.stop()is not on the interface, so nothing in the tree ever called it. Both dispatchers went on claiming and updating delivery rows afterawait kernel.shutdown()had resolved.start()unref()s both timers, so a long-lived host process still exits — which is why this stayed invisible in production and surfaced somewhere else entirely.How that becomes a red run on a green suite
Read out of the installed vitest (4.1.10), not assumed:
SqlDriver's console fallback (sql-driver.ts:3983) warns.console.*in a worker is an RPC to the main process.sendLogcallsstate().rpc.onUserConsoleLog({...})and discards the returned promise.execute()'sfinallydoesawait rpcDone()— which awaits a snapshot of the pending set — and then$rejectPendingCalls, which rejects anything created after that snapshot withEnvironmentTeardownError.The width of the window is the duration of
rpcDone(). That is the entire "load-dependence" recorded on the card: ~1 ms on an idle box, long enough on a saturated queue runner. It explains why the identical diff ran the identical suite clean PR-side and red queue-side.Reproduced before it was fixed
4-vCPU container (same profile as the CI runner under
turbo --concurrency=4), 4 concurrent fullexamples/app-showcasesuites × 3 rounds, with a probe wrappingrpc.onUserConsoleLog:Corroborating, from vitest's own leak detector on the unmodified suite — one file, and it is the file both sightings name:
And the reason that file is the one named: instrumenting every
console.*across the suite, it makes 294 of the 348 console calls in the whole showcase run — the loudest by an order of magnitude, so it is where the queue is deepest at teardown.Effect of the fix, measured paired
Identical harness, same box, same N — 48 loaded runs of the affected file each side;
distrebuilt between legs and the rebuild verified on disk.dist)dist)afterAllRPCSTART)RPCSTART − RPCOK)Both halves of the race shrink: the late calls that arm it are gone, and the queue depth that widens the window roughly halves.
The change
destroy()now carries the teardown body — the hook the kernel actually calls.stop()is retained as a delegating alias. It is public API of an exported class, and an embedder may well have learned to call it directly precisely because the kernel never did. No accept/reject behaviour of any contract moves.packages/services/service-messaging/src/plugin-shutdown-stops-dispatchers.test.ts: boots a realObjectKernel+ real ObjectQL + real sqlite, counts delivery traffic on the veryIDataEnginethe dispatchers captured, and asserts none of it happens aftershutdown()resolves. The pre-shutdown leg is a load-bearing positive control — without it a dispatcher that never started would pass vacuously.packages/services/service-messaging/vitest.config.ts: one anchored alias for@objectstack/core. The pin asserts a property of the kernel's teardown contract, so the@objectstack/coreit runs against must be this checkout's source, not adistartifact. Required bypnpm check:test-source-alias.Ablation (the pin can go red)
Renamed
destroy()→ABLATED_destroy(); confirmed on disk by grep against the exact text (async destroy(0 occurrences,async ABLATED_destroy(1). No rebuild needed — the subject is imported by relative path, so it resolves to source, notdist. Result:48 further delivery reads/writes in the 80 ms after a resolved
shutdown()— the defect, quantified. Restored from the commit (async destroy(back to 1,ABLATED0) and re-run:Test Files 1 passed (1) · Tests 2 passed (2).Verification
All gates run at head
bd5293d83, working tree byte-identical to HEAD (git status --porcelainempty). Gate set derived withnode scripts/pm/dispatch-gates.mjs(no path args — it derives its own change set from the merge base), re-derived after the final commit; exit codes captured before any pipe.Suites —
EXIT=0each:Gates, each quoting its own verdict line:
Changeset
@objectstack/service-messagingis published, and this changes its runtime behaviour, so it carries apatchchangeset — notskip-changeset. (examples/app-showcaseis untouched by this PR.)Clause ② — declared
Does not change contract accept/reject behaviour, and does not widen public surface in any way a consumer can be broken by. It rests on:
destroy()is an implementation of an already-declared optional hook onPlugin, not a new interface member;stop()is retained with identical semantics, so no existing call site changes meaning; no schema, validator, error code, or API shape is touched. The behavioural delta is confined to what happens afterkernel.shutdown()resolves, where the previous behaviour was the defect.Out of scope — filed, not fixed here
plugin-reportshas the same defect (5 timer sites,stop(), nodestroy()), plus five further plugins whosestop()the kernel never calls (no timers, so lower stakes).approval-resume-relation-expand.test.tstears down driver-before-kernel, so the kernel drains against a disconnected driver.finding: the upstream amplifier is still live. Any lateconsole.*in any package can fail a green suite; vitest's non-TTY default reporter (MinimalReporter,silent: 'passed-only') never prints that output, so a suite pays the RPC round-trips for logs nobody reads.--detectAsyncLeaksis the tool that finds the exposed files.Flaky-signature ledger — PROPOSAL ONLY, ⛔ not self-added
Per the signature-ledger rule this promotion is a human action. Drafted for whoever takes it:
Generated by Claude Code