@@ -8,6 +8,12 @@ import { MessageChannel, receiveMessageOnPort, Worker } from 'node:worker_thread
88import type { UsagePoolSignal , UsageSignal , WindowSignal } from '@ccm/engine' ;
99
1010const 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
1218interface 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
79101const flag = new Int32Array(workerData.sab);
80102const 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;
82110let 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
94203function 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+ });
106232child.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