fix(relay): keep a revived pane whose pid only refuses the liveness probe

`revive` gated each serialized pane on a hand-rolled `process.kill(pid, 0)` in a
bare try/catch, so any refusal retired the pane. EPERM means the process exists
under another uid; only ESRCH is evidence of absence.

The file already imports `isProcessAlive`, whose ESRCH-only contract
`reapPtyProvenExited` documents 450 lines earlier -- this call site just did not
use it. Reuse it rather than keeping a second implementation of the same
concept. Malformed pids still skip, as before.

See docs/reference/ssh-execution-boundary.md.
This commit is contained in:
Neil
2026-09-11 04:49:30 -07:00
parent 95c77502d2
commit 9596160394
2 changed files with 35 additions and 4 deletions
+31
View File
@@ -518,6 +518,37 @@ describe('PtyHandler', () => {
expect(JSON.parse(live).map((entry: { id: string }) => entry.id)).toEqual(['pty-21'])
})
// Why: the pid gate is the one place revive turns an observation into "this pane is
// finished". `kill(pid, 0)` answers EPERM when the process exists under another uid, and
// the same ESRCH-only rule `reapPtyProvenExited` applies has to hold here
// (docs/reference/ssh-execution-boundary.md).
it('keeps a pane whose pid refuses the probe and drops only a proven-gone one', async () => {
const state = JSON.stringify([
{ id: 'pty-30', pid: 424242, cols: 80, rows: 24, cwd: LIVE_CWD },
{ id: 'pty-31', pid: 434343, cols: 80, rows: 24, cwd: LIVE_CWD }
])
const killSpy = vi.spyOn(process, 'kill').mockImplementation((pid) => {
if (pid === 424242) {
throw Object.assign(new Error('kill EPERM'), { code: 'EPERM' })
}
if (pid === 434343) {
throw Object.assign(new Error('kill ESRCH'), { code: 'ESRCH' })
}
return true
})
try {
await dispatcher.callRequest('pty.revive', { state })
} finally {
killSpy.mockRestore()
}
expect(mockPtySpawn).toHaveBeenCalledTimes(1)
const live = (await dispatcher.callRequest('pty.serialize', {
ids: ['pty-30', 'pty-31']
})) as string
expect(JSON.parse(live).map((entry: { id: string }) => entry.id)).toEqual(['pty-30'])
})
describe('a Windows relay reviving a WSL pane', () => {
const worktreeId = 'r::/remote/wsl-worktree'
const historyFile = join(
+4 -4
View File
@@ -2907,10 +2907,10 @@ export class PtyHandler {
if (this.ptys.has(entry.id) || this.pendingReviveIds.has(entry.id)) {
continue
}
// Only re-attach if the original process is still alive
try {
process.kill(entry.pid, 0)
} catch {
// Only re-attach if the host proves the original process is still there. `isProcessAlive`
// is ESRCH-only for the same reason `reapPtyProvenExited` is: a refusal this host cannot
// resolve is unverifiable, not absence (docs/reference/ssh-execution-boundary.md).
if (!Number.isInteger(entry.pid) || entry.pid <= 0 || !isProcessAlive(entry.pid)) {
continue
}
const ownedPath = entry.worktreeId