fix(pty): preserve startup cancellation and zombie cleanup
This commit is contained in:
@@ -6,9 +6,9 @@ Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the
|
||||
|
||||
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
|
||||
|
||||
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
|
||||
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
|
||||
|
||||
Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity left the process table while the shell can still reap it and only then stops the shell. A survivor failure does not cache a permanently rejected close; a later close retries the teardown.
|
||||
Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ProcessInspector {
|
||||
isStdinWaiting(pgid: number): boolean
|
||||
/** Return the root and its current transitive descendants, children first. */
|
||||
processTree(rootPid: number): ProcessIdentity[]
|
||||
/** Return whether the exact identity remains a non-quiescent process. */
|
||||
isAlive(identity: ProcessIdentity): boolean
|
||||
signalGroup(pgid: number, signal: PtySignal): void
|
||||
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void
|
||||
@@ -49,6 +50,7 @@ interface ProcStat {
|
||||
parentPid: number
|
||||
pgrp: number
|
||||
session: number
|
||||
state: string
|
||||
tpgid: number
|
||||
started: string
|
||||
}
|
||||
@@ -64,13 +66,15 @@ export function parseProcStat(text: string): ProcStat | undefined {
|
||||
if (open <= 0 || close <= open) return undefined
|
||||
const pid = Number(text.slice(0, open).trim())
|
||||
const rest = text.slice(close + 2).trim().split(/\s+/)
|
||||
const state = rest[0] || ''
|
||||
const parentPid = Number(rest[1])
|
||||
const pgrp = Number(rest[2])
|
||||
const session = Number(rest[3])
|
||||
const tpgid = Number(rest[5])
|
||||
const started = rest[19]
|
||||
if (![pid, parentPid, pgrp, session, tpgid].every(Number.isSafeInteger) || started === undefined) return undefined
|
||||
return { pid, parentPid, pgrp, session, tpgid, started }
|
||||
if (![pid, parentPid, pgrp, session, tpgid].every(Number.isSafeInteger)
|
||||
|| state.length !== 1 || started === undefined) return undefined
|
||||
return { pid, parentPid, pgrp, session, state, tpgid, started }
|
||||
}
|
||||
|
||||
function readLinuxStat(internals: ProcessInspectorInternals, pid: number): ProcStat | undefined {
|
||||
@@ -269,7 +273,8 @@ class LinuxProcessInspector extends PosixProcessInspector {
|
||||
}
|
||||
|
||||
isAlive(identity: ProcessIdentity): boolean {
|
||||
return readLinuxStat(this.internals, identity.pid)?.started === identity.started
|
||||
const stat = readLinuxStat(this.internals, identity.pid)
|
||||
return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -186,6 +186,9 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
|
||||
if (result.waitReason === 'timeout') throw new Error('PTY shell did not reach readiness before startup timeout')
|
||||
this.motd = result.viewport
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
throw error
|
||||
} finally {
|
||||
this.initializing = false
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import { createProcessInspector, parseProcStat } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
|
||||
import type { ProcessInspectorInternals } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
|
||||
|
||||
function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1): string {
|
||||
const rest = ['S', String(parentPid), String(pgrp), String(session), '99', String(tpgid)]
|
||||
function stat(pid: number, pgrp: number, session: number, tpgid: number, started: string, parentPid = 1, state = 'S'): string {
|
||||
const rest = [state, String(parentPid), String(pgrp), String(session), '99', String(tpgid)]
|
||||
while (rest.length < 19) rest.push('0')
|
||||
rest.push(started)
|
||||
return `${pid} (command with space) ${rest.join(' ')}`
|
||||
@@ -65,8 +65,10 @@ function fakeInternals() {
|
||||
describe('Linux process inspector', () => {
|
||||
it('parses stat safely, captures only the rooted process tree, and signals identities', () => {
|
||||
expect(parseProcStat('bad')).toBeUndefined()
|
||||
expect(parseProcStat('1 () ')).toBeUndefined()
|
||||
expect(parseProcStat('1 () S')).toBeUndefined()
|
||||
expect(parseProcStat(stat(10, 20, 30, 40, '500'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, tpgid: 40, started: '500' })
|
||||
expect(parseProcStat(stat(10, 20, 30, 40, '500', 1, 'SS'))).toBeUndefined()
|
||||
expect(parseProcStat(stat(10, 20, 30, 40, '500'))).toEqual({ pid: 10, parentPid: 1, pgrp: 20, session: 30, state: 'S', tpgid: 40, started: '500' })
|
||||
|
||||
const fake = fakeInternals()
|
||||
fake.dirs.set('/proc', ['x', '10', '11', '12', '13', '14'])
|
||||
@@ -90,6 +92,10 @@ describe('Linux process inspector', () => {
|
||||
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM')
|
||||
inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL')
|
||||
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
|
||||
fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500', 1, 'Z'))
|
||||
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(false)
|
||||
inspector.signalProcess({ pid: 10, started: '500' }, 'SIGKILL')
|
||||
expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']])
|
||||
})
|
||||
|
||||
it('detects read, select, poll, and epoll waits across non-leader threads', () => {
|
||||
|
||||
@@ -254,6 +254,21 @@ describe('LocalPtySession readiness and output', () => {
|
||||
await timedOut
|
||||
})
|
||||
|
||||
it('preserves the caller abort reason when startup cannot resolve a foreground group', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.pgid = undefined
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('startup cancelled')
|
||||
|
||||
const initializing = session.initialize(controller.signal)
|
||||
const rejected = expect(initializing).rejects.toBe(reason)
|
||||
controller.abort(reason)
|
||||
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('waits for printable prompt text when the startup marker is split from PS1', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
|
||||
Reference in New Issue
Block a user