fix: wait for PTY startup readiness
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user