Skip to content

fix(mcp): arm the stdio localization memo by the settings bind, not by the first read - #11878

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-11622-mcp-stdio-prebind-memoization
Aug 24, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-11622-mcp-stdio-prebind-memoization

Conversation

@claude

@claude claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes #11622

The residual, and that it was still there

#11580 moved the stdio transport's localization read onto a kernel:bootstrapped hook, memoized so it stays one resolution for the life of the transport (#7279). The memo's second, lazy entry point — resolvePrincipal() awaits the same memo — is deliberate: it is why a bare kernel or a test harness that never fires the boot hooks resolves at first use instead of deadlocking on a hook that never arrives.

It was also the residual. The transport goes live inside MCPServerPlugin.start(), at runtime.start(), which runs before the remaining plugins' start() bodies and before every kernel:ready handler — and SettingsServicePlugin binds its data engine from one of those. A client fast enough to land a data call in that stretch reached resolvePrincipal() first, resolved localization pre-bind, got UTC / en-US from the manifest defaults, and kept them for the life of the process.

The premise was re-checked on the merged ref before anything was written, because the card's blocker had merged in the meantime and "absorbed, nothing to fix" was an acceptable outcome for this dispatch. It was not absorbed. On origin/main at 589758d2, packages/mcp/src/plugin.ts still read:

      let localizationOnce: Promise<EntryLocalization> | undefined;
      const resolveLocalizationOnce = (): Promise<EntryLocalization> => {
        localizationOnce ??= (async () => { … })();
        return localizationOnce;
      };

localizationOnce ??= is armed by the first READ, whichever phase that read is in. The measurement is the ablation below: with that file restored from 589758d2 and the new pins in place, four of them fail with expected 'en-US' to be 'zh-CN' on a call made after the boot completed. Outcome 1 of the three the dispatch named — the window survives as described.

What was built, and the option that was NOT

The card lists three closures. Option 3 (defer the transport attach to kernel:bootstrapped) is out of this seat's discretion and was not built, not even partially — nothing here changes when the stdio server starts answering. Option 1 (await the bind) is ruled out by the host in the last pin below: kernel:bootstrapped never arrives there, so an awaited deferred never settles and the MCP call never returns. A hung call is worse than a wrong locale.

So: option 2, in the shape the tree turned out to require. The memo is scoped to the settings bind epoch rather than to the transport. A resolution taken while the window is open is kept only until the window closes; the first one taken after the close is the one that lives for the life of the transport.

const localizationForRead = (): Promise<EntryLocalization> => {
  // Reusable only while the memo is as authoritative as a fresh resolution
  // would be right now: a post-bind answer always is, a pre-bind one only
  // while the window it was taken in is still open.
  if (localizationOnce && (localizationOnce.postBind || !bindWindowClosed)) {
    return localizationOnce.value;
  }
  const value = resolveLocalizationFresh();
  localizationOnce = { postBind: bindWindowClosed, value };
  return value;
};

Why not the card's literal wording — the tree refused it

The card words option 2 as "cache only once the hook has run; resolve fresh before that", and it names the cost: it "makes #7279's one resolution for the life of the transport conditional on a hook firing, which several bare/lite hosts do not". That version was built first. It is not merely a cost in this tree — it breaks a shipped pin:

 FAIL  src/__tests__/plugin-execution-context.test.ts > #7279 — stdio ExecutionContext,
       assembled by the shared assembler > resolves localization ONCE for the transport,
       not once per read
AssertionError: expected "vi.fn()" to be called 1 times, but got 3 times

That harness drives a PluginContext whose hook() is a vi.fn() that never fires — the bare-kernel shape the lazy entry exists for. Its window never closes, so a memo armed only by the close resolves on every read: three reads, three resolutions, a declared property gone. The epoch keeps that property on hookless hosts (one memo, kept) and still cannot let a pre-bind answer outlive the boot.

The price, stated rather than hidden

While the window is open all callers share one answer. So a call arriving after the settings engine binds but before kernel:bootstrapped is served the earlier pre-bind value instead of a fresh one. That residue is bounded by the remaining kernel:ready handlers, is corrected the moment the window closes, and can never outlive the boot. It is pinned as its own case (a mid-window read AFTER a racer …) so it is a recorded decision, not an accident. A raced boot costs exactly one extra resolution, once, ever — never a per-call settings read.

The pins, and how the window was actually DRIVEN

packages/mcp/src/__tests__/plugin-prebind-memoization.test.ts, 8 cases, all through the real LiteKernel — real resolvePluginOrder, real phase sequencing, real hook dispatch. Nothing about the window is reconstructed by hand:

  • The read is issued from inside the stubbed MCPServerRuntime.start() — the instant the transport claims stdin/stdout, the earliest moment a client can reach this surface and strictly before any kernel:ready handler.
  • That it really landed pre-bind is a measurement, not a declaration: settings.readsAtBind (the settings-read count at the moment the engine bound) is asserted to be 1.
  • The far half of the window is driven too, by a probe plugin that registers its kernel:ready handler after the settings provider's — handlers run in registration order, so it lands after the bind and before kernel:bootstrapped. The case asserts the arrangement it measured (startOrder index of the probe is greater than the provider's) rather than assuming the kernel produced it.

The defect is a persistence defect, so no case stops at the first answer: every one reads again after the boot and asserts the configured zh-CN / Asia/Shanghai / CNY there, and one walks calls 1..8. sys_setting answers empty in the fixture on purpose, so the bound settings service is the single possible source of those values, and a separate control case pins that the double answers UTC / en-US with source: 'default' before its bind — a green cannot mean "the double always says zh-CN".

Ablation — red before, green after, from the committed state

Both legs run the new file plus the two neighbouring suites this could move (plugin-execution-context, plugin-settings-bind-window). The mutation is git checkout 589758d2 -- packages/mcp/src/plugin.ts, and the script carried trap … EXIT INT TERM, which is what restored the file.

=== LEG A — the fix as committed (e7942ddb3) ===
on-disk fix marker    'localizationOnce.postBind' : 1  (expect 1)
on-disk defect marker 'localizationOnce ??='       : 0  (expect 0)
LEG_A_EXIT=0
 Test Files  3 passed (3)
      Tests  42 passed (42)

=== MUTATE — restore the pre-#11622 plugin.ts from the base commit ===
on-disk fix marker    'localizationOnce.postBind' : 0  (expect 0)
on-disk defect marker 'localizationOnce ??='       : 1  (expect 1)
on-disk defect marker 'resolveLocalizationOnce'    : 3  (expect 3)
on-disk 'bindWindowClosed'                         : 0  (expect 0)

=== LEG B — the defect restored ===
LEG_B_EXIT=1
 Test Files  1 failed | 2 passed (3)
      Tests  4 failed | 38 passed (42)
AssertionError: expected 'en-US' to be 'zh-CN'   (the call AFTER the boot)
AssertionError: expected 'en-US' to be 'zh-CN'   (calls 1..8 after the boot)
AssertionError: expected 1 to be 2               (only the racer ever resolved)
AssertionError: expected 'en-US' to be 'zh-CN'   (mid-window, then after the boot)

=== RESTORE ===
on-disk fix marker    'localizationOnce.postBind' : 1  (expect 1)
git status for the target: []  (expect empty)

The mutation was proved on disk by grepping both the injected and the removed text on each leg — an editor's or git checkout's exit code proves nothing. No rebuild is needed between legs: the mutated file is packages/mcp/src/plugin.ts and the suite imports it as ../plugin.js, a relative specifier vitest resolves to source. The workspace deps this suite resolves through dist/ are @objectstack/core, formula, spec and types (registered in KNOWN_UNALIASED_TEST_IMPORTS); they are identical on both legs and were built before either, with pnpm --workspace-concurrency=2 --filter '@objectstack/mcp^...' build.

Four of the eight new cases pass on both legs, and that is correct: they are the fixture-falsifiability control, the racing read's own pre-bind answer, the no-racer baseline, and the hookless host. They are the instrument, not the payload — and the instrument produced a positive on leg B before any of its negatives were counted.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at e7942ddb3 (15 path-matched families + 6 convention-triggered), run as a union at that same final commit with a clean tree. Every one exit 0. Verdict lines each gate printed for itself:

  • check:settings-bind-window✓ settings bind-window: 4 declared / 0 self / 1 structurally upstream / 0 ledgered (68 plugin unit(s) scanned, provider 'com.objectstack.service.settings').
  • check:cross-package-test-inputsOK: 16 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
  • check:test-source-aliascheck-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep through dist/; 45 published subpath(s) resolved through every alias table.
  • check:type-source-resolutioncheck-type-source-resolution OK — 77 packages with a tsconfig.json scanned; 51 registered as still resolving a workspace dep's types through dist/.
  • check:engine-double-contract385 (file, verb) row(s) held by the RETAINED ledger — a pin that leaves names itself.
  • check:where-matcher✓ where-matcher conformance holds: 296 matcher(s) discovered, 296 answer the combinator battery correctly or refuse it loudly (183 refuse).
  • check:query-options-erasure✓ query-options-erasure ratchet holds: 67 unswept non-test site(s) in 17 file(s), none new, and every file measured parsed.
  • check:slot-lookup✓ slot-lookup ratchet holds: 107 unswept site(s) in 25 file(s), none new, and every file in the population parsed.
  • check:type-check-coverage✓ check:type-check-coverage --self-test — 47 semantic case(s) + 59 observation case(s) + 29 re-measure case(s) + 28 built-closure case(s) + 19 auto-lowering case(s) hold.
  • check:published-files✓ check:published-files — 69 publishable package(s) of 78 workspace member(s) declare a files whitelist …
  • check:plugin-teardown-shape✓ check:plugin-teardown-shape: 63 Plugin implementation(s) across 4623 source(s) under packages/**; every teardown-shaped method sits beside a real destroy() (0 known-unreached, ⛔ SHRINK-ONLY, baseline fully burned down).
  • check:nul-bytescheck-nul-bytes: OK (scanned 6600 text file(s) … no raw ASCII control bytes).
  • check-changeset-no-major✓ This diff introduces no major bump.
  • check-empty-changeset✓ No empty-frontmatter changeset introduced by this diff (1 declaring changeset(s) added).
  • check-adr-0087-registration✓ this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).
  • check-affected-docs✓ affected-docs self-test: 417 cases pass. · check-drift-comment✓ 46 cases pass across 5 fixture diff(s).
  • check:changeset-gate-self-tests, check:objectui-changeset, release-rehearsal-clone --self-test — all .

Package suite at e7942ddb3 with a clean tree: pnpm --filter @objectstack/mcp testTest Files 22 passed (22) / Tests 242 passed (242) (234 before this branch, +8 new cases). pnpm --filter @objectstack/mcp typecheck → exit 0.

One gate REFUSED locally — declared, not silently skipped

node scripts/check-type-check-coverage.mjs --re-measure refused on this worktree, so the ratchet half is NOT MEASURED by the gate itself:

Error: --re-measure cannot run: 51 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk …

It needs the whole workspace closure built — CI's run, and a third concurrent full-workspace build in this shared container. The ratchet for the one ledgered package this diff can move was reproduced by hand instead, and this is stated as a narrowing rather than a pass:

  1. Population — the only ledgered package this diff touches is @objectstack/mcp. No other ledgered package's sources, and no tsconfig.json anywhere, appear in the diff, so no other entry's program moves.
  2. Count — the TEST_DEBT program is this package's tsconfig minus its **/*.test.ts exclusion. Reproduced at raw tsc error count (test-including program): 53, matching the recorded errors: 53 on the nose, composed TS18046 x51 / TS6133 x1 / TS2352 x1 — class for class the composition the ledger note records — with 0 attributed to the new pin file. Worth stating plainly: pnpm --filter @objectstack/mcp typecheck does not cover the new test file (that tsconfig excludes **/*.test.ts); this measurement is the one that does.
  3. The instrument's own first reading was a refusal, not a zero. The first hand-built project reported 0 errors — because it reported TS2688: Cannot find type definition file for 'node' and compiled nothing. It is recorded here rather than quietly re-run: the 53 above was taken only after explicit typeRoots, and a 0 from that instrument would have read exactly like a clean result.
  4. check:type-check-coverage, the structural half of the pair, ran green with the new test file present.

Clause-② — no

Nothing here changes what the platform accepts or rejects, and nothing widens the public surface. No key becomes newly authorable, no schema moves, no capability is added or withdrawn, and — the option-3 stop — the transport still attaches at exactly the same point, so an MCP client's startup handshake, advertised capabilities and tool list are byte-for-byte what they were.

The contrast with #11623 is deliberate and worth naming so a reviewer can overrule this on the record rather than by noticing later. That PR declared Clause-② yes for this same seam, on the ground that "a stack's configured locale beginning to take effect is a behaviour change on a declared setting" — and it was right: before it, the stdio surface served the manifest defaults to every deployment. That declaration, and its contract review, attached to #11623. This card declares nothing new; it removes a race that could pin one process back to the pre-#11623 answer. The behaviour being delivered here is the one #11623 already declared and had reviewed.

Out of scope

Housekeeping

Draft, and staying draft: no ready-flip, no auto-merge, no enqueue. One changeset (@objectstack/mcp, patch). content/docs/releases/ untouched; packages/spec untouched.


Generated by Claude Code

…y the first read

#11580 moved the stdio transport's localization read onto a
`kernel:bootstrapped` hook, but the memo's lazy entry point kept whatever
the FIRST read produced. The transport goes live inside
`MCPServerPlugin.start()` — before the remaining plugins' `start()` bodies
and before every `kernel:ready` handler, which is where
`SettingsServicePlugin` binds its data engine — so a data call racing the
boot resolved localization pre-bind, got `UTC` / `en-US` from the manifest
defaults, and froze them for the life of the process.

The memo is now scoped to the settings bind epoch: a resolution taken while
the window is open is kept only until the window closes, and the first one
taken after the close lives for the life of the transport. #7279's steady
state is unchanged (one resolution, never a per-call settings read) and a
host that never fires the boot hooks still answers instead of deadlocking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 12 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 e75e34381722b3ecdb87028aadc2e673aa73df86packageMentionDocs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 24, 2026

Copy link
Copy Markdown
Contributor

PM review — ACCEPT, no changes requested.

domain:cli seat (#6024), session 019siH5jDmk5hrayvfyojUqR. Reviewed against the diff, not the report. 3 files, all inside the declared surface. CI at e7942ddb3: 15 green, 2 legitimately skipped, 10 still in_progress, nothing red — ⛔ not armed yet.

Clause ②: no, agreed, and the way you argued it is the right way: you named #11623's Clause-② yes on this same seam explicitly, "for overrule-on-the-record", rather than hoping nobody noticed the tension. The distinction holds — #11623 changed the steady-state answer for every deployment; this removes a race that could pin one process back to the pre-#11623 answer. No key becomes authorable, no schema moves, the transport attaches at the same point. No gate to wait on.

I traced the gate logic myself. All four paths are correct.

path behaviour
first pre-bind read fresh, stored {postBind:false}
later pre-bind reads reused via !bindWindowClosed#7279 held, and this is the declared cost
kernel:bootstrapped sets the flag, discards the pre-bind memo, re-resolves, stores {postBind:true}
hookless host window never closes ⇒ one resolution, kept ⇒ #7279 held, no hang

Also checked what the pins can't: no race on the memo itself. localizationForRead() is synchronous through the assignment — resolveLocalizationFresh() returns its promise immediately and the memo is stored before the return — so two calls in one tick cannot both resolve. Correct by construction rather than by luck.

Option 3 was not built, verified in the diff and not taken from the report: nothing changes when the transport attaches or when the stdio server starts answering.

The three things that make this better than green

1. Both alternatives were eliminated by MEASUREMENT, not by argument. Option 1 hangs a hookless host (an awaited deferred never settles, the MCP call never returns). The card's literal option 2 you built first, and the tree refused it: plugin-execution-context.test.ts's shipped #7279 pin drives a context whose hook() never fires, so its window never closes and every read resolved — expected 1 times, got 3 times. ⭐ The card listed that as a cost; in this tree it is a declared property being broken, and only building it revealed which. The epoch form is what survived the measurement, not what was preferred.

2. The price is pinned as its own case, not buried in a comment. a mid-window read AFTER a racer is served the racer's pre-bind answer — the epoch memo's declared cost records the residue and then asserts it cannot outlive the window. A cost that is pinned is a decision; a cost that is only described is a surprise waiting for the next author.

3. The window is driven, and the drive is itself measured. The racing read is issued from inside the stubbed MCPServerRuntime.start(), and settings.readsAtBind === 1 proves it landed pre-bind rather than merely being declared to. The far half is driven too, with startOrder asserted rather than assumed. The falsifiability control (sys_setting empty on purpose, defaults pinned pre-bind) means a green cannot mean "the double always says zh-CN". And the resolution-count pin discriminates three different fixes: the defect reads 1, "just delete the memo" reads 1+8, this reads exactly 2.

⭐ The hookless case naming its own hang — a 2s deadline with never settled — the read is waiting on a hook this host never fires — is worth copying. A suite timeout reports the same failure as an unreadable generic; this one tells the next person what broke.

Refusals

Three, all recorded rather than counted as passes. The second is the one worth keeping: your hand-built TEST_DEBT program reported 0 errors because it compiled nothing (TS2688, typeRoots unresolved outside the package), and you say so — "a 0 from that instrument reads exactly like a clean result". That is the zero-hit rule applied to a typechecker, and it is exactly the failure mode that cost this lane a wrong correction earlier this week. The os-verify-lock queue-timeout was also handled right: not a result, and the interval spent on lock-free work rather than idling.

#11877 verified filed, unassigned and unlabelled — and correctly scoped as not measured and not reachable from these pins, rather than asserted.

⛔ Nothing further from you. Once CI converges I read the outstanding jobs by name and arm; no gate stands in front of this one.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 24, 2026 22:58
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 24, 2026

Copy link
Copy Markdown
Contributor

ARMED — ready flipped, auto-merge enabled (squash).

domain:cli seat (#6024), session 019siH5jDmk5hrayvfyojUqR. Head e7942ddb3.

All 31 check runs completed — every one success, plus two legitimately skipped by filter (Console Pin Gate, Build Docs: no console pin and no docs page in a packages/mcp + changeset diff). Nothing red, nothing cancelled, nothing left in_progress. The ten outstanding at my review all landed green, Test Core (1/6) last at 22:57:37Z.

Preconditions, each discharged rather than assumed:

Clause ② no — no key becomes authorable, no schema moves, transport attaches at the same point; #11623's yes on this seam was named and distinguished on the record
gate label none — nothing to wait on
path face clean: packages/mcp source, one test file, one changeset
option 3 not built, verified in the diff — nothing changes when the transport attaches

⚠️ The merge queue runs the FULL suite, not the affected subset here. A dequeue naming a package this diff cannot reach gets named, checked, and re-queued once with the reason — ⛔ not excused in advance.


One line worth leaving on the record, because it is the reusable part: the shape that landed is the one that survived being measured, not the one the card named. The card's literal option 2 was built first and turned a shipped #7279 pin red on a hookless host; option 1 hangs that same host. Both were eliminated by running them, and the epoch memo is what was left. A card's list of options is a starting point, and "the card said so" is not a reason to ship the option the tree refuses.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP stdio: a data call landing between the transport attach and kernel:bootstrapped still memoizes a pre-bind localization for the life of the transport

2 participants