Skip to content

Commit a21ac1e

Browse files
PanQiWeiclaude
andauthored
fix(ccm): reap Codex quota collector process trees (#215)
* fix(ccm): reap Codex quota collector process trees * chore(ccm): record monitor reap changeset Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 91e23d0 commit a21ac1e

4 files changed

Lines changed: 413 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3232
count; at 46 modules it had roughly 11 tokens of headroom and any new module
3333
overflowed it.
3434

35+
### Fixed
36+
37+
- `ccm monitor` machine-wide Codex quota polling now owns the app-server process
38+
group and waits for launcher close plus full tree disappearance before
39+
publishing a result. Timeout and error paths escalate from `SIGTERM` to
40+
`SIGKILL`, preventing one unreaped `MainThread` zombie from accumulating per
41+
monitor tick.
42+
3543
## [0.22.0] — 2026-07-23
3644

3745
> **Noncommercial license boundary release** — the first cc-master plugin
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'ccm': patch
3+
---
4+
5+
修复 `ccm monitor` machine-wide Codex quota 轮询的进程回收竞态:collector 现在拥有独立的 app-server 进程组,超时或错误时按 `SIGTERM``SIGKILL` 升级清理,并等待 launcher close 与完整进程树消失后才发布结果,避免每轮监控累积一个未回收的 `MainThread` 僵尸进程。

ccm/apps/cli/src/codex-rate-limits.ts

Lines changed: 145 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ import { MessageChannel, receiveMessageOnPort, Worker } from 'node:worker_thread
88
import type { UsagePoolSignal, UsageSignal, WindowSignal } from '@ccm/engine';
99

1010
const DEFAULT_TIMEOUT_MS = 10_000;
11+
const WORKER_STARTUP_ALLOWANCE_MS = 1_000;
12+
const TERMINATION_GRACE_MS = 100;
13+
// Caller-side observation budget after escalation. A timed-out Worker remains unref'ed so it can
14+
// still own and reap the child instead of recreating the zombie leak this boundary prevents.
15+
const REAP_TIMEOUT_MS = 1_000;
16+
const REAP_POLL_MS = 5;
1117

1218
interface RateLimitWindow {
1319
usedPercent?: unknown;
@@ -41,29 +47,45 @@ export function readCodexUsageSignal(
4147
const flag = new Int32Array(sab);
4248
const { port1, port2 } = new MessageChannel();
4349
let worker: Worker | null = null;
50+
let workerChildReaped = false;
4451
try {
4552
worker = new Worker(WORKER_SOURCE, {
4653
eval: true,
4754
workerData: {
4855
codexBin,
4956
env,
5057
timeoutMs,
58+
terminationGraceMs: TERMINATION_GRACE_MS,
59+
reapPollMs: REAP_POLL_MS,
5160
sab,
5261
port: port2,
5362
},
5463
transferList: [port2],
5564
});
56-
Atomics.wait(flag, 0, 0, timeoutMs + 1000);
65+
const waitResult = Atomics.wait(
66+
flag,
67+
0,
68+
0,
69+
timeoutMs + WORKER_STARTUP_ALLOWANCE_MS + TERMINATION_GRACE_MS + REAP_TIMEOUT_MS,
70+
);
71+
if (waitResult === 'timed-out') return null;
5772
const msg = receiveMessageOnPort(port1)?.message as
58-
| { ok?: boolean; result?: unknown }
73+
| { ok?: boolean; reaped?: boolean; result?: unknown }
5974
| undefined;
75+
if (msg?.reaped !== true) return null;
76+
workerChildReaped = true;
6077
if (!msg?.ok) return null;
6178
return normalizeCodexRateLimits(msg.result);
6279
} catch {
6380
return null;
6481
} finally {
6582
try {
66-
worker?.terminate();
83+
if (workerChildReaped) {
84+
void worker?.terminate();
85+
} else {
86+
// terminate() before child `close` discards libuv's wait/reap handle. Let cleanup finish.
87+
worker?.unref();
88+
}
6789
} catch {
6890
/* ignore */
6991
}
@@ -78,31 +100,135 @@ const { workerData } = await import('node:worker_threads');
78100
79101
const flag = new Int32Array(workerData.sab);
80102
const port = workerData.port;
81-
let done = false;
103+
const ownsProcessGroup = process.platform !== 'win32';
104+
let child = null;
105+
let terminalPayload = null;
106+
let cleanupStarted = false;
107+
let launcherClosed = false;
108+
let treeGone = false;
109+
let published = false;
82110
let buffer = '';
111+
let responseTimer = null;
112+
let terminationTimer = null;
113+
let reapPollTimer = null;
83114
84-
function finish(payload) {
85-
if (done) return;
86-
done = true;
87-
try { port.postMessage(payload); } catch {}
115+
// Publishing the RPC payload and reaping the owned process tree are separate phases. The parent
116+
// receives no terminal message until both launcher close and complete tree disappearance hold.
117+
function clearTimer(timer) {
118+
if (timer) clearTimeout(timer);
119+
}
120+
121+
function processMissing(error) {
122+
return error && typeof error === 'object' && error.code === 'ESRCH';
123+
}
124+
125+
function observeTreeGone() {
126+
if (treeGone) return true;
127+
if (!child || !Number.isSafeInteger(child.pid) || child.pid <= 0) {
128+
treeGone = launcherClosed;
129+
return treeGone;
130+
}
131+
if (!ownsProcessGroup) {
132+
treeGone = launcherClosed;
133+
return treeGone;
134+
}
135+
try {
136+
process.kill(-child.pid, 0);
137+
return false;
138+
} catch (error) {
139+
if (processMissing(error)) treeGone = true;
140+
return treeGone;
141+
}
142+
}
143+
144+
function signalOwnedTree(signal) {
145+
if (!child || !Number.isSafeInteger(child.pid) || child.pid <= 0) {
146+
treeGone = launcherClosed;
147+
return false;
148+
}
149+
try {
150+
if (ownsProcessGroup) {
151+
process.kill(-child.pid, signal);
152+
return true;
153+
}
154+
const signaled = child.kill(signal);
155+
if (!signaled && launcherClosed) treeGone = true;
156+
return signaled;
157+
} catch (error) {
158+
if (processMissing(error)) treeGone = true;
159+
return false;
160+
}
161+
}
162+
163+
function publishReaped() {
164+
if (published || !terminalPayload) return;
165+
published = true;
166+
clearTimer(responseTimer);
167+
clearTimer(terminationTimer);
168+
clearTimer(reapPollTimer);
169+
try { port.postMessage({ ...terminalPayload, reaped: true }); } catch {}
88170
Atomics.store(flag, 0, 1);
89171
Atomics.notify(flag, 0);
90-
try { child.stdin.end(); } catch {}
91-
try { child.kill(); } catch {}
172+
try { port.close(); } catch {}
173+
}
174+
175+
function maybePublishReaped() {
176+
if (!cleanupStarted || !launcherClosed || !observeTreeGone()) return false;
177+
publishReaped();
178+
return true;
179+
}
180+
181+
function pollForReap() {
182+
if (published || maybePublishReaped()) return;
183+
clearTimer(reapPollTimer);
184+
reapPollTimer = setTimeout(pollForReap, workerData.reapPollMs);
185+
}
186+
187+
function requestFinish(payload) {
188+
if (cleanupStarted) return;
189+
cleanupStarted = true;
190+
terminalPayload = payload;
191+
clearTimer(responseTimer);
192+
responseTimer = null;
193+
try { child?.stdin.end(); } catch {}
194+
signalOwnedTree('SIGTERM');
195+
terminationTimer = setTimeout(() => {
196+
if (published || maybePublishReaped()) return;
197+
signalOwnedTree('SIGKILL');
198+
maybePublishReaped();
199+
}, workerData.terminationGraceMs);
200+
pollForReap();
92201
}
93202
94203
function write(msg) {
95-
try { child.stdin.write(JSON.stringify(msg) + '\\n'); } catch { finish({ ok: false }); }
204+
try { child.stdin.write(JSON.stringify(msg) + '\\n'); } catch { requestFinish({ ok: false }); }
96205
}
97206
98-
const child = spawn(workerData.codexBin, ['app-server', '--stdio'], {
99-
env: { ...process.env, ...workerData.env },
100-
stdio: ['pipe', 'pipe', 'ignore'],
101-
});
207+
try {
208+
child = spawn(workerData.codexBin, ['app-server', '--stdio'], {
209+
detached: ownsProcessGroup,
210+
env: { ...process.env, ...workerData.env },
211+
stdio: ['pipe', 'pipe', 'ignore'],
212+
});
213+
} catch {
214+
launcherClosed = true;
215+
treeGone = true;
216+
requestFinish({ ok: false });
217+
}
102218
103-
const timer = setTimeout(() => finish({ ok: false }), workerData.timeoutMs);
104-
child.once('error', () => finish({ ok: false }));
105-
child.once('exit', () => finish({ ok: false }));
219+
if (!child) {
220+
maybePublishReaped();
221+
return;
222+
}
223+
224+
responseTimer = setTimeout(() => requestFinish({ ok: false }), workerData.timeoutMs);
225+
child.once('error', () => requestFinish({ ok: false }));
226+
child.once('exit', () => requestFinish({ ok: false }));
227+
child.once('close', () => {
228+
launcherClosed = true;
229+
if (!cleanupStarted) requestFinish({ ok: false });
230+
maybePublishReaped();
231+
});
106232
child.stdout.on('data', (chunk) => {
107233
buffer += chunk.toString('utf8');
108234
let nl = buffer.indexOf('\\n');
@@ -123,8 +249,7 @@ function handleLine(line) {
123249
return;
124250
}
125251
if (msg.id === 6) {
126-
clearTimeout(timer);
127-
finish(msg.error ? { ok: false } : { ok: true, result: msg.result });
252+
requestFinish(msg.error ? { ok: false } : { ok: true, result: msg.result });
128253
}
129254
}
130255

0 commit comments

Comments
 (0)