fix(pty): contain Windows ConPTY input failures

This commit is contained in:
Neil
2026-08-31 05:43:04 -07:00
parent 43f384324f
commit 21981d0fdc
6 changed files with 113 additions and 12 deletions
+49
View File
@@ -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();
}
+3 -3
View File
@@ -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
+2 -9
View File
@@ -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<void> {
// 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
@@ -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)
}
)
})
@@ -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)
}
@@ -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<void>((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 {}
}
})
})