fix: wait for PTY startup readiness

This commit is contained in:
NI0317
2026-07-21 16:12:42 +08:00
parent 58cde5103a
commit 85ac747208
6 changed files with 39 additions and 12 deletions

View File

@@ -6,7 +6,7 @@ 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 current session-level sandbox override is resolved at spawn and remains fixed for the PTY lifetime.
Linux readiness combines a private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the prompt marker plus silence/timeout because it has no `/proc` syscall surface. Unrecognized or unreadable process state is never a positive exact-idle signal.
Linux readiness combines a private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the prompt marker plus silence/timeout because it has no `/proc` syscall surface. 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.
## Model Experience

View File

@@ -148,6 +148,7 @@ export class LocalPtySession implements PtyBackendSession {
private activeTimer: NodeJS.Timeout | undefined
private activeAbort: (() => void) | undefined
private promptSeen = false
private initializing = false
private lastOutputAt = Date.now()
private closePromise: Promise<void> | undefined
@@ -171,13 +172,19 @@ export class LocalPtySession implements PtyBackendSession {
/**
* Capture startup output through the same readiness contract as later sends.
* @param signal - optional cancellation while the shell reaches its first prompt.
* @returns Resolves after startup readiness; rejects if the shell exits.
* @returns Resolves after startup readiness; rejects on exit or readiness timeout.
*/
async initialize(signal?: AbortSignal): Promise<void> {
const operation = this.startSend({ text: '', submit: false, ...signal !== undefined ? { signal } : {} })
const result = await operation.done
if (result.waitReason === 'session_exit') throw new Error('PTY shell exited during startup')
this.motd = result.viewport
this.initializing = true
try {
const operation = this.startSend({ text: '', submit: false, ...signal !== undefined ? { signal } : {} })
const result = await operation.done
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
} finally {
this.initializing = false
}
}
startSend(request: PtySendRequest): PtySendOperation {
@@ -289,14 +296,15 @@ export class LocalPtySession implements PtyBackendSession {
return
}
const elapsed = Date.now() - operation.startedAt
if (elapsed >= this.config.exactProbeAfterMs) {
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
if (startupHasOutput && elapsed >= this.config.exactProbeAfterMs) {
const pgid = this.inspector.foregroundPgid(this.pid)
if (pgid !== undefined && this.inspector.isStdinWaiting(pgid)) {
this.settleActive('stdin_read')
return
}
}
if (Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) {
if (startupHasOutput && Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) {
this.settleActive('inferred_idle')
return
}

View File

@@ -219,6 +219,25 @@ describe('LocalPtySession readiness and output', () => {
expect(cancellable.cancel()).toBe(true)
await expect(cancellable.done).rejects.toThrow('write failed')
})
it('does not treat zero-output startup silence as readiness and fails on startup timeout', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config())
let settled = false
const initializing = session.initialize().then(() => { settled = true })
await vi.advanceTimersByTimeAsync(60)
expect(settled).toBe(false)
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await initializing
const timeoutTerminal = new FakeTerminal()
const timeout = new LocalPtySession(timeoutTerminal.asPty(), new FakeInspector(), config())
const timedOut = expect(timeout.initialize()).rejects.toThrow('startup timeout')
await vi.advanceTimersByTimeAsync(100)
await timedOut
})
})
describe('LocalPtySession bounds, signals, and teardown', () => {