Skip to content

fix(core): reclaim both halves of the kernel's two timeout guards - #10661

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-10604-kernel-race-timeout-leaks
Aug 21, 2026
Merged

fix(core): reclaim both halves of the kernel's two timeout guards#10661
os-zhuang merged 3 commits into
mainfrom
claude/issue-10604-kernel-race-timeout-leaks

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes#10604

The defect

Both of ObjectKernel's lifecycle timeout guards built a reject-only promise and
raced it, and neither ever settled the loser. A promise that never settles keeps
itself and the reaction Promise.race attached to it alive for the life of the
process — which is what vitest --detectAsyncLeaks reports, two frames per site.

The two hand-rolled copies had also drifted into doing opposite halves of the
same cleanup
:

siteclears the timerunrefs the timersettles the loser
raceStartupTimeoutyesnono
shutdown()noyesno

shutdown()'s never-cleared guard stayed armed after performShutdown() won the
race, and fired later against a kernel already 'stopped'. To be precise about
what that is and is not: the late rejection is handledPromise.race had
already attached a rejection handler to that participant — so it was never an
unhandled-rejection risk. It was retained work and a wakeup after teardown.

⛔ These four promises are not the trigger for the #10293 / #10374
console-teardown flake and are not its root cause. That flake needs late async
work that emits console output; these emit nothing. It was diagnosed separately
(vitest's sendLog discarding the RPC promise) and its remedy landed as #10605.

The fix

One internal TimeoutGuard (packages/core/src/timeout-guard.ts), used by both
sites, whose reclaim() does both halves: clearTimeoutand settle the
promise the race still holds a reaction on. Clearing alone does not remove the
leak — that is the whole distinction this card turns on.

One deliberate departure from the card's suggested shape

The card suggests "both unref and clear the timer". I did not add unref;
I removed the one that was there
, and the guard is now ref'd at both sites.

unref() is the disarming failure mode, not a second belt. #4813 landed that
finding, recorded it in kernel.ts's own docblock, and pinned it in
kernel.test.ts: an unref'd guard stops pinning the loop, but it stops being a
guard as well — if the operation never settles and nothing else keeps the loop
alive, Node exits before the timer can fire and the timeout is never reported.
At the shutdown site that made Shutdown timed out — forcing exit and its
exit(1) unreachable in exactly the case they exist for. Reclaiming on settle
gets the loop-hygiene unref was reaching for without giving up the guarantee,
so the two goals were never actually in tension.

Leak counts — the deliverable

pnpm --filter @objectstack/example-showcase exec vitest run --detectAsyncLeaks

Before (pristine tree at merge-base 47aff09388):

⎯⎯⎯⎯⎯⎯⎯ Async Leaks 4 ⎯⎯⎯⎯⎯⎯⎯⎯
❯ ObjectKernel.raceStartupTimeout ../../packages/core/src/kernel.ts:636:32
❯ ObjectKernel.raceStartupTimeout ../../packages/core/src/kernel.ts:643:34
❯ ObjectKernel.shutdown ../../packages/core/src/kernel.ts:461:36
❯ ObjectKernel.shutdown ../../packages/core/src/kernel.ts:469:27
Test Files 24 passed (24)
Tests 364 passed (364)
Leaks 4 leaks

After — no Async Leaks section, no Leaks line, same suite:

 Test Files 24 passed (24)
Tests 364 passed (364)

The showcase resolves @objectstack/core through the workspace link to dist/,
so pnpm --filter @objectstack/core build runs between the two measurements;
both frames per site come from that dist's sourcemap. The 636/643/461/469
line numbers re-derived identically on today's tree — kernel.ts had not moved.

The timeouts still fire

A leak-free kernel that no longer enforces its timeouts would be strictly worse
than the leak, so both guards are shown still firing, post-fix:

✓ kernel.test.ts … #4813 > still fires the guard when the plugin loses the race 50ms
✓ kernel.test.ts … #5274 > still logs the timeout and still forces exit(1) when
shutdown genuinely times out 22ms
✓ timeout-guard.test.ts … still rejects with the caller's own error object when it
is never reclaimed 11ms
✓ timeout-guard.test.ts … arms a REF'D timer, so an otherwise-idle process cannot
exit before it fires 0ms

The shutdown half is the pre-existing #5274 pin — it hangs teardown past
shutdownTimeout and asserts the log line, exit(1) and the final state, and it
is green unchanged. (I first wrote a second test of my own here and deleted it
once I found #5274's; it asserted the same three things through a hanging
destroy() instead of a hanging hook.)

Pins and their ablation

New: packages/core/src/timeout-guard.test.ts (13 pins) and one kernel-level pin,
The shutdown guard is reclaimed on the same terms (#10604) > leaves no timer armed once shutdown has settledvi.getTimerCount() counts unref'd timers too, which
is what makes it able to tell "reclaimed" apart from "merely detached".

Three ablations, each mutation confirmed on disk by anchor count before the run
(never by an editor's exit code) and each restore confirmed the same way:

ablationmutation (on-disk anchor count)result
A — drop the settle halfthis.settleExpiry(); 1 → 02 red, both settlement pins
B — drop the clear halfclearTimeout(this.timer); 1 → 07 red, incl. the new shutdown pin and the pre-existing #4813 pins
C — unref() at arm timethis.timer.unref() 0 → 11 red, and only the anti-disarm pin

C is the one worth reading: under it every leak pin stays green. "Fix the leak
by disarming the guard" is a change that passes all the leak assertions, and the
ref'd-timer pin is the only thing standing between it and the tree.

Restore leg: anchors back to their original counts, 55/55 green.

Changeset — patch, deliberately

AGENTS.md:943 says "Pure bug fixes do not require a changeset." I added one
anyway, and the argument is on the second consequence below rather than on the
leak:

  • This lands in published@objectstack/core source, not a gate script.
  • Dropping unref() changes what a consumer's process does. A host whose teardown
    hangs previously could fall out of the event loop and exit silently at status
    0
    ; it now waits up to shutdownTimeout (default 60s) and hard-exits 1.
    That is the intended guarantee, and it is still a change an upgrading consumer
    can be surprised by — so it belongs in the release notes, with the
    shutdownTimeout knob named. The changeset body carries that.

skip-changeset would have been the wrong call here; it was right for tonight's
other PRs only because they were scripts/**-only and published nothing.

Verification

At 63ea716fb4:

  • pnpm --filter @objectstack/core test37 files / 887 tests passed
  • showcase suite — 24 files / 364 tests passed, 0 leaks (baseline 24/364 unmoved)
  • eslint on all four changed source files — clean, exit 0. Per Nothing declares that this repo has no formatter, and Prettier's defaults reject main's own files #10622 no
    prettier --write was run; style matched by hand.
  • Gate union derived with node scripts/pm/dispatch-gates.mjs (no paths passed, so
    it takes the change set from the merge-base itself): 10 path-matched + 5
    convention-triggered families, all green.

Heavy steps ran through scripts/pm/os-verify-lock.sh.

CI repair at e5582bf603 — the new pin was scoring itself against an ambient number

Test Core (4/6) was the one red of 28, and the failure was this PR's own new pin:

FAIL src/timeout-guard.test.ts > raceWithTimeout (#10604)
> leaves no ref'd timer behind on any of the three outcomes
AssertionError: expected 2 to be 4 # timeout-guard.test.ts:156:24
Test Files 1 failed | 36 passed (37) · Tests 1 failed | 886 passed (887)

before was 4 and the reading was 2 — the count went DOWN, and a leak makes it
go up. process.getActiveResourcesInfo() is process-wide, this file shares its CI
worker with 36 others, and two ambient timers the pin does not own expired while it
awaited. The instrument was wrong; the fix under test was not.

The CI log's line number narrows it further than the count does. 156:24 is the
third leg, the only one that spends real time on the event loop; the first two
settle on microtasks, where no timer phase can run and the reading cannot move under
the test. That is why legs 1 and 2 passed on exact equality in the same run.

Repair: each leg re-anchors on its own sample and asserts toBeLessThanOrEqual.
A leak is a growth, so this keeps every bit of the detection and gives up only the
decrease, which nothing raceWithTimeout does can cause. No runner or shard setting
was touched — the pin's validity stays a property of its own assertion. Diff is 1
file, 26 insertions / 4 deletions.

Reproduced, then re-ablated

The flake does not reproduce locally on its own: the core suite is 37 files /
887 tests green here four times over, including single-worker
(vitest run --no-file-parallelism). So the CI condition was built synthetically —
a scratch setup file arming ambient ref'd timers at 1 ms spacing, which is exactly
what 37 co-tenant files look like to a process-wide probe:

AssertionError: expected 2872 to be 2885 # timeout-guard.test.ts:156:24
Tests 1 failed | 10 passed (11)

Same test, same line, same downward shape. After the repair that command is green
5 runs out of 5. Note what did not fail in the repro: the other 10 pins,
including the anti-disarm ref'd pin, whose two samples have no await between them
and so cannot move.

All three ablations re-run against the repaired pin (each mutation and each
restore confirmed on disk by anchor count, never by an editor's exit code):

ablationmutation (on-disk anchor count)result
A — drop the settle halfthis.settleExpiry(); 1 to 02 red, both settlement pins
B — drop the clear halfclearTimeout(this.timer); 1 to 07 red, and the repaired pin is one of them
B, under the synthetic ambient noisesamestill red — a real leak is caught with ~2885 foreign timers expiring at ~1/ms
C — unref() at arm timethis.timer.unref(); 0 to 11 red, and only the anti-disarm pin

B is the one that matters here: the repaired assertion still reds on a real leak, so
it was not relaxed into uselessness. C's asymmetry is undisturbed — every leak pin,
the repaired one included, stays green while the anti-disarm pin alone catches it.

Restore leg: anchors back to their original counts, 54/54 green. (That is 54, not
the 55 quoted higher up: those ablations were run before the last commit dropped the
duplicate shutdown pin, so they have now been re-run against the shipped tree.)

Verification at e5582bf603

  • pnpm --filter @objectstack/core test37 files / 887 tests passed, baseline unmoved
  • same suite with --no-file-parallelism — 37 / 887 passed
  • gate union re-derived with node scripts/pm/dispatch-gates.mjs, no paths passed —
    identical 16 families (this commit adds no new path), all 16 exit 0 at this head,
    after turbo run build. check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured, 1913 raw tsc error(s) total, none above its recorded number,
    so @objectstack/core is still at its recorded 98 and the ratchet holds.
  • eslint packages/core/src/timeout-guard.test.ts — exit 0, no output. No
    prettier --write (Nothing declares that this repo has no formatter, and Prettier's defaults reject main's own files #10622).
  • control-byte self-scan over the changed file — no matches.

Generated by Claude Code


Generated by Claude Code


Generated by Claude Code

Both lifecycle races built a reject-only timeout promise and raced it, and
neither settled the loser — so the promise and the race's reaction on it were
retained past the end of every run (four leaking promises per showcase run
under `vitest --detectAsyncLeaks`).
The two hand-rolled copies had also drifted into doing opposite halves of the
same cleanup: `raceStartupTimeout` cleared its timer and never unref'd;
`shutdown()` unref'd and never cleared, leaving the guard armed to fire against
a kernel already 'stopped'.
Both now go through one `TimeoutGuard`, whose `reclaim()` clears the timer AND
settles the promise. The guard stays ref'd while the race is undecided (#4813):
`unref()` is removed rather than added, because an unref'd guard lets an
otherwise-idle process exit before the timeout can be reported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
The 'still forces exit when teardown hangs' pin I added duplicated the
pre-existing #5274 test, which hangs teardown the same way and asserts the same
three things. Point at it from the #10604 block instead.
Changeset: patch on @objectstack/core. It is a bug fix, but it lands in
published source and changes what an embedding host's process does at teardown,
which is the kind of thing release notes are compiled from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@github-actions

github-actionsBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/core, touching 5 documentable anchor(s).

9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/knowledge-rag.mdx(via ObjectKernel (symbol))
  • content/docs/kernel/architecture.mdx(via ObjectKernel (symbol))
  • content/docs/kernel/events.mdx(via ObjectKernel (symbol))
  • content/docs/kernel/index.mdx(via ObjectKernel (symbol))
  • content/docs/permissions/authentication.mdx(via ObjectKernel (symbol))
  • content/docs/plugins/anatomy.mdx(via ObjectKernel (symbol))
  • content/docs/plugins/packages.mdx(via ObjectKernel (symbol))
  • content/docs/protocol/kernel/index.mdx(via ObjectKernel (symbol))
  • content/docs/protocol/kernel/lifecycle.mdx(via ObjectKernel (symbol))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx(via ObjectKernel (symbol))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json dd8172ee223e14e3104191356fff10aa3f8abe33packageMentionDocs.

Which tree this was computed on

This run read content/docs from c21c5526cf1aabe927c9446b1d93147fe1886236 — the merge of head e5582bf60306012060c41a3b306b532ee6f28392 into base dd8172ee223e14e3104191356fff10aa3f8abe33, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c21c5526cf1aabe927c9446b1d93147fe1886236 && git checkout c21c5526cf1aabe927c9446b1d93147fe1886236
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dd8172ee223e14e3104191356fff10aa3f8abe33 e5582bf60306012060c41a3b306b532ee6f28392 && git checkout -B drift-repro dd8172ee223e14e3104191356fff10aa3f8abe33 && git merge --no-ff e5582bf60306012060c41a3b306b532ee6f28392
node scripts/docs-audit/affected-docs.mjs --json dd8172ee223e14e3104191356fff10aa3f8abe33

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs dd8172ee223e14e3104191356fff10aa3f8abe33 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 21, 2026
@claude

claudeBot commented Aug 21, 2026

Copy link
Copy Markdown
ContributorAuthor

PM triage of the red check — ⚠️the pin is environment-sensitive; the fix is not implicated.

Test Core (4/6) is red on 63ea716fb4. Diagnosed against the full job log (11,752 lines pulled from logs_url, not a tail). 1 failed / 886 passed, and the one failure is this PR's own new pin.

⚠️ First, the trap in this log

It carries a wall of ERROR lines — shutdown boom, manual boom, Test error, Plugin name is required, Invalid semantic version, Service 'svc' not found. Every one of them is a deliberate test fixture. Anyone grepping this log for a cause will hit them first. The actual failure is a single assertion:

FAIL src/timeout-guard.test.ts > raceWithTimeout (#10604)
> leaves no ref'd timer behind on any of the three outcomes
AssertionError: expected 2 to be 4

The cause

The pin counts ref'd timers with a process-global probe:

constrefd=()=>process.getActiveResourcesInfo().filter((r)=>r==='Timeout').length;constbefore=refd();// … then asserts refd() === before after each of three outcomes

expected 2 to be 4 means before was 4 and the later reading was 2 — ⭐ the count went DOWN. A leaked timer makes that number go up. Two timers that were alive when before was sampled simply expired during the test, and they were not this test's.

getActiveResourcesInfo() is process-wide. This shard runs 37 test files in one worker, so the baseline includes other suites' pending timers and vitest's own. Locally — one file, a quiet process — the baseline is stable and the assertion holds; in a shared CI worker it cannot. ⇒ A false red from the instrument, not a defect in the change. The equality assertion is the problem: it treats an ambient global as if this test owned it.

⭐ Same class as a card that landed tonight

This is exactly #10511 / PR #10626: a fixture scored against an ambient value it does not control, green locally and red in CI for reasons unrelated to the thing under test. That one was repaired by making both sides of the comparison derive from one anchor the test owns rather than from the environment.

Shapes that would fix it here, in preference order:

  1. Assert non-increase, not equalityexpect(refd()).toBeLessThanOrEqual(before). A leak is a growth; foreign timers expiring is not a signal. Cheapest, and it keeps the pin pointed at the defect direction.
  2. Attribute the delta — sample immediately around the call with no await gap in which foreign timers can settle, and compare only the change the call is responsible for.
  3. Isolate the file so the process is quiet — heaviest, and it makes the pin's validity depend on a runner setting rather than on the assertion.

⛔ Whatever is chosen, the pin must still fail on a real leak — that is its whole purpose, and #10604's brief made it non-negotiable that the fix not be validated by weakening its own guard. Option 1 preserves that; a blanket relaxation would not.

Not yet actioned

The implementing seat is still running and pushed this head five minutes before the failure. ⛔ I am not putting a second agent on its branch mid-run. This is recorded now so the diagnosis is durable and does not have to be re-derived; the repair follows once that seat reports.


Generated by Claude Code

@claude

claudeBot commented Aug 21, 2026

Copy link
Copy Markdown
ContributorAuthor

PM review — ACCEPT the substance.My brief contained a fix that would have passed its own acceptance criteria while destroying the guarantee, and your ablation C proves it.

Head 63ea716fb4. One check red — that is the instrument, diagnosed separately in the comment above and repaired next; it does not touch the verdict below.

⛔ The brief error, which is the important part

I wrote: "Both halves of the cleanup at both sites: clear and unref." You did not do that. You removed the existing unref at the shutdown site, citing #4813 — which had already landed the opposite finding in this same file:

an unref'd guard stops pinning the loop but stops being a guard too, so a hung teardown in an otherwise-idle process exits silently at status 0 and never reaches Shutdown timed out — forcing exit / exit(1)

And then you proved it rather than arguing it. Ablation C applies my own suggestionthis.timer.unref() at arm time — and:

1 red, and ONLY the anti-disarm pin; every leak pin stayed GREEN.

"fix the leak by disarming the guard" passes every leak assertion. My brief's instruction would have produced a PR that was green on every criterion the brief itself named, with the shutdown guarantee silently gone. That is the single most valuable result in tonight's round, and it exists only because you built a pin for the failure mode the brief told you to avoid and then ran the brief's version against it.

You were also right on the mechanism, where I was wrong twice over: settling the loser is what removes the leak — clearing the timer alone removes none of the four frames, because two of them are the race's own reaction promises on the never-settling participant. So "clear and unref" was wrong about what to do and about why. Reclaiming on settle gets the loop hygiene unref was reaching for without giving up the guarantee — "the two goals were never in tension."

The other three corrections, all accepted

  1. Line numbers had not moved. I told you to re-derive because kernel.ts may have shifted since b34ef8de8. It had not — raceStartupTimeout still 629-647, shutdown still 457-469, and all four leak frames reproduced at exactly 636:32 / 643:34 / 461:36 / 469:27. Checking was still right; the caution was just unnecessary here.
  2. The proofs I asked you to construct already existed. I asked for "a plugin that exceeds the startup timeout and one whose shutdown hangs" — both are landed pins (hanging-plugin内核的插件 init/start 超时守卫定时器从不清除也不 unref —— 每个进程在工作结束后还要空转 startupTimeout(CLI 挂 ~120s 才退出) #4813, hanging-shutdown-plugincore: ObjectKernel 上一个抛错的 kernel:shutdown handler 会跳过所有插件 destroy() 并 process.exit(1),日志还谎报「Shutdown timed out」 #5274), green and unchanged. You wrote a duplicate of the shutdown one, found core: ObjectKernel 上一个抛错的 kernel:shutdown handler 会跳过所有插件 destroy() 并 process.exit(1),日志还谎报「Shutdown timed out」 #5274's, and deleted yours in the second commit rather than ship two near-identical tests. Deleting your own work on discovering it was redundant is the right instinct and the one most likely to be skipped.
  3. pnpm --filter @objectstack/core typecheck does not exist — and you noted it fails loudly (ERR_PNPM_RECURSIVE_RUN_NO_SCRIPT), not as the silent zero-match exit-0 trap, which is the distinction that matters. The 98 pre-existing errors are all ledgered debt, not a verdict on this change.

⭐ And the detail that shows the ratchet was actually understood rather than merely satisfied: you used an explicit ./timeout-guard.js specifier in the new test precisely so it adds zero debt — the sibling test files omit the extension and each pay one TS2835, so a bare specifier would have made it 99 and turned check:type-check-debt red. Green at 98.

The evidence

  • Before, on a pristine tree with git status --porcelain empty: Async Leaks 4, all four frames at the cited positions, 24 passed / 364 passed.
  • After at 63ea716fb4: 0PROMISE leaking, 0 Async Leaks sections, 24 passed / 364 passed — baseline unmoved. And you rebuilt @objectstack/core between the two measurements because the showcase resolves it through the workspace link to dist/ — without that the "after" run would have measured the old code and read as a fix.
  • Timeouts still fire, verbose, post-fix: #4813 > still fires the guard when the plugin loses the race; #5274 > still logs the timeout and still forces exit(1) when shutdown genuinely times out; arms a REF'D timer, so an otherwise-idle process cannot exit before it fires.
  • Ablations A and B each anchor-counted on disk both ways: removing the settle half reds exactly the two settlement pins; removing the clear half reds 7 including the pre-existing 内核的插件 init/start 超时守卫定时器从不清除也不 unref —— 每个进程在工作结束后还要空转 startupTimeout(CLI 挂 ~120s 才退出) #4813 pins — which is the evidence the shared helper really is on the startup path too, not just the shutdown one.
  • 16 gate families, union derived by dispatch-gates with no paths, all exit 0 after the turbo build the debt ratchet requires.

The red check

Test Core (4/6) fails on this PR's own new pin, leaves no ref'd timer behind, with expected 2 to be 4 — the count went down, which a leak cannot cause. It reads process.getActiveResourcesInfo(), a process-global counter, in a shard running 37 files in one worker: foreign timers expired between the baseline sample and the assertion. Your local run of the same head is 37 passed / 887 passed, which is consistent — the pin is sound in a quiet process and cannot hold in a shared one.

Same class as #10511 / PR #10626, which landed tonight: a fixture scored against an ambient value it does not own. Repair dispatched; ⛔ the constraint carried into it is that the pin must still fail on a real leak — asserting non-increase rather than equality keeps that, since a leak is a growth and a foreign timer expiring is not a signal.


Generated by Claude Code

…ient count
`process.getActiveResourcesInfo()` is process-wide and this file shares its
CI worker with three dozen others, so the absolute Timeout count is ambient
and the pin did not own it. Foreign timers alive at the baseline expired
during the third leg -- the only one that spends real time on the loop --
and the reading went DOWN: `expected 2 to be 4` on Test Core (4/6).
A leak is a GROWTH, so each leg now re-anchors on its own sample and asserts
non-increase. That keeps the whole point of the pin (an unreclaimed 120s
guard reads `+1`, which is what ablating the `clearTimeout` half reds) and
gives up only the decrease, which nothing `raceWithTimeout` does can cause.
No runner or shard configuration is touched: the pin's validity stays a
property of its own assertion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@claude

claudeBot commented Aug 21, 2026

Copy link
Copy Markdown
ContributorAuthor

PM review — verified against the diff and against CI, not the report. Approving.

CI has confirmed the repair. On e5582bf603, Test Core (4/6) — the shard that was red —
is success, along with 2/6, 3/6, 5/6 and 6/6. Nothing is failing.

The diagnosis was right and the narrowing is yours, not mine. My brief had the shape —
expected 2 to be 4 is the count going down, and no leak can cause that — but not the
location. The CI frame timeout-guard.test.ts:156:24 is the third leg specifically, the
only one that spends real time on the loop; legs 1 and 2 settle on microtasks, where no timer
phase can run, so their reading provably cannot move. The empirical confirmation is the part I
value most: under ~2885 ambient timers expiring at ~1/ms, legs 1–2 held exact equality and
so did the synchronous anti-disarm pin. That is a prediction that could have failed and didn't.

The shipped shape is better than the one I asked for. I specified
toBeLessThanOrEqual(before); you added per-leg re-anchoring, so each comparison spans one
outcome rather than the whole test:

letbefore=refd();awaitraceWithTimeout(Promise.resolve('ok'),120_000,()=>newError('must not fire'));expect(refd()).toBeLessThanOrEqual(before);before=refd();// ← re-anchored, per leg

Reading the file, the split is what makes this coherent: the synchronous pin
(arms a REF'D timer…) keeps exact equality on before + 1, which is safe precisely because
it never awaits and no foreign timer can expire mid-assertion; the async three-outcome pin
asserts non-increase. Two different hazards, two different assertions. Documenting the
process-wide hazard in a ⚠️ comment beside the probe is the right place for it.

Both constraints I set are met, and met by measurement:

Rejecting my alternative was the right call. I offered vi.useFakeTimers +
vi.getTimerCount() as a probe the test owns outright. You declined because the sibling pin at
timeout-guard.test.ts:34-47 already makes exactly that assertion, so converting this one would
collapse it into a near-duplicate of a pin the prior seat had just finished de-duplicating — and
would drop the ref'd-ness dimension, which is this pin's only reason to use the real probe.
Correct, and better reasoned than my suggestion.

"Could not reproduce naturally" was stated plainly rather than papered over — 4 green runs
including --no-file-parallelism, then the condition built synthetically, reproducing the same
test, the same line, and the same downward shape. That is the honest order of operations. Both
scratch files archived and deleted before deriving gates, with git status --porcelain empty.

All 16 gates exit 0 at the same tree as the final head. @objectstack/core's debt ledger entry
is unmoved.

One correction to my own brief, not to your work. I have been writing the #9465 fence as
.changeset/**, which is over-broad: the fence is the changeset toolchain — config, scripts,
and the release workflows — not individual entry files. This PR adds
.changeset/kernel-timeout-guard-reclaim.md, which is correct and required, since
packages/core publishes. Arming as soon as the last shard and the dogfood legs clear.

Follow-up #10685 correctly filed rather than folded in.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 21, 2026 08:49
@os-zhuang
os-zhuang enabled auto-merge August 21, 2026 08:50
@os-zhuang
os-zhuang added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 47cd3ecAug 21, 2026
35 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10604-kernel-race-timeout-leaks branch August 21, 2026 09:02
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-zhuang@claude