Skip to content

Commit 87858c1

Browse files
NathanFlurryclaude
andcommitted
fix: CJS event loop pumping — track timers for _waitForActiveHandles
Timer callbacks (setTimeout/setInterval) use stream events, not pending bridge promises, so the V8 event loop never entered for CJS scripts with only timers. Fix: expose _getPendingTimerCount and _waitForTimerDrain from the timer system, and have _waitForActiveHandles check for pending timers in addition to active handles. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4f2c2bd commit 87858c1

3 files changed

Lines changed: 169 additions & 25 deletions

File tree

packages/nodejs/src/bridge/active-handles.ts

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -75,35 +75,61 @@ export function _unregisterHandle(id: string): void {
7575
}
7676

7777
/**
78-
* Wait for all active handles to complete.
79-
* Returns immediately if no handles are active.
78+
* Wait for all active handles and pending timers to complete.
79+
* Returns immediately if no handles are active and no timers are pending.
80+
*
81+
* Timers (setTimeout/setInterval) are tracked separately via _getPendingTimerCount
82+
* and _waitForTimerDrain exposed from the process bridge module. This ensures CJS
83+
* scripts that create timers don't exit before the timers fire.
8084
*/
8185
export function _waitForActiveHandles(): Promise<void> {
82-
if (_getActiveHandles().length === 0) {
86+
const getPendingTimerCount = (globalThis as Record<string, unknown>)
87+
._getPendingTimerCount as (() => number) | undefined;
88+
const waitForTimerDrain = (globalThis as Record<string, unknown>)
89+
._waitForTimerDrain as (() => Promise<void>) | undefined;
90+
91+
const hasHandles = _getActiveHandles().length > 0;
92+
const hasTimers =
93+
typeof getPendingTimerCount === "function" && getPendingTimerCount() > 0;
94+
95+
if (!hasHandles && !hasTimers) {
8396
return Promise.resolve();
8497
}
85-
return new Promise((resolve) => {
86-
let settled = false;
87-
const complete = () => {
88-
if (settled) {
89-
return;
90-
}
91-
settled = true;
92-
resolve();
93-
};
94-
_waitResolvers.push(complete);
95-
const poll = () => {
96-
if (settled) {
97-
return;
98-
}
99-
if (_getActiveHandles().length === 0) {
100-
complete();
101-
return;
102-
}
103-
setTimeout(poll, 10);
104-
};
105-
setTimeout(poll, 10);
106-
});
98+
99+
const promises: Promise<void>[] = [];
100+
101+
if (hasHandles) {
102+
promises.push(
103+
new Promise((resolve) => {
104+
let settled = false;
105+
const complete = () => {
106+
if (settled) {
107+
return;
108+
}
109+
settled = true;
110+
resolve();
111+
};
112+
_waitResolvers.push(complete);
113+
const poll = () => {
114+
if (settled) {
115+
return;
116+
}
117+
if (_getActiveHandles().length === 0) {
118+
complete();
119+
return;
120+
}
121+
setTimeout(poll, 10);
122+
};
123+
setTimeout(poll, 10);
124+
}),
125+
);
126+
}
127+
128+
if (hasTimers && typeof waitForTimerDrain === "function") {
129+
promises.push(waitForTimerDrain());
130+
}
131+
132+
return Promise.all(promises).then(() => {});
107133
}
108134

109135
/**

packages/nodejs/src/bridge/process.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,6 +1365,39 @@ class TimerHandle {
13651365
}
13661366

13671367
const _timerEntries = new Map<number, TimerEntry>();
1368+
let _timerDrainResolvers: Array<() => void> = [];
1369+
1370+
/**
1371+
* Check if all timers have been drained and resolve any waiters.
1372+
* Called after a timer fires or is cleared.
1373+
*/
1374+
function checkTimerDrain(): void {
1375+
if (_timerEntries.size === 0 && _timerDrainResolvers.length > 0) {
1376+
const resolvers = _timerDrainResolvers;
1377+
_timerDrainResolvers = [];
1378+
resolvers.forEach((r) => r());
1379+
}
1380+
}
1381+
1382+
/**
1383+
* Returns the number of pending timer entries (setTimeout + setInterval).
1384+
* Used by _waitForActiveHandles to detect pending async work.
1385+
*/
1386+
function _getPendingTimerCount(): number {
1387+
return _timerEntries.size;
1388+
}
1389+
1390+
/**
1391+
* Returns a Promise that resolves when all timer entries have been drained.
1392+
* If no timers are pending, resolves immediately.
1393+
*/
1394+
function _waitForTimerDrain(): Promise<void> {
1395+
if (_timerEntries.size === 0) return Promise.resolve();
1396+
return new Promise((resolve) => {
1397+
_timerDrainResolvers.push(resolve);
1398+
});
1399+
}
1400+
13681401
const _nextTickQueue: NextTickEntry[] = [];
13691402
let _nextTickScheduled = false;
13701403

@@ -1426,6 +1459,8 @@ function timerDispatch(_eventType: string, payload: unknown): void {
14261459
if (entry.repeat && _timerEntries.has(timerId)) {
14271460
armKernelTimer(timerId);
14281461
}
1462+
1463+
checkTimerDrain();
14291464
}
14301465

14311466
export function setTimeout(
@@ -1456,6 +1491,7 @@ export function clearTimeout(timer: TimerHandle | number | undefined): void {
14561491
_timerEntries.delete(id);
14571492
}
14581493
bridgeDispatchSync<void>(TIMER_DISPATCH.clear, id);
1494+
checkTimerDrain();
14591495
}
14601496

14611497
export function setInterval(
@@ -1482,6 +1518,8 @@ export function clearInterval(timer: TimerHandle | number | undefined): void {
14821518
}
14831519

14841520
exposeCustomGlobal("_timerDispatch", timerDispatch);
1521+
exposeCustomGlobal("_getPendingTimerCount", _getPendingTimerCount);
1522+
exposeCustomGlobal("_waitForTimerDrain", _waitForTimerDrain);
14851523

14861524
export function setImmediate(
14871525
callback: (...args: unknown[]) => void,

packages/nodejs/test/kernel-runtime.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,4 +686,84 @@ describe('Node RuntimeDriver', () => {
686686
expect(uniquePids.size).toBe(12);
687687
});
688688
});
689+
690+
describe('CJS event loop pumping', () => {
691+
let kernel: Kernel;
692+
693+
afterEach(async () => {
694+
await kernel?.dispose();
695+
});
696+
697+
it('setTimeout callback fires before CJS script exits', async () => {
698+
const vfs = new SimpleVFS();
699+
kernel = createKernel({ filesystem: vfs as any });
700+
await kernel.mount(createNodeRuntime());
701+
702+
const chunks: Uint8Array[] = [];
703+
const proc = kernel.spawn('node', ['-e', `
704+
setTimeout(() => {
705+
console.log("TIMER_FIRED");
706+
process.exit(0);
707+
}, 100);
708+
`], {
709+
onStdout: (data) => chunks.push(data),
710+
});
711+
712+
const code = await proc.wait();
713+
const output = chunks.map(c => new TextDecoder().decode(c)).join('');
714+
expect(code).toBe(0);
715+
expect(output).toContain('TIMER_FIRED');
716+
});
717+
718+
it('async main with console.log produces output', async () => {
719+
const vfs = new SimpleVFS();
720+
kernel = createKernel({ filesystem: vfs as any });
721+
await kernel.mount(createNodeRuntime());
722+
723+
const chunks: Uint8Array[] = [];
724+
const proc = kernel.spawn('node', ['-e', `
725+
async function main() {
726+
console.log("ASYNC_HELLO");
727+
}
728+
main();
729+
`], {
730+
onStdout: (data) => chunks.push(data),
731+
});
732+
733+
const code = await proc.wait();
734+
const output = chunks.map(c => new TextDecoder().decode(c)).join('');
735+
expect(code).toBe(0);
736+
expect(output).toContain('ASYNC_HELLO');
737+
});
738+
739+
it('chained setTimeout callbacks all execute', async () => {
740+
const vfs = new SimpleVFS();
741+
kernel = createKernel({ filesystem: vfs as any });
742+
await kernel.mount(createNodeRuntime());
743+
744+
const chunks: Uint8Array[] = [];
745+
const proc = kernel.spawn('node', ['-e', `
746+
let count = 0;
747+
function step() {
748+
count++;
749+
console.log("STEP:" + count);
750+
if (count < 3) {
751+
setTimeout(step, 50);
752+
} else {
753+
process.exit(0);
754+
}
755+
}
756+
setTimeout(step, 50);
757+
`], {
758+
onStdout: (data) => chunks.push(data),
759+
});
760+
761+
const code = await proc.wait();
762+
const output = chunks.map(c => new TextDecoder().decode(c)).join('');
763+
expect(code).toBe(0);
764+
expect(output).toContain('STEP:1');
765+
expect(output).toContain('STEP:2');
766+
expect(output).toContain('STEP:3');
767+
});
768+
});
689769
});

0 commit comments

Comments
 (0)