Skip to content

Commit 9fb83f7

Browse files
committed
Merge PR openai#490: Stop orphaned Codex companion brokers (idle-timeout + shutdown-path corrections)
2 parents ff9702c + da16256 commit 9fb83f7

4 files changed

Lines changed: 199 additions & 3 deletions

File tree

plugins/codex/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Stop an orphaned shared Codex app-server broker after 15 minutes with no connected clients. Active foreground and background jobs keep their broker connection open, and the timeout can be configured with `CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS` (`0` disables the safety timer).
6+
- Close the broker listener before asynchronous child cleanup and safely reject reconnects already queued during shutdown.
7+
38
## 1.0.0
49

510
- Initial version of the Codex plugin for Claude Code

plugins/codex/scripts/app-server-broker.mjs

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@ import { BROKER_BUSY_RPC_CODE, CodexAppServerClient } from "./lib/app-server.mjs
1010
import { parseBrokerEndpoint } from "./lib/broker-endpoint.mjs";
1111

1212
const STREAMING_METHODS = new Set(["turn/start", "review/start", "thread/compact/start"]);
13+
const BROKER_IDLE_TIMEOUT_ENV = "CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS";
14+
const DEFAULT_BROKER_IDLE_TIMEOUT_MS = 15 * 60 * 1000;
15+
16+
function resolveIdleTimeoutMs(env = process.env) {
17+
const rawValue = env[BROKER_IDLE_TIMEOUT_ENV];
18+
if (rawValue == null || rawValue === "") {
19+
return DEFAULT_BROKER_IDLE_TIMEOUT_MS;
20+
}
21+
22+
const parsed = Number(rawValue);
23+
if (!Number.isFinite(parsed) || parsed < 0) {
24+
return DEFAULT_BROKER_IDLE_TIMEOUT_MS;
25+
}
26+
return Math.floor(parsed);
27+
}
1328

1429
function buildStreamThreadIds(method, params, result) {
1530
const threadIds = new Set();
@@ -70,6 +85,16 @@ async function main() {
7085
let activeStreamSocket = null;
7186
let activeStreamThreadIds = null;
7287
const sockets = new Set();
88+
const idleTimeoutMs = resolveIdleTimeoutMs();
89+
let idleTimer = null;
90+
let shuttingDown = false;
91+
92+
function cancelIdleShutdown() {
93+
if (idleTimer) {
94+
clearTimeout(idleTimer);
95+
idleTimer = null;
96+
}
97+
}
7398

7499
function clearSocketOwnership(socket) {
75100
if (activeRequestSocket === socket) {
@@ -100,11 +125,20 @@ async function main() {
100125
}
101126

102127
async function shutdown(server) {
128+
if (shuttingDown) {
129+
return;
130+
}
131+
shuttingDown = true;
132+
cancelIdleShutdown();
133+
// Stop accepting connections before awaiting child cleanup. Otherwise a
134+
// reconnect can slip into the async shutdown window and inherit a closing
135+
// app-server client.
136+
const serverClosed = new Promise((resolve) => server.close(resolve));
103137
for (const socket of sockets) {
104138
socket.end();
105139
}
106140
await appClient.close().catch(() => {});
107-
await new Promise((resolve) => server.close(resolve));
141+
await serverClosed;
108142
if (listenTarget.kind === "unix" && fs.existsSync(listenTarget.path)) {
109143
fs.unlinkSync(listenTarget.path);
110144
}
@@ -113,9 +147,33 @@ async function main() {
113147
}
114148
}
115149

150+
function scheduleIdleShutdown(server) {
151+
cancelIdleShutdown();
152+
if (shuttingDown || idleTimeoutMs === 0 || sockets.size > 0 || activeRequestSocket || activeStreamSocket) {
153+
return;
154+
}
155+
156+
idleTimer = setTimeout(async () => {
157+
idleTimer = null;
158+
if (sockets.size > 0 || activeRequestSocket || activeStreamSocket) {
159+
return;
160+
}
161+
await shutdown(server);
162+
process.exit(0);
163+
}, idleTimeoutMs);
164+
}
165+
116166
appClient.setNotificationHandler(routeNotification);
117167

118168
const server = net.createServer((socket) => {
169+
if (shuttingDown) {
170+
// An already-accepted connection event can be delivered after
171+
// server.close(). Reject it decisively and absorb a simultaneous reset.
172+
socket.on("error", () => {});
173+
socket.destroy();
174+
return;
175+
}
176+
cancelIdleShutdown();
119177
sockets.add(socket);
120178
socket.setEncoding("utf8");
121179
let buffer = "";
@@ -232,11 +290,13 @@ async function main() {
232290
socket.on("close", () => {
233291
sockets.delete(socket);
234292
clearSocketOwnership(socket);
293+
scheduleIdleShutdown(server);
235294
});
236295

237296
socket.on("error", () => {
238297
sockets.delete(socket);
239298
clearSocketOwnership(socket);
299+
scheduleIdleShutdown(server);
240300
});
241301
});
242302

@@ -250,7 +310,9 @@ async function main() {
250310
process.exit(0);
251311
});
252312

253-
server.listen(listenTarget.path);
313+
server.listen(listenTarget.path, () => {
314+
scheduleIdleShutdown(server);
315+
});
254316
}
255317

256318
main().catch((error) => {

tests/broker-idle.test.mjs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import fs from "node:fs";
2+
import net from "node:net";
3+
import path from "node:path";
4+
import test from "node:test";
5+
import assert from "node:assert/strict";
6+
import { once } from "node:events";
7+
import { spawn } from "node:child_process";
8+
import { fileURLToPath } from "node:url";
9+
10+
import { buildEnv, installFakeCodex } from "./fake-codex-fixture.mjs";
11+
import { makeTempDir } from "./helpers.mjs";
12+
import { sendBrokerShutdown, waitForBrokerEndpoint } from "../plugins/codex/scripts/lib/broker-lifecycle.mjs";
13+
14+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
15+
const BROKER_SCRIPT = path.join(ROOT, "plugins", "codex", "scripts", "app-server-broker.mjs");
16+
const IDLE_TIMEOUT_ENV = "CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS";
17+
18+
function spawnTestBroker({ idleTimeoutMs }) {
19+
const workspace = makeTempDir();
20+
const binDir = makeTempDir();
21+
const sessionDir = makeTempDir("codex-plugin-broker-");
22+
const socketPath = path.join(sessionDir, "broker.sock");
23+
const endpoint = `unix:${socketPath}`;
24+
const pidFile = path.join(sessionDir, "broker.pid");
25+
installFakeCodex(binDir);
26+
27+
const child = spawn(
28+
process.execPath,
29+
[BROKER_SCRIPT, "serve", "--endpoint", endpoint, "--cwd", workspace, "--pid-file", pidFile],
30+
{
31+
cwd: workspace,
32+
env: {
33+
...buildEnv(binDir),
34+
[IDLE_TIMEOUT_ENV]: String(idleTimeoutMs)
35+
},
36+
stdio: ["ignore", "pipe", "pipe"]
37+
}
38+
);
39+
40+
return { child, endpoint, pidFile, socketPath };
41+
}
42+
43+
async function waitForExit(child, timeoutMs = 3000) {
44+
if (child.exitCode != null || child.signalCode != null) {
45+
return;
46+
}
47+
await Promise.race([
48+
once(child, "exit"),
49+
new Promise((_, reject) => setTimeout(() => reject(new Error("Timed out waiting for broker to exit.")), timeoutMs))
50+
]);
51+
}
52+
53+
function terminateBroker(child) {
54+
if (child.exitCode == null && child.signalCode == null) {
55+
child.kill("SIGTERM");
56+
}
57+
}
58+
59+
test("broker exits and removes runtime files after its last client stays disconnected", async (t) => {
60+
const broker = spawnTestBroker({ idleTimeoutMs: 150 });
61+
t.after(() => terminateBroker(broker.child));
62+
63+
assert.equal(await waitForBrokerEndpoint(broker.endpoint), true);
64+
await waitForExit(broker.child);
65+
66+
assert.equal(broker.child.exitCode, 0);
67+
assert.equal(fs.existsSync(broker.socketPath), false);
68+
assert.equal(fs.existsSync(broker.pidFile), false);
69+
});
70+
71+
test("broker idle shutdown waits until a connected client disconnects", async (t) => {
72+
const broker = spawnTestBroker({ idleTimeoutMs: 150 });
73+
t.after(() => terminateBroker(broker.child));
74+
75+
assert.equal(await waitForBrokerEndpoint(broker.endpoint), true);
76+
const socket = net.createConnection({ path: broker.socketPath });
77+
await once(socket, "connect");
78+
79+
await new Promise((resolve) => setTimeout(resolve, 350));
80+
assert.equal(broker.child.exitCode, null);
81+
assert.equal(broker.child.signalCode, null);
82+
83+
socket.end();
84+
await once(socket, "close");
85+
await waitForExit(broker.child);
86+
assert.equal(broker.child.exitCode, 0);
87+
});
88+
89+
test("broker idle timeout can be disabled", async (t) => {
90+
const broker = spawnTestBroker({ idleTimeoutMs: 0 });
91+
t.after(() => terminateBroker(broker.child));
92+
93+
assert.equal(await waitForBrokerEndpoint(broker.endpoint), true);
94+
await new Promise((resolve) => setTimeout(resolve, 350));
95+
assert.equal(broker.child.exitCode, null);
96+
assert.equal(broker.child.signalCode, null);
97+
98+
terminateBroker(broker.child);
99+
await waitForExit(broker.child);
100+
});
101+
102+
test("broker shuts down cleanly while new clients race to connect", async (t) => {
103+
const broker = spawnTestBroker({ idleTimeoutMs: 0 });
104+
t.after(() => terminateBroker(broker.child));
105+
106+
assert.equal(await waitForBrokerEndpoint(broker.endpoint), true);
107+
const reconnects = Array.from(
108+
{ length: 50 },
109+
() =>
110+
new Promise((resolve) => {
111+
const socket = net.createConnection({ path: broker.socketPath });
112+
const timer = setTimeout(() => socket.destroy(), 1000);
113+
const finish = () => {
114+
clearTimeout(timer);
115+
resolve();
116+
};
117+
socket.on("connect", () => socket.end());
118+
socket.on("error", finish);
119+
socket.on("close", finish);
120+
})
121+
);
122+
123+
await Promise.all([sendBrokerShutdown(broker.endpoint), ...reconnects]);
124+
await waitForExit(broker.child);
125+
assert.equal(broker.child.exitCode, 0);
126+
});

tests/fake-codex-fixture.mjs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,9 @@ export function buildEnv(binDir) {
749749
const sep = process.platform === "win32" ? ";" : ":";
750750
return {
751751
...process.env,
752-
PATH: `${binDir}${sep}${process.env.PATH}`
752+
PATH: `${binDir}${sep}${process.env.PATH}`,
753+
// Production keeps an idle broker warm for 15 minutes. Tests only need a
754+
// brief reuse window and should not leave dozens of detached helpers.
755+
CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS: "2000"
753756
};
754757
}

0 commit comments

Comments
 (0)