Skip to content

Commit 59aeadd

Browse files
imcodes-winclaude
andcommitted
fix(windows): daemon must NEVER die from uncaught errors
Three independent bugs that combined to crash the daemon on Windows: 1. **Duplicate uncaughtException handlers** — lifecycle.ts registered a handler that called shutdown(1), index.ts registered a 'stays alive' handler. Node calls ALL listeners, so the shutdown handler always ran and the keep-alive log was just decoration. Daemon died on every uncaught exception. Fix: removed the lifecycle.ts handler. Single global handler in index.ts. 2. **Stays-alive handler registered AFTER startup()** — node-pty can throw synchronously from socket event callbacks during startup (e.g. Cannot resize a pty that has already exited). If that fires before line 125 of index.ts, no handler is installed yet and the daemon dies on Node's default uncaughtException behavior. Fix: moved the global handlers to the very top of index.ts (lines 3-30), BEFORE any imports. Now the handler is in place before any module-load-time side effect can fire. 3. **conptyResize crashed on already-exited PTY** — node-pty's resize() throws synchronously if the PTY has exited but the close event hasn't propagated yet. This was hitting ConPTY sessions during normal teardown. Fix: skip resize when session.exited is true; wrap the call in try/catch and mark exited if node-pty throws anyway. Verified end-to-end on Windows: daemon survived multiple terminal resize events that previously killed it. PID 1404180 still running. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 96eda9b commit 59aeadd

File tree

3 files changed

+43
-12
lines changed

3 files changed

+43
-12
lines changed

src/agent/conpty.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,17 @@ export function conptySubscribe(name: string, callback: (data: string) => void):
324324
export function conptyResize(name: string, cols: number, rows: number): void {
325325
const session = sessions.get(name);
326326
if (!session) return;
327-
session.pty.resize(cols, rows);
327+
if (session.exited) return; // pty already exited — resize would throw
328+
try {
329+
session.pty.resize(cols, rows);
330+
} catch (err) {
331+
// node-pty throws "Cannot resize a pty that has already exited" if the
332+
// exit event hasn't propagated yet. Swallow it instead of letting it
333+
// become an uncaught exception that takes the daemon down.
334+
logger.debug({ name, err: err instanceof Error ? err.message : String(err) }, 'conptyResize: ignored error on dead pty');
335+
session.exited = true;
336+
return;
337+
}
328338
session.cols = cols;
329339
session.rows = rows;
330340
}

src/daemon/lifecycle.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -767,10 +767,10 @@ function setupSignalHandlers(): void {
767767
const handler = () => shutdown(0);
768768
process.on('SIGTERM', handler);
769769
process.on('SIGINT', handler);
770-
process.on('uncaughtException', (err) => {
771-
logger.error({ err }, 'Uncaught exception');
772-
shutdown(1);
773-
});
770+
// NOTE: uncaughtException / unhandledRejection are handled in src/index.ts
771+
// with a "daemon stays alive" policy. We must NOT register a shutdown
772+
// handler here — that would override the keep-alive behavior and let any
773+
// stray error take the daemon down.
774774
}
775775

776776
export function getDaemonContext(): DaemonContext {

src/index.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,31 @@
11
#!/usr/bin/env node
2+
// ── GLOBAL ERROR HANDLERS — registered FIRST, before anything else ────────
3+
// The daemon must NEVER die from an unhandled error. node-pty in particular
4+
// throws synchronously from socket event handlers (e.g. resize on a dead pty)
5+
// which propagates as uncaughtException. These handlers MUST be installed
6+
// before any module imports that might trigger such errors.
7+
process.on('uncaughtException', (err) => {
8+
// Use console.error if logger isn't loaded yet, otherwise use the logger.
9+
try {
10+
// eslint-disable-next-line @typescript-eslint/no-require-imports
11+
const log = require('./util/logger.js').default;
12+
log.error({ err }, 'Uncaught exception — daemon stays alive');
13+
} catch {
14+
// eslint-disable-next-line no-console
15+
console.error('Uncaught exception (logger not loaded):', err);
16+
}
17+
});
18+
process.on('unhandledRejection', (err) => {
19+
try {
20+
// eslint-disable-next-line @typescript-eslint/no-require-imports
21+
const log = require('./util/logger.js').default;
22+
log.error({ err }, 'Unhandled rejection — daemon stays alive');
23+
} catch {
24+
// eslint-disable-next-line no-console
25+
console.error('Unhandled rejection (logger not loaded):', err);
26+
}
27+
});
28+
229
import { Command } from 'commander';
330
// These modules are imported lazily to avoid eager tmux backend detection on Windows.
431
// Commands like `bind` don't need tmux/conpty and shouldn't crash when node-pty is missing.
@@ -103,13 +130,7 @@ program
103130
throw err; // re-throw other startup errors
104131
}
105132
// Called by launchd/systemd plist/unit — run inline.
106-
// Global error handlers: daemon must NEVER crash from unhandled errors.
107-
process.on('uncaughtException', (err) => {
108-
logger.error({ err }, 'Uncaught exception — daemon stays alive');
109-
});
110-
process.on('unhandledRejection', (err) => {
111-
logger.error({ err }, 'Unhandled rejection — daemon stays alive');
112-
});
133+
// Global error handlers are registered at the top of this file.
113134
logger.info('Daemon running. Press Ctrl+C to stop.');
114135
await new Promise(() => {});
115136
return;

0 commit comments

Comments
 (0)