From 21981d0fdc20901e6b7635f1e64fe4a7901366ba Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:13:18 -0700 Subject: [PATCH] fix(pty): contain Windows ConPTY input failures --- config/patches/node-pty@1.1.0.patch | 49 +++++++++++++++++++ pnpm-lock.yaml | 6 +-- src/main/daemon/daemon-entry.ts | 11 +---- .../daemon-native-pty-exception.test.ts | 20 ++++++++ .../daemon/daemon-native-pty-exception.ts | 9 ++++ ...node-pty-windows-input-error.win32.test.ts | 30 ++++++++++++ 6 files changed, 113 insertions(+), 12 deletions(-) create mode 100644 src/main/daemon/daemon-native-pty-exception.test.ts create mode 100644 src/main/daemon/daemon-native-pty-exception.ts create mode 100644 src/main/daemon/node-pty-windows-input-error.win32.test.ts diff --git a/config/patches/node-pty@1.1.0.patch b/config/patches/node-pty@1.1.0.patch index f8ddaae9104..81db24b078b 100644 --- a/config/patches/node-pty@1.1.0.patch +++ b/config/patches/node-pty@1.1.0.patch @@ -789,3 +789,52 @@ index 7b286d3d644c26141df516929703aa6e129df4b2..ec6bf3932c65b89c013ff133dc6bf46a return exports; }; +diff --git a/lib/windowsTerminal.js b/lib/windowsTerminal.js +index 3c38f89d14cece8081782a9600ebc582b7c2a62a..7cdfdf75c7396998cf93d135c5de6ef96668ceba 100644 +--- a/lib/windowsTerminal.js ++++ b/lib/windowsTerminal.js +@@ -103,6 +103,20 @@ var WindowsTerminal = /** @class */ (function (_super) { + _this._name = name; + _this._readable = true; + _this._writable = true; ++ // A ConPTY input-pipe error must retire only this terminal. Without a listener, Node promotes ++ // errors such as write EAGAIN to uncaughtException and kills every PTY in the daemon. ++ _this._agent.inSocket.on('error', function () { ++ if (!_this._writable) { ++ return; ++ } ++ _this._close(); ++ try { ++ _this._agent.kill(); ++ } ++ catch (_a) { ++ // The failing pipe may have raced process exit; the terminal is already unwritable. ++ } ++ }); + _this._forwardEvents(); + return _this; + } +diff --git a/src/windowsTerminal.ts b/src/windowsTerminal.ts +index 13f6c6dbb3d7965c4699568f4704be3b6a33d947..0aacd793831ccfc9ff5c85f0117d87a7534b5d34 100644 +--- a/src/windowsTerminal.ts ++++ b/src/windowsTerminal.ts +@@ -115,5 +115,18 @@ export class WindowsTerminal extends Terminal { + this._readable = true; + this._writable = true; +- ++ // A ConPTY input-pipe error must retire only this terminal. Without a listener, Node promotes ++ // errors such as write EAGAIN to uncaughtException and kills every PTY in the daemon. ++ this._agent.inSocket.on('error', () => { ++ if (!this._writable) { ++ return; ++ } ++ this._close(); ++ try { ++ this._agent.kill(); ++ } catch { ++ // The failing pipe may have raced process exit; the terminal is already unwritable. ++ } ++ }); ++ + this._forwardEvents(); + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73be03378d2..e6ecbfe9a16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,7 +115,7 @@ patchedDependencies: '@xterm/addon-webgl@0.20.0-beta.299': 94687e89a0115e6e6aa102837f986debdc029c091527ee5eb4a4e17ceaf9473e '@xterm/xterm@6.1.0-beta.303': 98756bcedc402bcdb7c6ab7b015d2e59cd18e97b03a2c06a27e95bb3ba429d9d lint-staged@16.4.0: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673 - node-pty@1.1.0: 9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17 + node-pty@1.1.0: 9f6143903f8a8da310dd8a668dff51a83eb32a598cfe512c5e004156c6abb62c importers: @@ -156,7 +156,7 @@ importers: version: 3.3.1 node-pty: specifier: ^1.1.0 - version: 1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17) + version: 1.1.0(patch_hash=9f6143903f8a8da310dd8a668dff51a83eb32a598cfe512c5e004156c6abb62c) posthog-node: specifier: ^5.33.3 version: 5.33.3 @@ -12194,7 +12194,7 @@ snapshots: node-int64@0.4.0: {} - node-pty@1.1.0(patch_hash=9a2eedbf2448b8ff1387a8a740ee5e4e968bf8484d7d80e12a3f6a2dc26b0e17): + node-pty@1.1.0(patch_hash=9f6143903f8a8da310dd8a668dff51a83eb32a598cfe512c5e004156c6abb62c): dependencies: node-addon-api: 7.1.1 diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 72537f64cbf..10ddff3b886 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -25,6 +25,7 @@ import { MacosLoginSessionDeathWatch } from './macos-login-session-death-watch' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' import { readCurrentDaemonReadyIdentity } from './daemon-ready-identity' import { publishDaemonPidFile } from './daemon-spawner' +import { isNativePtyException } from './daemon-native-pty-exception' export type ParsedDaemonArgs = { socketPath: string @@ -149,15 +150,7 @@ async function main(): Promise { // crash the daemon — masking those would hide real issues. process.on('uncaughtException', (err) => { const msg = err?.message ?? '' - const isNativeError = - err?.name === 'Error' && - (msg.includes('pty') || - msg.includes('Pty') || - msg.includes('EIO') || - msg.includes('EPIPE') || - msg.includes('EBADF') || - msg.includes('ENXIO')) - if (isNativeError) { + if (isNativePtyException(err)) { daemonLog.log('uncaught-exception-suppressed', { name: err?.name, message: msg }) console.error('[daemon] Native PTY exception (suppressed):', err) return diff --git a/src/main/daemon/daemon-native-pty-exception.test.ts b/src/main/daemon/daemon-native-pty-exception.test.ts new file mode 100644 index 00000000000..0e1f07b589b --- /dev/null +++ b/src/main/daemon/daemon-native-pty-exception.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { isNativePtyException } from './daemon-native-pty-exception' + +describe('isNativePtyException', () => { + it.each([ + new Error('write EAGAIN'), + new Error('read EIO'), + new Error('Pty process exited'), + Object.assign(new Error('write failed'), { code: 'EPIPE' }) + ])('contains native PTY failures without killing the daemon', (error) => { + expect(isNativePtyException(error)).toBe(true) + }) + + it.each([new Error('database invariant failed'), new TypeError('logic bug'), 'EAGAIN'])( + 'does not suppress unrelated or malformed failures', + (error) => { + expect(isNativePtyException(error)).toBe(false) + } + ) +}) diff --git a/src/main/daemon/daemon-native-pty-exception.ts b/src/main/daemon/daemon-native-pty-exception.ts new file mode 100644 index 00000000000..47a34972281 --- /dev/null +++ b/src/main/daemon/daemon-native-pty-exception.ts @@ -0,0 +1,9 @@ +const NATIVE_PTY_ERROR_CODE_PATTERN = /\b(?:EIO|EPIPE|EBADF|ENXIO|EAGAIN)\b/ + +export function isNativePtyException(error: unknown): boolean { + if (!(error instanceof Error) || error.name !== 'Error') { + return false + } + const code = (error as NodeJS.ErrnoException).code + return /\bpty\b/i.test(error.message) || NATIVE_PTY_ERROR_CODE_PATTERN.test(code ?? error.message) +} diff --git a/src/main/daemon/node-pty-windows-input-error.win32.test.ts b/src/main/daemon/node-pty-windows-input-error.win32.test.ts new file mode 100644 index 00000000000..4c4fb1cc7c8 --- /dev/null +++ b/src/main/daemon/node-pty-windows-input-error.win32.test.ts @@ -0,0 +1,30 @@ +import type { Socket } from 'node:net' +import { spawn, type IPty } from 'node-pty' +import { describe, expect, it } from 'vitest' + +type WindowsPtyInternals = IPty & { + _agent: { inSocket: Socket } +} + +describe.skipIf(process.platform !== 'win32')('node-pty Windows input errors', () => { + it('retires only the failed PTY when its ConPTY input pipe emits EAGAIN', async () => { + const terminal = spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/q'], { + cwd: process.cwd(), + env: process.env + }) + const input = (terminal as WindowsPtyInternals)._agent.inSocket + const exited = new Promise((resolve) => terminal.onExit(() => resolve())) + + try { + expect(input.listenerCount('error')).toBeGreaterThan(0) + expect(() => + input.emit('error', Object.assign(new Error('write EAGAIN'), { code: 'EAGAIN' })) + ).not.toThrow() + await exited + } finally { + try { + terminal.kill() + } catch {} + } + }) +})