-
Notifications
You must be signed in to change notification settings - Fork 2.2k
fix: bound the lifetime of detached brokers and task workers #652
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f7cd86a
c24d41b
44e427c
b1371af
3ea94be
98669f6
69a72b7
f62edae
2ef1c57
efe1cf2
957fbd1
79790e2
a397c88
1a6191f
d9e3065
da91429
3f8d7c5
17649c2
1b49b12
3180be7
efdfdeb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,7 +13,7 @@ import process from "node:process"; | |
| import { spawn } from "node:child_process"; | ||
| import readline from "node:readline"; | ||
| import { parseBrokerEndpoint } from "./broker-endpoint.mjs"; | ||
| import { ensureBrokerSession, loadBrokerSession } from "./broker-lifecycle.mjs"; | ||
| import { ensureBrokerSession, isBrokerEndpointReady, loadBrokerSession } from "./broker-lifecycle.mjs"; | ||
| import { terminateProcessTree } from "./process.mjs"; | ||
|
|
||
| const PLUGIN_MANIFEST_URL = new URL("../../.claude-plugin/plugin.json", import.meta.url); | ||
|
|
@@ -22,6 +22,9 @@ const PLUGIN_MANIFEST = JSON.parse(fs.readFileSync(PLUGIN_MANIFEST_URL, "utf8")) | |
| export const BROKER_ENDPOINT_ENV = "CODEX_COMPANION_APP_SERVER_ENDPOINT"; | ||
| export const BROKER_BUSY_RPC_CODE = -32001; | ||
|
|
||
| /** How long a failed connect waits for its transport to report an exit before killing it. */ | ||
| const CONNECT_CLEANUP_GRACE_MS = 5000; | ||
|
|
||
| /** @type {ClientInfo} */ | ||
| const DEFAULT_CLIENT_INFO = { | ||
| title: "Codex Plugin", | ||
|
|
@@ -87,6 +90,12 @@ class AppServerClientBase { | |
| if (this.closed) { | ||
| throw new Error("codex app-server client is closed."); | ||
| } | ||
| // `closed` only covers a close we asked for. The transport can die on its own — between a | ||
| // successful connect and the very next request, for instance — and a request registered after | ||
| // that never resolves, because the exit that would reject it has already been reported. | ||
| if (this.exitResolved) { | ||
| throw this.exitError ?? new Error("codex app-server connection closed."); | ||
| } | ||
|
|
||
| const id = this.nextId; | ||
| this.nextId += 1; | ||
|
|
@@ -98,7 +107,7 @@ class AppServerClientBase { | |
| } | ||
|
|
||
| notify(method, params = {}) { | ||
| if (this.closed) { | ||
| if (this.closed || this.exitResolved) { | ||
| return; | ||
| } | ||
| this.sendMessage({ method, params }); | ||
|
|
@@ -175,6 +184,20 @@ class AppServerClientBase { | |
| this.resolveExit(undefined); | ||
| } | ||
|
|
||
| /** | ||
| * Settle the exit state when initialization failed before a transport existed. | ||
| * | ||
| * `close()` ends by awaiting `exitPromise`, and only a live transport ever resolves it. But | ||
| * initialization can fail before one is created — a malformed endpoint rejects while being | ||
| * parsed, a spawn can throw — and closing then waits for an exit that nothing will report, | ||
| * turning a configuration error into a hang. | ||
| */ | ||
| settleExitIfNoTransport(hasTransport) { | ||
| if (!hasTransport) { | ||
| this.handleExit(this.exitError); | ||
| } | ||
| } | ||
|
|
||
| sendMessage(_message) { | ||
| throw new Error("sendMessage must be implemented by subclasses."); | ||
| } | ||
|
|
@@ -262,6 +285,7 @@ class SpawnedCodexAppServerClient extends AppServerClientBase { | |
| }, 50).unref?.(); | ||
| } | ||
|
|
||
| this.settleExitIfNoTransport(Boolean(this.proc)); | ||
| await this.exitPromise; | ||
| } | ||
|
|
||
|
|
@@ -319,6 +343,7 @@ class BrokerCodexAppServerClient extends AppServerClientBase { | |
| if (this.socket) { | ||
| this.socket.end(); | ||
| } | ||
| this.settleExitIfNoTransport(Boolean(this.socket)); | ||
| await this.exitPromise; | ||
| } | ||
|
|
||
|
|
@@ -338,7 +363,22 @@ export class CodexAppServerClient { | |
| if (!options.disableBroker) { | ||
| brokerEndpoint = options.brokerEndpoint ?? options.env?.[BROKER_ENDPOINT_ENV] ?? process.env[BROKER_ENDPOINT_ENV] ?? null; | ||
| if (!brokerEndpoint && options.reuseExistingBroker) { | ||
| brokerEndpoint = loadBrokerSession(cwd)?.endpoint ?? null; | ||
| // Probe before trusting the record. A broker that was killed rather than shut down leaves | ||
| // its session behind, and connecting to that endpoint surfaces ENOENT/ECONNREFUSED as a | ||
| // failure of whatever the caller was doing — an authentication check, most visibly — | ||
| // rather than as a broker that is simply gone. Discard it and fall through to spawning. | ||
| // Probe before trusting the record. A broker that was killed rather than shut down leaves | ||
| // its session behind, and connecting to that endpoint surfaces ENOENT/ECONNREFUSED as a | ||
| // failure of whatever the caller was doing — an authentication check, most visibly — | ||
| // rather than as a broker that is simply gone. Fall through to spawning instead. | ||
| // | ||
| // The record itself is left alone: `status` and `setup` report a recorded shared runtime | ||
| // whether or not it answers, and reclaiming a dead broker's files belongs to | ||
| // `ensureBrokerSession`, which is the path that actually replaces it. | ||
| const persisted = loadBrokerSession(cwd)?.endpoint ?? null; | ||
| if (persisted && (await isBrokerEndpointReady(persisted))) { | ||
| brokerEndpoint = persisted; | ||
| } | ||
| } | ||
| if (!brokerEndpoint && !options.reuseExistingBroker) { | ||
| const brokerSession = await ensureBrokerSession(cwd, { env: options.env }); | ||
|
|
@@ -348,7 +388,31 @@ export class CodexAppServerClient { | |
| const client = brokerEndpoint | ||
| ? new BrokerCodexAppServerClient(cwd, { ...options, brokerEndpoint }) | ||
| : new SpawnedCodexAppServerClient(cwd, options); | ||
| await client.initialize(); | ||
| try { | ||
| await client.initialize(); | ||
| } catch (error) { | ||
| // initialize() has usually already spawned the app-server, and with it every configured MCP | ||
| // server. The caller never receives this object, so this is the only chance to reclaim them. | ||
| // | ||
| // Bounded, because close() waits on the transport reporting its exit: an app-server that | ||
| // answered with an error but ignores EOF and SIGTERM would otherwise swallow the original | ||
| // failure entirely and leave the caller waiting forever. Whatever is still up after the | ||
| // grace gets killed outright. | ||
| await Promise.race([ | ||
| client.close().catch(() => {}), | ||
| new Promise((resolve) => { | ||
| setTimeout(resolve, CONNECT_CLEANUP_GRACE_MS).unref?.(); | ||
| }) | ||
| ]); | ||
| if (client.proc && client.proc.exitCode === null && !client.proc.killed) { | ||
| try { | ||
| client.proc.kill("SIGKILL"); | ||
|
Comment on lines
+407
to
+409
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a directly spawned app-server ignores Useful? React with 👍 / 👎. |
||
| } catch { | ||
| // Already gone. | ||
| } | ||
| } | ||
| throw error; | ||
| } | ||
| return client; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import process from "node:process"; | ||
|
|
||
| const DEFAULT_BROKER_IDLE_SHUTDOWN_MS = 10 * 60 * 1000; | ||
| const DEFAULT_BROKER_STARTUP_TIMEOUT_MS = 5 * 60 * 1000; | ||
| const DEFAULT_WORKER_TTL_MS = 24 * 60 * 60 * 1000; | ||
|
|
||
| /** `setTimeout` truncates anything larger to a 32-bit int, firing almost immediately instead. */ | ||
| const MAX_TIMEOUT_MS = 2 ** 31 - 1; | ||
|
|
||
| function readDurationMs(raw, fallback) { | ||
| if (typeof raw !== "string" || raw.trim() === "") { | ||
| return fallback; | ||
| } | ||
| const parsed = Number(raw.trim()); | ||
| if (!Number.isFinite(parsed) || parsed < 0) { | ||
| return fallback; | ||
| } | ||
| // Clamp rather than reject: an operator asking for 30 days means "effectively never", and | ||
| // letting that overflow into ~1ms would kill exactly what they meant to protect. | ||
| return Math.min(parsed, MAX_TIMEOUT_MS); | ||
| } | ||
|
|
||
| /** | ||
| * Schedule `onExpiry`, treating `0` as "no limit". | ||
| * | ||
| * Every limit here documents `0` as disabled, but `setTimeout(fn, 0)` means "next tick" — passing | ||
| * a disabled limit straight through would fire the guard immediately instead of never. Returns | ||
| * the timer, or `null` when disabled; pass that back to {@link disarmTimeout}. | ||
| */ | ||
| export function armTimeout(ms, onExpiry) { | ||
| if (!ms) { | ||
| return null; | ||
| } | ||
| const timer = setTimeout(onExpiry, ms); | ||
| // Never hold the event loop open on the guard alone. | ||
| timer.unref?.(); | ||
| return timer; | ||
| } | ||
|
|
||
| /** Counterpart to {@link armTimeout}; tolerates the `null` a disabled limit produces. */ | ||
| export function disarmTimeout(timer) { | ||
| if (timer) { | ||
| clearTimeout(timer); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * How long the broker may sit with no connected client before shutting itself down, taking its | ||
| * app-server (and every MCP server under it) with it. | ||
| * | ||
| * A broker outlives the client that spawned it, so without this it survives a crashed or | ||
| * timed-out SessionEnd hook and holds that whole tree alive indefinitely. `0` disables the timer. | ||
| */ | ||
| export function brokerIdleShutdownMs(env = process.env) { | ||
| return readDurationMs(env.CODEX_BROKER_IDLE_SHUTDOWN_MS, DEFAULT_BROKER_IDLE_SHUTDOWN_MS); | ||
| } | ||
|
|
||
| /** | ||
| * How long the broker may spend starting up before it gives up and takes its tree down. | ||
| * | ||
| * Until the broker is listening there is no idle timer, and the client that spawned it stops | ||
| * waiting after a couple of seconds without killing anything — so a wedged app-server or MCP | ||
| * startup would strand the whole tree permanently. The default is generous because a cold start | ||
| * legitimately spawns every configured MCP server. `0` disables the bound. | ||
| */ | ||
| export function brokerStartupTimeoutMs(env = process.env) { | ||
| return readDurationMs(env.CODEX_BROKER_STARTUP_TIMEOUT_MS, DEFAULT_BROKER_STARTUP_TIMEOUT_MS); | ||
| } | ||
|
|
||
| /** | ||
| * Ceiling on a detached background worker's wall-clock lifetime. | ||
| * | ||
| * The worker is deliberately detached so a background task survives the session that queued it, | ||
| * and its immediate parent exits right after enqueue — so there is no parent to watch and nothing | ||
| * else that reclaims it. This is a runaway guard, not a task deadline: the default is far longer | ||
| * than any real background task, and still reclaims the multi-day trees that prompted it. `0` | ||
| * disables the ceiling. | ||
| */ | ||
| export function workerTtlMs(env = process.env) { | ||
| return readDurationMs(env.CODEX_TASK_WORKER_TTL_MS, DEFAULT_WORKER_TTL_MS); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the task settles during the five-second post-TTL grace period,
runTrackedJob()can overwrite the recorded timeout with a successful terminal result, andstatus --waitmay observe that success and return beforebeforeKillchanges it back to failed. The newly added delayedbeforeKill: recordExpiryis fresh evidence that the earlier overwrite is only repaired at kill time; latch the expiry immediately so normal completion bookkeeping cannot publish a contradictory result during the grace window.Useful? React with 👍 / 👎.