Skip to content

Commit 0b49d7a

Browse files
authored
fix: prevent orphaned processes via stdin/ppid/onclose lifecycle guards (#77)
* fix: prevent orphaned processes via stdin/ppid/onclose lifecycle guards Adds 4 missing lifecycle guards to main() that caused orphaned node processes to accumulate (~27GB RAM from 41 orphans on a 32GB machine): - stdin end/close listeners: detect client exit via pipe closure - server.onclose: handle graceful MCP protocol disconnect - PPID polling (5s, unref'd): cross-platform parent death detection - SIGHUP handler: terminal close on Unix + Windows console close * fix: address review — filter ESRCH, hoist cleanup registration - PPID poll: only exit on ESRCH (process gone), ignore EPERM (process alive but different UID — setuid, Linux security modules, etc.) - Hoist stopAllWatchers + exit/signal listeners to before stdin/onclose handlers, closing the race window where process.exit() could fire during initProject() before the cleanup listener was registered
1 parent a814b24 commit 0b49d7a

File tree

1 file changed

+48
-17
lines changed

1 file changed

+48
-17
lines changed

src/index.ts

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1612,9 +1612,57 @@ async function main() {
16121612
}
16131613
}
16141614

1615+
// Parent death guard — catches SIGKILL, crashes, terminal close on ALL platforms.
1616+
// process.kill(pid, 0) throws ESRCH when the process no longer exists.
1617+
const parentPid = process.ppid;
1618+
if (parentPid > 1) {
1619+
const parentGuard = setInterval(() => {
1620+
try {
1621+
process.kill(parentPid, 0);
1622+
} catch (err: unknown) {
1623+
// ESRCH = process gone → exit. EPERM = process alive, different UID → ignore.
1624+
if ((err as NodeJS.ErrnoException).code === 'ESRCH') {
1625+
process.exit(0);
1626+
}
1627+
}
1628+
}, 5_000);
1629+
parentGuard.unref();
1630+
}
1631+
16151632
const transport = new StdioServerTransport();
16161633
await server.connect(transport);
16171634

1635+
// Register cleanup before any handler that calls process.exit(), so the
1636+
// exit listener is always in place when stdin/onclose/signals fire.
1637+
const stopAllWatchers = () => {
1638+
for (const project of getAllProjects()) {
1639+
project.stopWatcher?.();
1640+
}
1641+
};
1642+
1643+
process.once('exit', stopAllWatchers);
1644+
process.once('SIGINT', () => {
1645+
stopAllWatchers();
1646+
process.exit(0);
1647+
});
1648+
process.once('SIGTERM', () => {
1649+
stopAllWatchers();
1650+
process.exit(0);
1651+
});
1652+
process.once('SIGHUP', () => {
1653+
stopAllWatchers();
1654+
process.exit(0);
1655+
});
1656+
1657+
// Detect stdin pipe closure — the primary signal that the MCP client is gone.
1658+
// StdioServerTransport only listens for 'data'/'error', never 'end'.
1659+
process.stdin.on('end', () => process.exit(0));
1660+
process.stdin.on('close', () => process.exit(0));
1661+
1662+
// Handle graceful MCP protocol-level disconnect.
1663+
// Fires after SDK internal cleanup when transport.close() is called.
1664+
server.onclose = () => process.exit(0);
1665+
16181666
if (process.env.CODEBASE_CONTEXT_DEBUG) console.error('[DEBUG] Server ready');
16191667

16201668
await refreshKnownRootsFromClient();
@@ -1634,23 +1682,6 @@ async function main() {
16341682
/* best-effort */
16351683
}
16361684
});
1637-
1638-
// Cleanup all watchers on exit
1639-
const stopAllWatchers = () => {
1640-
for (const project of getAllProjects()) {
1641-
project.stopWatcher?.();
1642-
}
1643-
};
1644-
1645-
process.once('exit', stopAllWatchers);
1646-
process.once('SIGINT', () => {
1647-
stopAllWatchers();
1648-
process.exit(0);
1649-
});
1650-
process.once('SIGTERM', () => {
1651-
stopAllWatchers();
1652-
process.exit(0);
1653-
});
16541685
}
16551686

16561687
// Export server components for programmatic use

0 commit comments

Comments
 (0)