Skip to content

fix(events): stand by instead of exiting when the event runtime socket is owned - #561

Merged
ScriptedAlchemy merged 5 commits into
mainfrom
fix/event-runtime-standby
Sep 4, 2026
Merged

ScriptedAlchemy merged 5 commits into
mainfrom
fix/event-runtime-standby

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 4, 2026 •

Copy link
Copy Markdown
Owner

Closes #559

Symptom

A generated stdio MCP server exits during startup — before answering initialize — whenever another process from the same plugin install already hosts the event runtime socket. Hosts see CONNECTION_CLOSED; the MCP log shows EventRuntimeTransportError: Event runtime endpoint already has a live server. Observed today with Claude Desktop (every Claude Code session shares one --plugin-dir, so three sessions' hauler servers died at start) and Cursor (imports Claude-installed plugins from the same cache root, held the endpoint, every new Claude session lost). Details in #559.

Root cause

entry-shell.ts derives the event runtime endpointId from artifact epoch + target + plugin root, so every process launched from one install maps to one socket. events/ipc.ts openServer / claimEndpoint treat a live server on that socket as fatal (runtime-failed), and mcp-server-runtime.ts startEventRuntime awaits createEventRuntimeServer, so the rejection propagates out of createGeneratedRouteMcpServer and the generated entry. A per-install singleton that fails closed is the wrong contract: several concurrent sessions per host, and several hosts sharing one install, are the normal case.

Fix — standby with takeover

packages/agent-bundle/src/events/ipc.ts

  • createEventRuntimeServer gains whenOwned?: 'fail' | 'standby' (EventRuntimeOwnedEndpointPolicy). The default 'fail' is byte-for-byte the old behaviour: same EventRuntimeTransportError, same code/message/name.
  • Under 'standby', a live owner is not fatal. The server resolves in the standby role with nothing bound, and a scope-bound fiber (standByForEndpoint, Effect.forkScoped) sleeps STANDBY_PROBE_INTERVAL (1 s) plus 0–STANDBY_PROBE_JITTER_MS (250 ms) of jitter, probes the endpoint, and as soon as it is not live runs the same openServer path an unowned start uses — claim lock, identity-checked stale-socket removal, listen, release. Losing the race to another standby (the existing claim lock serializes claims) leaves it standing by for the next tick; only one wins.
  • openServer is extended, not forked: the three sites that can see a live server (pre-claim probe, claim-loop probe, under-claim probe) now fail with a private EventRuntimeEndpointOwnedError marker subclass, and listenServer maps EADDRINUSE (the only owner signal Windows has) to the same marker, so the standby loop retries exactly "owned elsewhere" and nothing else. Permission errors, an unrecoverable claim, or a stale socket the owner cannot remove still fail closed under both policies.
  • EventRuntimeServer gains role(): 'owner' | 'standby' and onRoleChange(listener) => unsubscribe; endpoint is unchanged. The service state is now { endpoint, listener: Ref, roleListeners, standby: Fiber | undefined }; the bind and the Ref.set are one uninterruptible acquireRelease, and close() interrupts and awaits the standby fiber before reading the listener, so a close() racing a takeover still finds and removes the socket that takeover just bound. Every existing invariant is preserved — identity-checked lock/socket removal, uninterruptible claim/release pairing, the Linux recovery gate, epoch checks.
  • close() takes the endpoint claim before it stops listening and holds it through the identity-checked removal (releaseOwnedEndpoint), so a standby cannot bind at the path while the old owner is still tearing down; standbys wait out the claim through their bounded retries.
  • createEventRuntimeServer also gains onStandbyError?: (error: EventRuntimeTransportError) => void: every failure the standby loop recovers from by retrying, other than "still owned" (probe/claim/stale-removal/listen/release failures, and a role listener that threw as Event runtime role listener failed.). Role listeners each run in their own guard.

packages/agent-bundle/src/mcp-server-runtime.ts

  • startEventRuntime passes whenOwned: 'standby', and writes to stderr only (stdout is the protocol stream): agent-bundle event runtime: <endpoint> is owned by another process; standing by at start, and agent-bundle event runtime: <endpoint> was released by its owner; took it over on takeover.
  • onStandbyError is wired to createStandbyErrorReporter: agent-bundle event runtime: <endpoint> takeover failed, still standing by: <message>, one line per distinct message, repeated at most every 30 s.

Not changed on purpose:

  • role is not added to the status payload. Only the owner has a socket to answer on, so a status response's role is always owner — the field would carry no information — and eventRuntimeStatusPayloadSchema is .strict() and parsed by install/doctor.ts, which runs from a different install of agent-bundle than the server it probes; an additive field would make an older doctor reject a newer server's status.
  • Hook wrappers (adapters/hook-contract.ts) are untouched: with an owner alive they are served as before; during the ≤ ~1 s takeover gap they see runtime-unavailable and the documented fallback: 'standalone' applies.

Test plan

Unit — packages/agent-bundle/tests/event-ipc.test.ts (the existing ipc file, same createEventRuntimeServerForTest patterns):

  • keeps rejecting a second live server when the caller asks for the fail policy explicitly (and every existing fail-closed test is unchanged and passing)
  • stands by behind a live owner and takes the endpoint over once the owner closes — role() === 'standby', requestEventRuntime answered by the first, first closes, onRoleChange('owner') fires within 2 s, second serves requests, second closes → socket and lock gone
  • closes a standby before any takeover without disturbing the owner or leaving files behind
  • lets exactly one of several standbys take over and keeps the rest standing by
  • holds the endpoint claim across close() so no standby can bind while the old owner is still tearing down — beforeEndpointRemoval pauses the owner after it stopped listening (path already unlinked) and before its identity check; the standby's attempt inside that window is held off by the claim (reported as claim did not clear), the role stays standby, and after the resume the standby takes over with its socket intact
  • reports a takeover that fails for a reason other than "still owned" and keeps standing by until one succeeds — afterEndpointProbe throws once on the first free probe; onStandbyError receives the typed error and the next attempt takes over
  • keeps notifying the remaining role listeners when an earlier one throws, and reports the throw

Unit — packages/agent-bundle/tests/mcp-server-runtime.test.ts:

  • createStandbyErrorReporter reports each distinct takeover failure once, and again only after the repeat interval
  • the generated server announces standby, takeover failures, and the takeover on stderr only (stubbed runtime server, captured process.stderr.write)

Integration — packages/agent-bundle/tests/generated-route-server.test.ts (boots real stdio children from a built artifact; connectGeneratedServer refactored onto a new connectGeneratedEntry helper that keeps the child's stderr):

  • keeps a second generated server from the same install alive while the first owns the event runtime socket, then hands the socket over — two children from one artifact both complete initialize + tools/list concurrently; the second's stderr contains the standby line; the first serves ping; after the first exits the second logs the takeover, owns the socket (requestEventRuntimeStatus pid matches), and serves ping; closing it removes the socket and lock.
  • Fails on origin/main: with origin/main's events/ipc.ts + mcp-server-runtime.ts and this test, the second child dies with EventRuntimeTransportError: Event runtime endpoint already has a live server. (code: 'runtime-failed') → Generated route server failed to connect on all three attempts; passes with this branch.

Gates on the final tree: pnpm typecheck ✓ · pnpm lint ✓ (0 errors, 0 warnings) · pnpm test:unit ✓ (3370 passed) · pnpm test:projection ✓ (171 passed) · node scripts/run-packed-tests.mjs ✓ (27 passed, 10 files) · pnpm docs:site:build ✓ (dead-link and language-parity checks pass). No new module was added, so there is nothing to check for a missing production importer.

CI: every check green. Verify (Node 24) failed once on packages/workbench/tests/mcp-app-real.e2e.test.ts › "runs a generated SDK-v2 App …" (page.waitForRequest timeout on the /api/mcp/apps/*/close + DELETE /api/mcp/sessions/<id> requests after clicking Close MCP session — the known Workbench MCP session-close timing race, no event runtime involved); the failed jobs were rerun once and passed.

Docs

  • website/docs/en/guide/authoring/hooks.mdx and website/docs/zh/guide/authoring/hooks.mdx: new Concurrent sessions paragraph in the event runtime section — one owner per install root, others stand by and take over, the two stderr lines, the claim lock picking one winner, and hooks' standalone fallback during the gap. (docs/entry-conventions.md does not describe the entry's event wiring, so it is unchanged.)
  • .changeset/event-runtime-standby.md — agent-bundle: patch.

Self-review

Reviewer: gpt-5.6-sol-medium (change-risk-reviewer) against 752879a. Three findings, all fixed in 43a85a1:

  1. High — successor socket can be unlinked during takeover. close() now acquires the endpoint claim before it stops listening and holds it through the identity-checked stat+rm (releaseOwnedEndpoint), so takeover and teardown are serialized by the same lock the takeover path uses and no successor can bind at the path — possibly on the inode the kernel just freed — before the old owner's identity check runs. (Note on the literal window: Node/libuv unlinks the socket path when the server closes, so the old owner's stat never sees its own inode; the real hazard was a successor binding before the identity check and inode reuse defeating it — the claim closes both.) Test: deterministic interleaving via the existing beforeEndpointRemoval hook; fails on 752879a with expected 'owner' to be 'standby'.
  2. Medium — takeover failures retried forever without diagnostics. The loop now swallows only EventRuntimeEndpointOwnedError; every other failure keeps it standing by but is reported through the new onStandbyError option, which the generated runtime writes to stderr rate-limited to one line per distinct message per 30 s (createStandbyErrorReporter). Tests: afterEndpointProbe throwing once → callback fires, next attempt takes over (fails on 752879a: expected [] to have a length of 1); reporter rate-limit unit test and stderr wiring test (fail on 752879a: export missing / two lines instead of three).
  3. Medium — one throwing role listener suppresses later listeners. Each listener runs in its own try/catch (notifyRoleListeners); a throw is reported via onStandbyError as Event runtime role listener failed. and never stops the remaining listeners or the transition. Test: throwing listener registered before a recording one → the recording one still receives 'owner' (fails on 752879a: Timed out after 5000ms).

Docs: en/zh Concurrent sessions paragraph gained the sentence that takeover failures other than "still owned" are reported on the server's stderr. Changeset unchanged (behavioural summary only).

Second pass

Reviewer: gpt-5.6-sol-medium (change-risk-reviewer) against 43a85a1. One finding (Warning), fixed in 78c76ba:

  • Throwing error reporter stops recovery. onStandbyError was invoked unguarded from the standby loop and from the throwing-role-listener handler, so a reporter that threw could end the standby fiber or skip later listeners. Every invocation now goes through one non-throwing helper (guardedStandbyErrorReporter) that swallows a reporter throw; both paths use it. Tests: reporter that throws on the first non-owned failure (afterEndpointProbe throw-once) → standby still takes over on the next attempt; throwing listener + throwing reporter → a later recording listener still receives 'owner'. Both fail on 43a85a1 with Timed out after 5000ms and pass after.

Cleared without change: close latency bounded (~990 ms + I/O); uninterruptibleMask preserves claim release; race test has explicit 10/30 s bounds; rate-limit keys are static messages; createStandbyErrorReporter is not package-exported, so no changeset addition.

…t is owned

A generated MCP server exited before answering `initialize` whenever another
process from the same plugin install already owned the event runtime socket
(`Event runtime endpoint already has a live server.`), so every session after
the first launched from one install root got no server at all.

`createEventRuntimeServer` gains `whenOwned: 'fail' | 'standby'` (default
`'fail'`, unchanged). Under `'standby'` a live owner is not fatal: the server
resolves in the `standby` role and a scope-bound fiber probes the endpoint
every ~1 s (jittered) and runs the existing `openServer` path the moment it is
free; the claim lock lets exactly one standby win. `EventRuntimeServer` exposes
`role()` and `onRoleChange()`. The generated runtime passes `'standby'` and
announces both the standby start and the takeover on stderr only.

Closes #559
@changeset-bot

changeset-bot Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 212aa15

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agent-bundle Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-04T22:11:25.487852Z 752879a PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@561
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@561
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/rsc-markdown-stream@561
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@561

commit: 212aa15

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 752879a963

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

pid: process.pid,
startedAt,
}),
whenOwned: 'standby',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve MCP-session affinity for standby hooks

When two sessions run concurrently, this policy leaves session B's MCP requests on B's dispatcher and lineage registry, but every hook wrapper still connects to the single install-wide socket owned by session A. Consequently B's hook events render against A's module/provider state, B's pre-tool observations cannot correlate with tool calls handled by B's registry, and event-triggered notifications use A's MCP connection. This breaks the documented guarantee that hooks share state with tools in exactly the concurrent-session scenario being enabled; the new integration test only invokes a stateless ping tool and never sends a hook belonging to the standby session. The endpoint needs per-session routing or equivalent affinity rather than one active handler per install.

AGENTS.md reference: AGENTS.md:L78-L81

Useful? React with 👍 / 👎.

… guard role listeners

Self-review findings on #561 (752879a):

- close() now takes the endpoint claim before it stops listening and holds it
  through the identity-checked removal, so no standby can bind a new socket at
  the path — possibly on the inode the kernel just freed — before the old
  owner's identity check runs. Standbys wait out the claim (bounded retries).
- The standby loop only swallows "still owned"; every other takeover failure
  keeps it standing by but is reported through the new
  `onStandbyError` option, which the generated runtime writes to stderr at one
  line per distinct message per 30 s.
- Role listeners run in their own guards: a throwing listener is reported via
  `onStandbyError` and never stops the remaining listeners or the transition.
…ndby loop or skip role listeners

Every onStandbyError invocation goes through one guarded helper that
swallows a reporter throw, so the standby keeps probing and the remaining
role listeners still run (second-pass self-review finding on 43a85a1).
@ScriptedAlchemy
ScriptedAlchemy merged commit cd0b4a6 into main Sep 4, 2026
14 checks passed
@ScriptedAlchemy
ScriptedAlchemy deleted the fix/event-runtime-standby branch September 4, 2026 23:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generated MCP server exits when another session already owns the event runtime socket

1 participant