Uh oh!
There was an error while loading. Please reload this page.
test(core): measure ref'd-timer leaks on the subject's own handles, not on the process - #10786
Conversation
Four core pins counted ref'd timers with a PROCESS-global probe (`process.getActiveResourcesInfo().filter(r => r === 'Timeout').length`) and scored a subject against the absolute value. A `Test Core` shard runs ~37 core files in one worker, so that value is ambient: it belongs to every co-tenant file, not to the test reading it. Scoring it with `toBe` is sound only while the window crosses no event-loop turn. `health-monitor.test.ts` said so and held itself to it. Three others relied on the same property silently, with an `await` inside the measured window — green only because the plugin hooks they awaited settle on microtasks, a property of code they do not own and written down nowhere. Add a retry backoff to `bootstrap()` or a debounce to `reloadPlugin()` and the pins go intermittently red, pointing at the timer count instead of at the change. Rather than loosen the comparison, name the guards. The new `refd-timer-probe.testkit.ts` records the `Timeout` handles the subject arms (told apart by the timeout they were configured with) and reports how many are still holding the loop open — `stillPinningTheLoop()`, deliberately synchronous, so the invariant is structural instead of a comment. Every site now also pins how many guards were armed, which the absolute count could never distinguish from "nothing was measured". Refs #10685
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not see
Coarse fallback — 23 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 377b083cec4a703d1b2287539edae67badbbeee3 && git checkout 377b083cec4a703d1b2287539edae67badbbeee3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 53428b8c9dc6ba3aa79b6b00a1a3a83877be7f81 e303311cdf629f5a65d2370b26b1c0ef15a79081 && git checkout -B drift-repro 53428b8c9dc6ba3aa79b6b00a1a3a83877be7f81 && git merge --no-ff e303311cdf629f5a65d2370b26b1c0ef15a79081
node scripts/docs-audit/affected-docs.mjs --json 53428b8c9dc6ba3aa79b6b00a1a3a83877be7f81 |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10685
Nothing was red. This is an unstated invariant, not a live failure — and the remedy is chosen so the pins get sounder, not quieter.
The defect
Four
@objectstack/corepins counted ref'd timers with a process-global probe:getActiveResourcesInfo()reports the whole process and aTest Coreshard runs ~37 core files in one worker, so the absolute count is ambient: it belongs to every co-tenant file, not to the test reading it. Scoring a subject against it withtoBeis sound only while the window contains no event-loop turn — timer callbacks run in the timers phase, and a microtask drain never reaches it.health-monitor.test.tsstated that requirement and held itself to it. Three others depended on it silently, with anawaitinside the measured window (kernel.bootstrap()twice,manager.reloadPlugin()once). They passed only because those hooks settle on microtasks — a property of code they do not own, written down nowhere.The remedy: name the guards instead of counting the process
New
packages/core/src/refd-timer-probe.testkit.ts:recordGuards(delay, body)captures theTimeouthandles the subject arms, told apart from every other timer on the shared loop by the very timeout they were configured with.stillPinningTheLoop(handles)reports how many of those are still holding the loop open. It is deliberately synchronous, which is what makes the invariant structural rather than a comment: its two samples are adjacent statements, noawaitcan be inserted between them without turning it into a different and visiblyasyncfunction, and between two adjacent synchronous statements no timer callback can run at all. The ambient value cancels; what survives is the subject's own delta.Each site now reads:
health-monitor.test.tsalready did this by hand; its assertions are unchanged, its two local helper definitions now come from the testkit so the package holds one documented instrument rather than four hand-rolled copies. The module docblock is where the invariant, and the two measured incidents behind it (#10661'sexpected 2 to be 4, #6329's@vitest/runner100 ms throttle timer), now live.Does this weaken what the tests assert?
Net stronger, with one narrowing stated plainly.
Stronger: the old absolute count could not distinguish "eight guards armed and all eight reclaimed" from "nothing was ever armed" — both read as a difference of zero. Every site now pins the arming count (2, 8, 1) as well as the reclaim, so a subject that stops arming guards reds instead of passing vacuously.
Narrowed: the new probe watches only handles armed with the guard's own timeout, so a leak of some other timer inside
bootstrap()no longer shows up here. That coverage is not lost — the fake-timer siblings in the same files (vi.getTimerCount()) already assert that no timer of any delay is pending after the subject settles, and they seeunref()'d timers too, whichgetActiveResourcesInfo()cannot. The old assertion could only have caught such a timer by luck anyway, since ambient noise moved it just as freely.Deliberately not done: widening the comparison (
toBeLessThanOrEqual, a tolerance). That direction converts a latent false-red into a permanent false-green — with a process-global reading it also lets a real subject leak of +1 hide behind two foreign expiries of -2 in the same window. Filed as #10785.Evidence
Exposure demonstrated before fixing, then closed. Ambient noise = 4000 ref'd timers armed at 1 ms spacing via a scratch
setupFiles(the #10661 technique); "future change" = a simulated retry backoff inbootstrap()and debounce inreloadPlugin(), i.e. a real-timer await inside the measured window, applied to production source only.54 passed (54)54 passed (54)— exposure latent, as the card saysexpected 3744 to be 3755,expected 2037 to be 2047,expected 2021 to be 2037The count goes down in leg C — foreign timers expiring mid-window — the same signature as the CI red in #10661.
Ablations, each applied by anchor and confirmed on disk by anchor count (never by an editor's exit code), predicted before running:
clearTimeoutfromTimeoutGuard.reclaim()expected 2 to be +0,expected 8 to be +0clearTimeoutfrom hot-reload'sraceShutdownTimeoutexpected 1 to be +050 passedA1/A2 are the "green for the right reason" legs: a real leak still reds, and the message now names how many guards leaked instead of quoting an ambient count. B confirms the testkit is what supplies the data, health-monitor's de-duplicated site included. Every mutation was restored with
git restore --source=HEADand the tree proven byte-identical withgit hash-objectagainst the HEAD blob. No rebuild is involved anywhere: these tests import their subjects through relative source specifiers, so vitest resolvessrc/*.tsdirectly and neverdist/— legs C and D differ only by an edit tosrc/kernel.tswith no build step between them, which is that fact demonstrated rather than assumed.Suite:
pnpm --filter @objectstack/core test—Test Files 37 passed (37),Tests 887 passed (887).Gates
Derived with
node scripts/pm/dispatch-gates.mjsfrom the real changeset and run ate303311cdf; exit codes captured by redirect-then-capture, never after a pipe.check:kernel-hook-pairs4 dispatched kernel:* hook(s), each pinned in both kernel.test.ts and lite-kernel.test.tscheck:slot-lookupslot-lookup ratchet holds: 107 unswept site(s) ... none newcheck:test-source-alias72 packages with tests scannedcheck:type-source-resolution76 packages with a tsconfig.json scannedscripts/docs-audit/check-affected-docs.mjscheck:nul-bytesscanned 6238 text file(s) ... no raw ASCII control bytescheck:engine-double-contract371 pinned, 133 in the DEBT ledger, 2 exemptcheck:where-matcher271 matcher(s) discovered ... 0 silently-wrongcheck:type-check-coverage64/77 workspace packages type-checkedcheck:type-check-debt --re-measurewas narrowed and declared: it refuses an unbuilt worktree and needs the whole workspace closure built. Instead the ratchet's own measurement was reproduced for the only package this diff touches —pnpm exec tsc --noEmitinpackages/corereports 98 raw errors, exactly theDEBT['@objectstack/core']entry, with zero of them in the new testkit. CI runs the farm regardless.No changeset: test-only, nothing publishes.
skip-changesetapplied additively.Filed separately, both
domain:devx, unassigned: #10783 (a fifth site with the same shape, inservice-automation, outside this card's list) and #10785 (a gate that would close the class, plus thetoBeLessThanOrEqualmasking arithmetic).Generated by Claude Code