Skip to content

Commit 779bed7

Browse files
NathanFlurryclaude
andcommitted
fix: destroy V8 session on process kill to prevent dangling IPC socket
When a spawned process was killed, the V8 session's execute() promise remained pending with its handler registered in sessionHandlers, keeping the IPC socket ref'd and preventing Node.js from exiting. Two fixes: 1. Track the current V8 session in NodeExecutionDriver and destroy it during terminate()/dispose() to unregister the session handler and unref the IPC socket. 2. Close streaming stdin source on process kill so pending reads resolve instead of hanging forever. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a28840c commit 779bed7

3 files changed

Lines changed: 76 additions & 0 deletions

File tree

packages/nodejs/src/execution-driver.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,6 +772,8 @@ export class NodeExecutionDriver implements RuntimeDriver {
772772
private configuredMaxTimers?: number;
773773
private configuredMaxHandles?: number;
774774
private pid?: number;
775+
// Track the current V8 session so it can be destroyed on terminate/dispose
776+
private _currentSession: V8Session | null = null;
775777

776778
constructor(options: NodeExecutionDriverOptions) {
777779
this.memoryLimit = options.memoryLimit ?? 128;
@@ -1250,6 +1252,9 @@ export class NodeExecutionDriver implements RuntimeDriver {
12501252
bindingKeys,
12511253
);
12521254

1255+
// Track session so terminate/dispose can destroy it
1256+
this._currentSession = session;
1257+
12531258
// Execute in V8 session
12541259
const result = await session.execute({
12551260
bridgeCode,
@@ -1381,6 +1386,7 @@ export class NodeExecutionDriver implements RuntimeDriver {
13811386
exports: undefined as T,
13821387
};
13831388
} finally {
1389+
this._currentSession = null;
13841390
await session.destroy().catch(() => {});
13851391
this.finalizeExecutionState(finalExitCode);
13861392
}
@@ -1389,6 +1395,11 @@ export class NodeExecutionDriver implements RuntimeDriver {
13891395
dispose(): void {
13901396
if (this.disposed) return;
13911397
this.disposed = true;
1398+
// Destroy V8 session to unregister handler and unref IPC socket
1399+
if (this._currentSession) {
1400+
this._currentSession.destroy().catch(() => {});
1401+
this._currentSession = null;
1402+
}
13921403
killActiveChildProcesses(this.state);
13931404
clearActiveHostTimers(this.state);
13941405
if (this.pid !== undefined) {
@@ -1398,6 +1409,11 @@ export class NodeExecutionDriver implements RuntimeDriver {
13981409

13991410
async terminate(): Promise<void> {
14001411
if (this.disposed) return;
1412+
// Destroy V8 session to unregister handler and unref IPC socket
1413+
if (this._currentSession) {
1414+
await this._currentSession.destroy().catch(() => {});
1415+
this._currentSession = null;
1416+
}
14011417
killActiveChildProcesses(this.state);
14021418
const closers = Array.from(this.state.activeHttpServerClosers.values());
14031419
await Promise.allSettled(closers.map((close) => close()));

packages/nodejs/src/kernel-runtime.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,10 @@ class NodeRuntimeDriver implements RuntimeDriver {
565565
if (exitResolved) return;
566566
const normalizedSignal = signal > 0 ? signal : 15;
567567
killedSignal = normalizedSignal;
568+
// Close streaming stdin so pending reads resolve
569+
if (ctx.streamStdin) {
570+
streamCloseStdin();
571+
}
568572
const driver = this._activeDrivers.get(ctx.pid);
569573
if (!driver) {
570574
reportKilledExit(normalizedSignal);

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,4 +880,60 @@ describe('Node RuntimeDriver', () => {
880880
expect(output).toContain('direct node script');
881881
});
882882
});
883+
884+
describe('dispose cleanup (no dangling handles)', () => {
885+
it('kernel.dispose after killing a streamStdin process leaves no active handles', async () => {
886+
const k = createKernel({ filesystem: new SimpleVFS() });
887+
await k.mount(createNodeRuntime());
888+
889+
// Write a long-running stdin reader script
890+
await k.writeFile('/tmp/reader.mjs', new TextEncoder().encode(
891+
`process.stdin.setEncoding('utf8');\n` +
892+
`process.stdin.on('data', (d) => process.stdout.write('GOT:' + d));\n`
893+
));
894+
895+
const chunks: Uint8Array[] = [];
896+
const proc = k.spawn('node', ['/tmp/reader.mjs'], {
897+
streamStdin: true,
898+
onStdout: (data) => chunks.push(data),
899+
});
900+
901+
// Send data and verify it's received
902+
proc.writeStdin('hello\n');
903+
await new Promise(r => setTimeout(r, 200));
904+
const output = chunks.map(c => new TextDecoder().decode(c)).join('');
905+
expect(output).toContain('GOT:hello');
906+
907+
// Kill the process
908+
proc.kill();
909+
const code = await proc.wait();
910+
expect(code).toBe(143); // SIGTERM = 128 + 15
911+
912+
// Dispose the kernel — if the IPC socket is still ref'd, vitest hangs
913+
await k.dispose();
914+
}, 10_000);
915+
916+
it('kernel.dispose after killing a streamStdin process closes stdin source', async () => {
917+
const k = createKernel({ filesystem: new SimpleVFS() });
918+
await k.mount(createNodeRuntime());
919+
920+
// Write a script that blocks on stdin
921+
await k.writeFile('/tmp/blocker.mjs', new TextEncoder().encode(
922+
`process.stdin.resume();\n` +
923+
`process.stdin.on('data', () => {});\n`
924+
));
925+
926+
const proc = k.spawn('node', ['/tmp/blocker.mjs'], {
927+
streamStdin: true,
928+
});
929+
930+
// Kill immediately — stdin source should be closed
931+
proc.kill();
932+
const code = await proc.wait();
933+
expect(code).toBe(143);
934+
935+
await k.dispose();
936+
// Test passes if it completes without hanging
937+
}, 10_000);
938+
});
883939
});

0 commit comments

Comments
 (0)