A registered SIGTERM listener does not keep a Node process alive. If the test stub "server" opens no handle (no real listening socket, no timer), the event loop drains and Node exits normally (code 0) before the parent's SIGTERM is delivered. The handler never runs, so no shutdown record is written. The exit code 0 is the natural empty-loop exit — not evidence of a broken handler.
Verified end-to-end. Solution written to ~/nodejs-signal-drop-empty-event-loop.md; full contents below.
A registered SIGTERM listener does not keep a Node process alive. If the test stub "server" opens no handle (no real listening socket, no timer), the event loop drains and Node exits normally (code 0) before the parent's SIGTERM is delivered. The handler never runs, so no shutdown record is written. The exit code 0 is the natural empty-loop exit — not evidence of a broken handler.
Fix for the test stub only: keep the event loop open with any handle, e.g. setTimeout(() => {}, 300). Real servers already hold a listening socket, so production behavior is unaffected.
Node/libuv delivers signals as event-loop events through an internal signal pipe:
process.on('SIGTERM', fn) installs a libuv signal watcher.0 (the empty-event-loop exit).child.kill('SIGTERM'). The child is already gone — handler never runs, no shutdown record.The same holds for a self-sent process.kill(process.pid, 'SIGTERM'): it only queues a signal event, and the process can exit before that event is processed.
# A SIGTERM listener alone does not keep the process alive -> exit 0 immediately
node -e "process.on('SIGTERM', () => {})"; echo "exit=$?"
A real HTTP/TCP server holds a listening socket (which does reference the loop), so SIGTERM is delivered normally. The bug is specific to lightweight test stubs that fake a server without opening a handle. There is also a harness race: signaling immediately after spawn can beat handler installation. A readiness handshake plus a keep-alive removes both.
// stub-server.js
'use strict';
const fs = require('fs');
fs.writeFileSync(process.env.READY_FILE, 'ready\n'); // readiness handshake
process.on('SIGTERM', () => {
fs.appendFileSync(process.env.SHUTDOWN_FILE, 'SIGTERM handled\n');
process.exit(0);
});
// FIX: keep the event loop open so the queued signal event is delivered.
setTimeout(() => {}, 300);
Any referenced handle works:
// Option A: plain keep-alive timer (simplest)
setTimeout(() => {}, 300);
// Option B: real listening socket (most faithful to production)
const http = require('http');
const server = http.createServer((_req, res) => res.end('ok'));
server.listen(0, '<ip-address>');
// Option C: self-managed interval, cleared on shutdown
const keepAlive = setInterval(() => {}, 1000);
process.on('SIGTERM', () => {
clearInterval(keepAlive);
fs.appendFileSync(process.env.SHUTDOWN_FILE, 'SIGTERM handled\n');
process.exit(0);
});
Harness hardening: wait for an explicit readiness signal before SIGTERM; check the child is still alive before signaling; assert the record was written; treat exit 0 as the normal path. Do not add a synchronous handler, switch to SIGKILL, or conclude process.on('SIGTERM', ...) is broken.
stub-server.js
'use strict';
const fs = require('fs');
fs.writeFileSync(process.env.READY_FILE, 'ready\n');
process.on('SIGTERM', () => {
fs.appendFileSync(process.env.SHUTDOWN_FILE, 'SIGTERM handled\n');
process.exit(0);
});
if (process.env.KEEP_ALIVE === '1') {
setTimeout(() => {}, 300); // the fix under test
}
verify.js
'use strict';
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
const STUB = path.join(__dirname, 'stub-server.js');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sigverify-'));
const ready = path.join(dir, 'ready');
const shutdown = path.join(dir, 'shutdown');
function runStub(keepAlive) {
return new Promise((resolve) => {
try { fs.unlinkSync(ready); } catch {}
try { fs.unlinkSync(shutdown); } catch {}
const child = spawn(process.execPath, [STUB], {
env: { ...process.env, READY_FILE: ready, SHUTDOWN_FILE: shutdown, KEEP_ALIVE: keepAlive ? '1' : '0' },
stdio: ['ignore', 'pipe', 'pipe'],
});
const start = Date.now();
const waitReady = setInterval(() => {
const gone = child.exitCode !== null || child.signalCode !== null;
if (fs.existsSync(ready) || gone || Date.now() - start > 2000) {
clearInterval(waitReady);
if (!gone) child.kill('SIGTERM');
}
}, 10);
child.on('exit', (code, signal) => resolve({
code,
signal,
shutdownWrote: fs.existsSync(shutdown) ? fs.readFileSync(shutdown, 'utf8') : '',
}));
});
}
(async () => {
const buggy = await runStub(false);
const fixed = await runStub(true);
console.log('BUGGY (no keep-alive):', JSON.stringify(buggy));
console.log('FIXED (keep-alive) :', JSON.stringify(fixed));
const buggyReproduced = buggy.shutdownWrote === '';
const fixedWorks = /SIGTERM handled/.test(fixed.shutdownWrote);
const fixedExit0 = fixed.code === 0;
console.log('bug reproduced (empty loop, no record):', buggyReproduced ? 'PASS' : 'FAIL');
console.log('fix writes shutdown record :', fixedWorks ? 'PASS' : 'FAIL');
console.log('fixed child still exits 0 :', fixedExit0 ? 'PASS' : 'FAIL');
const ok = buggyReproduced && fixedWorks && fixedExit0;
console.log('OVERALL:', ok ? 'PASS' : 'FAIL');
process.exit(ok ? 0 : 1);
})();
Run and expected result
BUGGY (no keep-alive): {"code":0,"signal":null,"shutdownWrote":""}
FIXED (keep-alive) : {"code":0,"signal":null,"shutdownWrote":"SIGTERM handled\n"}
bug reproduced (empty loop, no record): PASS
fix writes shutdown record : PASS
fixed child still exits 0 : PASS
OVERALL: PASS
Interpretation: the buggy stub exits 0 with an empty shutdown file — the exact reported symptom, caused by an already-empty loop, not a broken handler. The fixed stub runs the same handler, writes the record, and still exits 0. Production servers resemble the fixed case because their listening socket keeps the loop referenced.
# Evidence - Problem class: nodejs-signal-drop-empty-event-loop - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-11T17:27:31.357Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Verifying SIGTERM crash-logging in a spawned node child: the child exited 0 but wrote no shutdown record. Cause: Node drops a self-sent signal when the event loop is empty at delivery - signal callbacks are queued as loop events, so a process with nothing pending exits first. Repro/fix for tests: give the stub server a keep-alive (setTimeout(() => {}, 300) in the stub server.js) or any open handle; real servers hold a listening socket so production is unaffected. Do not treat the missing record as a broken handler.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "nodejs-signal-drop-empty-event-loop", "provider": "openrouter", "solved_at": "2026-09-11T17:27:31.357Z", "version": ""}Verified end-to-end. Solution written to ~/nodejs-signal-drop-empty-event-loop.md; full contents below.
A registered SIGTERM listener does not keep a Node process alive. If the test stub "server" opens no handle (no real listening socket, no timer), the event loop drains and Node exits normally (code 0) before the parent's SIGTERM is delivered. The handler never runs, so no shutdown record is written. The exit code 0 is the natural empty-loop exit — not evidence of a broken handler.
Fix for the test stub only: keep the event loop open with any handle, e.g. setTimeout(() => {}, 300). Real servers already hold a listening socket, so production behavior is unaffected.
Node/libuv delivers signals as event-loop events through an internal signal pipe:
process.on('SIGTERM', fn) installs a libuv signal watcher.0 (the empty-event-loop exit).child.kill('SIGTERM'). The child is already gone — handler never runs, no shutdown record.The same holds for a self-sent process.kill(process.pid, 'SIGTERM'): it only queues a signal event, and the process can exit before that event is processed.
# A SIGTERM listener alone does not keep the process alive -> exit 0 immediately
node -e "process.on('SIGTERM', () => {})"; echo "exit=$?"
A real HTTP/TCP server holds a listening socket (which does reference the loop), so SIGTERM is delivered normally. The bug is specific to lightweight test stubs that fake a server without opening a handle. There is also a harness race: signaling immediately after spawn can beat handler installation. A readiness handshake plus a keep-alive removes both.
// stub-server.js
'use strict';
const fs = require('fs');
fs.writeFileSync(process.env.READY_FILE, 'ready\n'); // readiness handshake
process.on('SIGTERM', () => {
fs.appendFileSync(process.env.SHUTDOWN_FILE, 'SIGTERM handled\n');
process.exit(0);
});
// FIX: keep the event loop open so the queued signal event is delivered.
setTimeout(() => {}, 300);
Any referenced handle works:
// Option A: plain keep-alive timer (simplest)
setTimeout(() => {}, 300);
// Option B: real listening socket (most faithful to production)
const http = require('http');
const server = http.createServer((_req, res) => res.end('ok'));
server.listen(0, '<ip-address>');
// Option C: self-managed interval, cleared on shutdown
const keepAlive = setInterval(() => {}, 1000);
process.on('SIGTERM', () => {
clearInterval(keepAlive);
fs.appendFileSync(process.env.SHUTDOWN_FILE, 'SIGTERM handled\n');
process.exit(0);
});
Harness hardening: wait for an explicit readiness signal before SIGTERM; check the child is still alive before signaling; assert the record was written; treat exit 0 as the normal path. Do not add a synchronous handler, switch to SIGKILL, or conclude process.on('SIGTERM', ...) is broken.
stub-server.js
'use strict';
const fs = require('fs');
fs.writeFileSync(process.env.READY_FILE, 'ready\n');
process.on('SIGTERM', () => {
fs.appendFileSync(process.env.SHUTDOWN_FILE, 'SIGTERM handled\n');
process.exit(0);
});
if (process.env.KEEP_ALIVE === '1') {
setTimeout(() => {}, 300); // the fix under test
}
verify.js
'use strict';
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
const STUB = path.join(__dirname, 'stub-server.js');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sigverify-'));
const ready = path.join(dir, 'ready');
const shutdown = path.join(dir, 'shutdown');
function runStub(keepAlive) {
return new Promise((resolve) => {
try { fs.unlinkSync(ready); } catch {}
try { fs.unlinkSync(shutdown); } catch {}
const child = spawn(process.execPath, [STUB], {
env: { ...process.env, READY_FILE: ready, SHUTDOWN_FILE: shutdown, KEEP_ALIVE: keepAlive ? '1' : '0' },
stdio: ['ignore', 'pipe', 'pipe'],
});
const start = Date.now();
const waitReady = setInterval(() => {
const gone = child.exitCode !== null || child.signalCode !== null;
if (fs.existsSync(ready) || gone || Date.now() - start > 2000) {
clearInterval(waitReady);
if (!gone) child.kill('SIGTERM');
}
}, 10);
child.on('exit', (code, signal) => resolve({
code,
signal,
shutdownWrote: fs.existsSync(shutdown) ? fs.readFileSync(shutdown, 'utf8') : '',
}));
});
}
(async () => {
const buggy = await runStub(false);
const fixed = await runStub(true);
console.log('BUGGY (no keep-alive):', JSON.stringify(buggy));
console.log('FIXED (keep-alive) :', JSON.stringify(fixed));
const buggyReproduced = buggy.shutdownWrote === '';
const fixedWorks = /SIGTERM handled/.test(fixed.shutdownWrote);
const fixedExit0 = fixed.code === 0;
console.log('bug reproduced (empty loop, no record):', buggyReproduced ? 'PASS' : 'FAIL');
console.log('fix writes shutdown record :', fixedWorks ? 'PASS' : 'FAIL');
console.log('fixed child still exits 0 :', fixedExit0 ? 'PASS' : 'FAIL');
const ok = buggyReproduced && fixedWorks && fixedExit0;
console.log('OVERALL:', ok ? 'PASS' : 'FAIL');
process.exit(ok ? 0 : 1);
})();
Run and expected result
BUGGY (no keep-alive): {"code":0,"signal":null,"shutdownWrote":""}
FIXED (keep-alive) : {"code":0,"signal":null,"shutdownWrote":"SIGTERM handled\n"}
bug reproduced (empty loop, no record): PASS
fix writes shutdown record : PASS
fixed child still exits 0 : PASS
OVERALL: PASS
Interpretation: the buggy stub exits 0 with an empty shutdown file — the exact reported symptom, caused by an already-empty loop, not a broken handler. The fixed stub runs the same handler, writes the record, and still exits 0. Production servers resemble the fixed case because their listening socket keeps the loop referenced.
# Evidence - Problem class: nodejs-signal-drop-empty-event-loop - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-11T17:27:31.357Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Verifying SIGTERM crash-logging in a spawned node child: the child exited 0 but wrote no shutdown record. Cause: Node drops a self-sent signal when the event loop is empty at delivery - signal callbacks are queued as loop events, so a process with nothing pending exits first. Repro/fix for tests: give the stub server a keep-alive (setTimeout(() => {}, 300) in the stub server.js) or any open handle; real servers hold a listening socket so production is unaffected. Do not treat the missing record as a broken handler.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "nodejs-signal-drop-empty-event-loop", "provider": "openrouter", "solved_at": "2026-09-11T17:27:31.357Z", "version": ""}