fix(pty): fence signals at a superseded PTY, and pin why kill is exempt

A signal means "interrupt my pane", so delivering one to a PTY the pane
has already replaced is a misdirected interrupt. Fence it with the same
lock-step proof used for write and resize.

`pty:kill` stays deliberately unfenced and a test now pins that: a
superseded PTY is orphaned, and reclaiming it is exactly what the
orphan-cleanup callers ask for. Refusing there would break the operation
that reclaims leaked shells — the opposite of the intent.

The fence sits at the IPC boundary, above `tryGetProviderForPty`, so it
covers local, daemon and SSH rather than the local path alone.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-08 19:11:54 -07:00
co-authored by Orca
parent 0de5994be9
commit 980e5d846b
2 changed files with 17 additions and 1 deletions
@@ -80,7 +80,7 @@ describe('superseded PTY operation fence', () => {
describe('the fence is wired into every mutating handler', () => {
// The capability existed for months and was never called from these handlers.
// Pin the call site, not the capability — that is the failure this program hit.
it.each(['pty:write', 'pty:writeAccepted', 'pty:resize'])(
it.each(['pty:write', 'pty:writeAccepted', 'pty:resize', 'pty:signal'])(
'%s consults the fence',
async (channel) => {
const { readFileSync } = await import('node:fs')
@@ -93,4 +93,14 @@ describe('the fence is wired into every mutating handler', () => {
expect(source.slice(start, start + 700)).toContain('isSupersededPtyId')
}
)
// pty:kill is deliberately NOT fenced: a superseded PTY is orphaned, and
// reclaiming it is exactly what the orphan-cleanup callers ask for.
it('leaves pty:kill unfenced on purpose', async () => {
const { readFileSync } = await import('node:fs')
const source = readFileSync('src/main/ipc/pty.ts', 'utf-8')
const start = source.search(/ipcMain\.handle\(\s*'pty:kill'/)
expect(start).toBeGreaterThan(0)
expect(source.slice(start, start + 500)).not.toContain('isSupersededPtyId')
})
})
+6
View File
@@ -7257,6 +7257,12 @@ export function registerPtyHandlers(
ipcMain.removeAllListeners('pty:signal')
ipcMain.on('pty:signal', (_event, args: { id: string; signal: string }) => {
// Why fenced but pty:kill is not: a signal means "interrupt MY pane", so a
// superseded id is a misdirected interrupt. A kill on a superseded id is the
// opposite — that PTY is now orphaned and reclaiming it is the point.
if (isSupersededPtyId(args.id)) {
return
}
tryGetProviderForPty(args.id)
?.sendSignal(args.id, args.signal)
.catch(() => {})