mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
1296 lines
53 KiB
TypeScript
1296 lines
53 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||
import { PassThrough } from 'node:stream'
|
||
import { LocalPtySession } from '@deepseek-ai/dsh-terminal-bash/src/session.ts'
|
||
import type { ResolvedConfig } from '@deepseek-ai/dsh-terminal-bash/src/config.ts'
|
||
import type { TerminalSendOperation, TerminalSessionStatus, TerminalSignal } from '@deepseek-ai/dsh-terminal'
|
||
import type {
|
||
SubprocessOutcome,
|
||
SubprocessTerminalHandle,
|
||
SubprocessTerminalSignal,
|
||
} from '@deepseek-ai/dsh-subprocess'
|
||
import { TerminalError } from '@deepseek-ai/dsh-terminal'
|
||
import type {
|
||
ProcessIdentity,
|
||
ProcessInspector,
|
||
} from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
|
||
|
||
class FakeInspector implements ProcessInspector {
|
||
pgid: number | undefined = 456
|
||
waiting = false
|
||
members: ProcessIdentity[] = []
|
||
alive = new Set<number>()
|
||
groups: Array<[number, TerminalSignal]> = []
|
||
processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = []
|
||
throwGroup = false
|
||
throwProcess = false
|
||
removeOnSignal = true
|
||
|
||
foregroundPgid() { return this.pgid }
|
||
isStdinWaiting() { return this.waiting }
|
||
processTree() { return this.members }
|
||
processSession() { return [] }
|
||
isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
|
||
signalGroup(pgid: number, signal: TerminalSignal) {
|
||
if (this.throwGroup) throw new Error('group failed')
|
||
this.groups.push([pgid, signal])
|
||
}
|
||
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') {
|
||
if (this.throwProcess) throw new Error('process raced')
|
||
this.processes.push([identity.pid, signal])
|
||
if (this.removeOnSignal) this.alive.delete(identity.pid)
|
||
}
|
||
}
|
||
|
||
class FakeTerminal implements SubprocessTerminalHandle {
|
||
pid = 123
|
||
readonly output = new PassThrough()
|
||
readonly writes: string[] = []
|
||
readonly kills: string[] = []
|
||
readonly outcome = Promise.withResolvers<SubprocessOutcome>()
|
||
readonly done = this.outcome.promise
|
||
throwWrite = false
|
||
throwKill = false
|
||
autoExitOnKill = true
|
||
terminateError: Error | undefined
|
||
private cleanup: Promise<void> | undefined
|
||
|
||
constructor(public inspector = new FakeInspector()) {}
|
||
|
||
emitData(data: string): void {
|
||
this.output.write(Buffer.from(data, 'utf8'))
|
||
}
|
||
|
||
emitBytes(data: Uint8Array): void {
|
||
this.output.write(data)
|
||
}
|
||
|
||
emitError(error: Error): void {
|
||
this.output.emit('error', error)
|
||
}
|
||
|
||
emitFailure(error: unknown): void {
|
||
this.output.end()
|
||
this.outcome.reject(error)
|
||
}
|
||
|
||
emitExit(exitCode = 0, signal?: number): void {
|
||
this.output.end()
|
||
this.outcome.resolve({
|
||
exitCode: signal === undefined || signal === 0 ? exitCode : null,
|
||
signal: signal === 9 ? 'SIGKILL' : signal === 15 ? 'SIGTERM' : null,
|
||
})
|
||
}
|
||
|
||
async write(data: string): Promise<void> {
|
||
if (this.throwWrite) throw new Error('write failed')
|
||
this.writes.push(data)
|
||
}
|
||
|
||
async inspectForeground() {
|
||
const processGroupId = this.inspector.foregroundPgid()
|
||
return processGroupId === undefined
|
||
? undefined
|
||
: { processGroupId, inputWaiting: this.inspector.isStdinWaiting() }
|
||
}
|
||
|
||
async signalForeground(signal: SubprocessTerminalSignal): Promise<number> {
|
||
const foreground = await this.inspectForeground()
|
||
if (foreground === undefined) throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`)
|
||
if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) {
|
||
throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead')
|
||
}
|
||
this.inspector.signalGroup(foreground.processGroupId, signal)
|
||
return foreground.processGroupId
|
||
}
|
||
|
||
terminate(): Promise<void> {
|
||
if (this.cleanup !== undefined) return this.cleanup
|
||
const cleanup = this.terminateOnce()
|
||
this.cleanup = cleanup
|
||
void cleanup.catch(() => { this.cleanup = undefined })
|
||
return cleanup
|
||
}
|
||
|
||
private async terminateOnce(): Promise<void> {
|
||
if (this.terminateError !== undefined) throw this.terminateError
|
||
if (this.throwKill) throw new Error('kill failed')
|
||
this.kills.push('SIGTERM')
|
||
if (this.autoExitOnKill) this.emitExit(0, 15)
|
||
}
|
||
}
|
||
|
||
function makeSession(
|
||
terminal: FakeTerminal,
|
||
inspector: FakeInspector,
|
||
resolved: ResolvedConfig,
|
||
): LocalPtySession {
|
||
terminal.inspector = inspector
|
||
return new LocalPtySession(terminal, resolved)
|
||
}
|
||
|
||
function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
|
||
return {
|
||
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
|
||
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
|
||
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100,
|
||
disposeGraceMs: 20,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
afterEach(() => { vi.useRealTimers() })
|
||
|
||
async function initialize(session: LocalPtySession, terminal: FakeTerminal): Promise<void> {
|
||
const pending = session.initialize()
|
||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
await pending
|
||
}
|
||
|
||
describe('LocalPtySession readiness and output', () => {
|
||
it('lets queued terminal output run before the first post-write readiness poll', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const inspect = terminal.inspectForeground.bind(terminal)
|
||
let inspections = 0
|
||
terminal.inspectForeground = async () => {
|
||
inspections += 1
|
||
return await inspect()
|
||
}
|
||
const operation = session.startSend({ text: 'true', submit: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
expect(inspections).toBe(1)
|
||
|
||
await vi.advanceTimersByTimeAsync(0)
|
||
expect(inspections).toBe(1)
|
||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
expect((await operation.done).waitReason).toBe('stdin_read')
|
||
})
|
||
|
||
it('discards prompt readiness observed during asynchronous pre-write inspection', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const session = new LocalPtySession(terminal, config())
|
||
await initialize(session, terminal)
|
||
|
||
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||
terminal.inspectForeground = async () => await inspection.promise
|
||
const operation = session.startSend({ text: 'long-running-command', submit: true })
|
||
let settled = false
|
||
void operation.done.then(() => { settled = true })
|
||
|
||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||
inspection.resolve({ processGroupId: 456, inputWaiting: true })
|
||
await vi.advanceTimersByTimeAsync(20)
|
||
expect(terminal.writes).toEqual(['long-running-command\r'])
|
||
expect(settled).toBe(false)
|
||
|
||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
expect((await operation.done).waitReason).toBe('stdin_read')
|
||
})
|
||
|
||
it('captures prompt MOTD, writes submit explicitly, and settles exact stdin waits', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
expect(session.motd).toBe('dsh> ')
|
||
|
||
inspector.waiting = true
|
||
const operation = session.startSend({ text: 'python3', submit: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
expect(terminal.writes).toEqual(['python3\r'])
|
||
inspector.pgid = 789
|
||
terminal.emitData('Python\r\n>>> ')
|
||
await vi.advanceTimersByTimeAsync(20)
|
||
expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', viewport: 'Python\n>>> ', sessionStatus: { kind: 'running' } })
|
||
expect(operation.cancel()).toBe(false)
|
||
})
|
||
|
||
it('does not reuse a pre-write stdin wait as post-write readiness', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
inspector.waiting = true
|
||
const operation = session.startSend({ text: 'echo ready', submit: true })
|
||
let settled = false
|
||
void operation.done.then(() => { settled = true })
|
||
await vi.advanceTimersByTimeAsync(20)
|
||
expect(settled).toBe(false)
|
||
|
||
inspector.waiting = false
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
expect(settled).toBe(false)
|
||
inspector.waiting = true
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
expect((await operation.done).waitReason).toBe('stdin_read')
|
||
})
|
||
|
||
it('tracks a pre-write wait exit before exact probing begins', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config({
|
||
exactProbeAfterMs: 50,
|
||
idleSilenceMs: 100,
|
||
timeoutMs: 200,
|
||
}))
|
||
await initialize(session, terminal)
|
||
|
||
inspector.waiting = true
|
||
const operation = session.startSend({ text: 'fast command', submit: true })
|
||
let settled = false
|
||
void operation.done.then(() => { settled = true })
|
||
inspector.waiting = false
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
inspector.waiting = true
|
||
await vi.advanceTimersByTimeAsync(30)
|
||
expect(settled).toBe(false)
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
expect(settled).toBe(true)
|
||
expect((await operation.done).waitReason).toBe('stdin_read')
|
||
})
|
||
|
||
it('distinguishes inferred idle, timeout, exit signal, and operation reads', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
inspector.pgid = undefined
|
||
|
||
const inferred = session.startSend({ text: 'sleep', submit: false })
|
||
terminal.emitData('working')
|
||
expect(inferred.readOutput()).toEqual({ delta: 'working', truncated: false })
|
||
await vi.advanceTimersByTimeAsync(60)
|
||
expect((await inferred.done).waitReason).toBe('inferred_idle')
|
||
|
||
const timeout = session.startSend({ text: 'blocked', submit: false })
|
||
await vi.advanceTimersByTimeAsync(40)
|
||
terminal.emitData('.')
|
||
await vi.advanceTimersByTimeAsync(40)
|
||
terminal.emitData('.')
|
||
await vi.advanceTimersByTimeAsync(30)
|
||
expect((await timeout.done).waitReason).toBe('timeout')
|
||
|
||
const exiting = session.startSend({ text: 'exit', submit: true })
|
||
terminal.emitExit(7, 9)
|
||
expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGKILL' } })
|
||
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
|
||
})
|
||
|
||
it('cancels with foreground-group SIGINT, observes AbortSignal, and contains write failures', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const controller = new AbortController()
|
||
const operation = session.startSend({ text: 'sleep', submit: true, signal: controller.signal })
|
||
expect(() => session.startSend({ text: 'again', submit: true })).toThrow('active send')
|
||
controller.abort()
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
||
expect(terminal.writes).not.toContain('\x03')
|
||
terminal.emitData('\x1b]133;D;130\x07dsh> ')
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
await operation.done
|
||
|
||
const aborted = new AbortController()
|
||
aborted.abort()
|
||
expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow('aborted before write')
|
||
|
||
terminal.throwWrite = true
|
||
const failed = session.startSend({ text: 'x', submit: false })
|
||
await expect(failed.done).rejects.toThrow('write failed')
|
||
const failedInternal = failed as unknown as { append(text: string): void; fail(error: unknown): void }
|
||
failedInternal.append('ignored')
|
||
failedInternal.fail(new Error('ignored'))
|
||
})
|
||
|
||
it('does not write a send canceled during asynchronous foreground inspection', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||
terminal.inspectForeground = async () => await inspection.promise
|
||
const controller = new AbortController()
|
||
const operation = session.startSend({ text: 'must not execute', submit: true, signal: controller.signal })
|
||
controller.abort()
|
||
inspection.resolve({ processGroupId: 456, inputWaiting: false })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
|
||
expect(terminal.writes).toEqual([])
|
||
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
||
terminal.emitData('\x1b]133;D;130\x07dsh> ')
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
await operation.done
|
||
})
|
||
|
||
it('retains a canceled send when the pre-write inspection rejects while its signal is in flight', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const failed = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||
terminal.inspectForeground = async () => await failed.promise
|
||
const uncanceled = session.startSend({ text: 'plain failure', submit: true })
|
||
failed.reject(new Error('inspect failed before write'))
|
||
await expect(uncanceled.done).rejects.toThrow('inspect failed before write')
|
||
|
||
terminal.inspectForeground = FakeTerminal.prototype.inspectForeground.bind(terminal)
|
||
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||
terminal.inspectForeground = async () => await inspection.promise
|
||
const signalGate = Promise.withResolvers<undefined>()
|
||
terminal.signalForeground = async (signal) => {
|
||
await signalGate.promise
|
||
inspector.signalGroup(456, signal)
|
||
return 456
|
||
}
|
||
const controller = new AbortController()
|
||
const operation = session.startSend({ text: 'must stay owned', submit: true, signal: controller.signal })
|
||
controller.abort()
|
||
inspection.reject(new Error('transient inspection failure'))
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
|
||
// The slot stays reserved while the cancellation's foreground signal is in flight.
|
||
expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
|
||
terminal.inspectForeground = async () => ({ processGroupId: 456, inputWaiting: false })
|
||
signalGate.resolve(undefined)
|
||
await vi.advanceTimersByTimeAsync(0)
|
||
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
||
|
||
terminal.emitData('\x1b]133;D;130\x07dsh> ')
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
await operation.done
|
||
})
|
||
|
||
it('retains a canceled send until asynchronous foreground signalling settles', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const signalGate = Promise.withResolvers<undefined>()
|
||
terminal.signalForeground = async (signal) => {
|
||
await signalGate.promise
|
||
const foreground = await terminal.inspectForeground()
|
||
if (foreground === undefined) throw new Error('cannot resolve foreground')
|
||
inspector.signalGroup(foreground.processGroupId, signal)
|
||
return foreground.processGroupId
|
||
}
|
||
const operation = session.startSend({ text: 'first', submit: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
expect(operation.cancel()).toBe(true)
|
||
|
||
terminal.emitData('\x1b]133;D;130\x07dsh> ')
|
||
await vi.advanceTimersByTimeAsync(100)
|
||
expect((await operation.done).waitReason).toBe('timeout')
|
||
expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
|
||
signalGate.resolve(undefined)
|
||
await vi.advanceTimersByTimeAsync(0)
|
||
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
||
expect(inspector.groups).not.toContainEqual([789, 'SIGINT'])
|
||
})
|
||
|
||
it('does not let an in-flight readiness inspection release a canceled send before signalling settles', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const readiness = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||
let inspections = 0
|
||
terminal.inspectForeground = async () => {
|
||
inspections += 1
|
||
if (inspections === 1) return { processGroupId: 456, inputWaiting: false }
|
||
if (inspections === 2) return await readiness.promise
|
||
return { processGroupId: 456, inputWaiting: true }
|
||
}
|
||
const signalling = Promise.withResolvers<undefined>()
|
||
const signalled = Promise.withResolvers<number>()
|
||
terminal.signalForeground = async (signal) => {
|
||
await signalling.promise
|
||
const foreground = await terminal.inspectForeground()
|
||
if (foreground === undefined) throw new Error('cannot resolve foreground')
|
||
inspector.signalGroup(foreground.processGroupId, signal)
|
||
signalled.resolve(foreground.processGroupId)
|
||
return foreground.processGroupId
|
||
}
|
||
|
||
const operation = session.startSend({ text: 'first', submit: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
expect(inspections).toBe(2)
|
||
await vi.advanceTimersByTimeAsync(50)
|
||
expect(operation.cancel()).toBe(true)
|
||
let settled = false
|
||
void operation.done.then(() => { settled = true })
|
||
|
||
readiness.resolve({ processGroupId: 456, inputWaiting: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
expect(settled).toBe(false)
|
||
expect(() => session.startSend({ text: 'successor', submit: true })).toThrow(TerminalError)
|
||
|
||
signalling.resolve(undefined)
|
||
expect(await signalled.promise).toBe(456)
|
||
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
||
await session.close('test complete')
|
||
expect((await operation.done).waitReason).toBe('session_exit')
|
||
})
|
||
|
||
it('does not let an in-flight readiness failure release a canceled send before signalling settles', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const readiness = Promise.withResolvers<never>()
|
||
let inspections = 0
|
||
terminal.inspectForeground = async () => {
|
||
inspections += 1
|
||
if (inspections === 1) return { processGroupId: 456, inputWaiting: false }
|
||
if (inspections === 2) return await readiness.promise
|
||
return { processGroupId: 456, inputWaiting: true }
|
||
}
|
||
const signalling = Promise.withResolvers<undefined>()
|
||
const signalled = Promise.withResolvers<number>()
|
||
terminal.signalForeground = async (signal) => {
|
||
await signalling.promise
|
||
const foreground = await terminal.inspectForeground()
|
||
if (foreground === undefined) throw new Error('cannot resolve foreground')
|
||
inspector.signalGroup(foreground.processGroupId, signal)
|
||
signalled.resolve(foreground.processGroupId)
|
||
return foreground.processGroupId
|
||
}
|
||
|
||
const operation = session.startSend({ text: 'first', submit: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
expect(inspections).toBe(2)
|
||
expect(operation.cancel()).toBe(true)
|
||
let settled = false
|
||
void operation.done.then(() => { settled = true })
|
||
|
||
readiness.reject(new Error('inspection failed during cancellation'))
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
expect(settled).toBe(false)
|
||
expect(() => session.startSend({ text: 'successor', submit: true })).toThrow(TerminalError)
|
||
|
||
signalling.resolve(undefined)
|
||
expect(await signalled.promise).toBe(456)
|
||
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
||
await session.close('test complete')
|
||
expect((await operation.done).waitReason).toBe('session_exit')
|
||
})
|
||
|
||
it('signals only after an in-flight provider write lands', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const writeGate = Promise.withResolvers<undefined>()
|
||
terminal.write = async () => { await writeGate.promise }
|
||
const operation = session.startSend({ text: 'must be interrupted', submit: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
expect(operation.cancel()).toBe(true)
|
||
await vi.advanceTimersByTimeAsync(20)
|
||
expect(inspector.groups).toEqual([])
|
||
|
||
writeGate.resolve(undefined)
|
||
await vi.advanceTimersByTimeAsync(0)
|
||
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
||
terminal.emitData('\x1b]133;D;130\x07dsh> ')
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
await operation.done
|
||
})
|
||
|
||
it('does not signal when a cancelled provider write rejects', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const writeGate = Promise.withResolvers<undefined>()
|
||
terminal.write = async () => { await writeGate.promise }
|
||
const operation = session.startSend({ text: 'rejected write', submit: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
expect(operation.cancel()).toBe(true)
|
||
|
||
const rejected = expect(operation.done).rejects.toThrow('write failed after cancellation')
|
||
writeGate.reject(new Error('write failed after cancellation'))
|
||
await rejected
|
||
expect(inspector.groups).toEqual([])
|
||
|
||
const next = session.startSend({ text: '', submit: false })
|
||
await vi.advanceTimersByTimeAsync(100)
|
||
expect((await next.done).waitReason).toBe('inferred_idle')
|
||
})
|
||
|
||
it('releases a timed-out cancellation after the provider write and signal settle', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const writeGate = Promise.withResolvers<undefined>()
|
||
terminal.write = async () => { await writeGate.promise }
|
||
const operation = session.startSend({ text: 'slow cancelled write', submit: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
expect(operation.cancel()).toBe(true)
|
||
await vi.advanceTimersByTimeAsync(100)
|
||
|
||
expect((await operation.done).waitReason).toBe('timeout')
|
||
expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow(expect.objectContaining({
|
||
code: 'SEND_ACTIVE',
|
||
}))
|
||
|
||
writeGate.resolve(undefined)
|
||
await vi.advanceTimersByTimeAsync(0)
|
||
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
|
||
|
||
const next = session.startSend({ text: '', submit: false })
|
||
await vi.advanceTimersByTimeAsync(100)
|
||
expect((await next.done).waitReason).toBe('inferred_idle')
|
||
})
|
||
|
||
it('retains the absolute timeout after cancellation while output stays active', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const operation = session.startSend({ text: 'ignore-sigint-and-write', submit: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
expect(operation.cancel()).toBe(true)
|
||
for (let elapsed = 20; elapsed <= 100; elapsed += 20) {
|
||
terminal.emitData('.')
|
||
await vi.advanceTimersByTimeAsync(20)
|
||
}
|
||
|
||
expect(await operation.done).toMatchObject({ waitReason: 'timeout' })
|
||
})
|
||
|
||
it('does not resume cancellation polling after the terminal exits during signalling', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const signalGate = Promise.withResolvers<undefined>()
|
||
terminal.signalForeground = async () => {
|
||
await signalGate.promise
|
||
return 456
|
||
}
|
||
const operation = session.startSend({ text: 'first', submit: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
expect(operation.cancel()).toBe(true)
|
||
terminal.emitExit(0)
|
||
await expect(operation.done).resolves.toMatchObject({ waitReason: 'session_exit' })
|
||
|
||
signalGate.resolve(undefined)
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
expect(session.status()).toEqual({ kind: 'exited', exitCode: 0, signal: null })
|
||
})
|
||
|
||
it('retains send ownership after timeout until an asynchronous provider write settles', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const writeGate = Promise.withResolvers<undefined>()
|
||
terminal.write = async () => { await writeGate.promise }
|
||
const operation = session.startSend({ text: 'slow write', submit: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
await vi.advanceTimersByTimeAsync(100)
|
||
|
||
expect((await operation.done).waitReason).toBe('timeout')
|
||
expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow('active send')
|
||
|
||
writeGate.resolve(undefined)
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
const rejectedWrite = Promise.withResolvers<undefined>()
|
||
terminal.write = async () => { await rejectedWrite.promise }
|
||
const rejected = session.startSend({ text: 'late rejection', submit: true })
|
||
await vi.advanceTimersByTimeAsync(100)
|
||
expect((await rejected.done).waitReason).toBe('timeout')
|
||
rejectedWrite.reject(new Error('write failed after timeout'))
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
|
||
const next = session.startSend({ text: '', submit: false })
|
||
await vi.advanceTimersByTimeAsync(100)
|
||
expect((await next.done).waitReason).toBe('inferred_idle')
|
||
})
|
||
|
||
it('retains send ownership when cancellation signalling fails during an asynchronous write', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const writeGate = Promise.withResolvers<undefined>()
|
||
terminal.write = async () => { await writeGate.promise }
|
||
let signalCalls = 0
|
||
terminal.signalForeground = async () => {
|
||
signalCalls += 1
|
||
throw new Error('interrupt failed')
|
||
}
|
||
const operation = session.startSend({ text: 'slow write', submit: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
expect(operation.cancel()).toBe(true)
|
||
await vi.advanceTimersByTimeAsync(20)
|
||
expect(signalCalls).toBe(0)
|
||
expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow('active send')
|
||
|
||
writeGate.resolve(undefined)
|
||
await expect(operation.done).rejects.toThrow('interrupt failed')
|
||
expect(signalCalls).toBe(1)
|
||
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
|
||
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
|
||
})
|
||
|
||
it('handles startup exit, unknown exit signals, cancel-write failure, and stale polls', async () => {
|
||
vi.useFakeTimers()
|
||
const startupTerminal = new FakeTerminal()
|
||
const startup = new LocalPtySession(startupTerminal, config())
|
||
const initializing = startup.initialize(new AbortController().signal)
|
||
startupTerminal.emitExit(1)
|
||
await expect(initializing).rejects.toThrow('exited during startup')
|
||
expect(startup.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
|
||
|
||
const terminal = new FakeTerminal()
|
||
const session = new LocalPtySession(terminal, config())
|
||
await initialize(session, terminal)
|
||
const operation = session.startSend({ text: '', submit: false })
|
||
const operationInternal = operation as unknown as {
|
||
append(text: string): void
|
||
settle(reason: 'timeout', status: TerminalSessionStatus, inherited: boolean): void
|
||
}
|
||
operationInternal.append('')
|
||
const sessionInternal = session as unknown as {
|
||
pollReadiness(operation: TerminalSendOperation): void
|
||
interrupt(operation: TerminalSendOperation): void
|
||
schedulePoll(operation: TerminalSendOperation): void
|
||
polling: boolean
|
||
statusValue: TerminalSessionStatus
|
||
appendOutput(text: string): void
|
||
}
|
||
sessionInternal.appendOutput('')
|
||
sessionInternal.pollReadiness({} as TerminalSendOperation)
|
||
sessionInternal.schedulePoll({} as TerminalSendOperation)
|
||
sessionInternal.polling = true
|
||
sessionInternal.schedulePoll(operation)
|
||
sessionInternal.polling = false
|
||
sessionInternal.interrupt({} as TerminalSendOperation)
|
||
sessionInternal.statusValue = { kind: 'exited', exitCode: 2, signal: null }
|
||
sessionInternal.pollReadiness(operation)
|
||
await operation.done
|
||
operationInternal.settle('timeout', { kind: 'running' }, false)
|
||
|
||
const unknownTerminal = new FakeTerminal()
|
||
const unknown = new LocalPtySession(unknownTerminal, config())
|
||
unknownTerminal.emitExit(1, 999)
|
||
await vi.waitFor(() => {
|
||
expect(unknown.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
|
||
})
|
||
|
||
const cancelTerminal = new FakeTerminal()
|
||
const cancelInspector = new FakeInspector()
|
||
const cancel = makeSession(cancelTerminal, cancelInspector, config())
|
||
await initialize(cancel, cancelTerminal)
|
||
const cancellable = cancel.startSend({ text: '', submit: false })
|
||
cancelInspector.throwGroup = true
|
||
expect(cancellable.cancel()).toBe(true)
|
||
await expect(cancellable.done).rejects.toThrow('group failed')
|
||
expect(cancellable.cancel()).toBe(false)
|
||
|
||
const missingGroupTerminal = new FakeTerminal()
|
||
const missingGroupInspector = new FakeInspector()
|
||
const missingGroup = makeSession(missingGroupTerminal, missingGroupInspector, config())
|
||
await initialize(missingGroup, missingGroupTerminal)
|
||
missingGroupInspector.pgid = undefined
|
||
const unresolved = missingGroup.startSend({ text: '', submit: false })
|
||
expect(unresolved.cancel()).toBe(true)
|
||
await expect(unresolved.done).rejects.toThrow('cannot resolve foreground process group')
|
||
})
|
||
|
||
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, 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, config())
|
||
const timedOut = expect(timeout.initialize()).rejects.toThrow('startup timeout')
|
||
await vi.advanceTimersByTimeAsync(100)
|
||
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 = makeSession(terminal, 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()
|
||
const session = new LocalPtySession(terminal, config())
|
||
let settled = false
|
||
const initializing = session.initialize().then(() => { settled = true })
|
||
|
||
terminal.emitData('\x1b]133;D;0\x07')
|
||
await vi.advanceTimersByTimeAsync(20)
|
||
expect(settled).toBe(false)
|
||
|
||
terminal.emitData('dsh> ')
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
await initializing
|
||
expect(session.motd).toBe('dsh> ')
|
||
})
|
||
|
||
it('does not attribute a delayed prior prompt to the current send', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const session = new LocalPtySession(terminal, config({ idleSilenceMs: 100, timeoutMs: 200 }))
|
||
await initialize(session, terminal)
|
||
|
||
const operation = session.startSend({ text: "printf 'PID=%s\\n' \"$!\"", submit: true })
|
||
let settled = false
|
||
void operation.done.then(() => { settled = true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
|
||
terminal.emitData('\x1b]133;D;0\x07dsh> printf \'PID=%s\\n\' "$!"\r\n')
|
||
await vi.advanceTimersByTimeAsync(20)
|
||
expect(settled).toBe(false)
|
||
|
||
terminal.emitData('PID=123\r\n\x1b]133;D;0\x07dsh> ')
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
expect(await operation.done).toMatchObject({ waitReason: 'stdin_read' })
|
||
})
|
||
|
||
it('retains a prompt marker until the startup shell regains the foreground group', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const operation = session.startSend({ text: 'run', submit: true })
|
||
let settled = false
|
||
void operation.done.then(() => { settled = true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
inspector.pgid = 789
|
||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||
await vi.advanceTimersByTimeAsync(50)
|
||
expect(settled).toBe(false)
|
||
|
||
inspector.pgid = 456
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
expect(settled).toBe(true)
|
||
expect((await operation.done).waitReason).toBe('stdin_read')
|
||
})
|
||
|
||
it('holds the idle fallback for the configured handoff grace, not one poll', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config({ handoffGraceMs: 40 }))
|
||
await initialize(session, terminal)
|
||
|
||
const operation = session.startSend({ text: 'run', submit: true })
|
||
let settled = false
|
||
void operation.done.then(() => { settled = true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
inspector.pgid = 789
|
||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||
// One poll past the silence bound would already have settled inferred_idle.
|
||
await vi.advanceTimersByTimeAsync(70)
|
||
expect(settled).toBe(false)
|
||
|
||
inspector.pgid = 456
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
expect((await operation.done).waitReason).toBe('stdin_read')
|
||
})
|
||
|
||
it('falls back to inferred idle when a foreground child emits an inherited prompt marker', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const operation = session.startSend({ text: 'bash -i', submit: true })
|
||
inspector.pgid = 789
|
||
terminal.emitData('\x1b]133;D;0\x07child> ')
|
||
await vi.advanceTimersByTimeAsync(100)
|
||
|
||
expect((await operation.done).waitReason).toBe('inferred_idle')
|
||
})
|
||
|
||
it('contains terminal transport failures and preserves the first failure', async () => {
|
||
const terminal = new FakeTerminal()
|
||
terminal.terminateError = new Error('cleanup after transport failure')
|
||
const session = new LocalPtySession(terminal, config())
|
||
const operation = session.startSend({ text: '', submit: false })
|
||
terminal.output.emit('data', 'plain text')
|
||
terminal.emitError(new Error('output transport failed'))
|
||
;(session as unknown as { onTransportFailure(error: unknown): void })
|
||
.onTransportFailure(new Error('later failure'))
|
||
await expect(operation.done).rejects.toThrow('output transport failed')
|
||
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
|
||
terminal.terminateError = undefined
|
||
await expect(session.close('transport')).rejects.toThrow('output transport failed')
|
||
|
||
const rejectedTerminal = new FakeTerminal()
|
||
const rejected = new LocalPtySession(rejectedTerminal, config())
|
||
const rejectedOperation = rejected.startSend({ text: '', submit: false })
|
||
rejectedTerminal.emitFailure('raw transport failure')
|
||
await expect(rejectedOperation.done).rejects.toThrow('raw transport failure')
|
||
})
|
||
|
||
it('replaces invalid UTF-8 terminal output', async () => {
|
||
const chunkTerminal = new FakeTerminal()
|
||
const chunkSession = new LocalPtySession(chunkTerminal, config())
|
||
const chunkOperation = chunkSession.startSend({ text: '', submit: false })
|
||
chunkTerminal.emitBytes(Uint8Array.from([0xff]))
|
||
expect(chunkOperation.readOutput()).toEqual({ delta: '<27>', truncated: false })
|
||
chunkTerminal.emitExit()
|
||
await chunkOperation.done
|
||
|
||
const endTerminal = new FakeTerminal()
|
||
const endSession = new LocalPtySession(endTerminal, config())
|
||
const endOperation = endSession.startSend({ text: '', submit: false })
|
||
endTerminal.emitBytes(Uint8Array.from([0xe2]))
|
||
endTerminal.emitExit()
|
||
expect((await endOperation.done).viewport).toBe('<27>')
|
||
})
|
||
|
||
it('contains readiness inspection failure and a stale inspection result', async () => {
|
||
vi.useFakeTimers()
|
||
const failedTerminal = new FakeTerminal()
|
||
const failedSession = new LocalPtySession(failedTerminal, config())
|
||
const failedOperation = failedSession.startSend({ text: '', submit: false })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
failedTerminal.inspectForeground = async () => { throw new Error('inspect failed') }
|
||
const failedInternal = failedSession as unknown as {
|
||
pollReadiness(operation: TerminalSendOperation): Promise<void>
|
||
}
|
||
await failedInternal.pollReadiness(failedOperation)
|
||
await expect(failedOperation.done).rejects.toThrow('inspect failed')
|
||
|
||
const staleTerminal = new FakeTerminal()
|
||
const staleSession = new LocalPtySession(staleTerminal, config())
|
||
const staleOperation = staleSession.startSend({ text: '', submit: false })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
const gate = Promise.withResolvers<ReturnType<FakeTerminal['inspectForeground']> extends Promise<infer T> ? T : never>()
|
||
staleTerminal.inspectForeground = async () => await gate.promise
|
||
const staleInternal = staleSession as unknown as {
|
||
active: TerminalSendOperation | undefined
|
||
pollReadiness(operation: TerminalSendOperation): Promise<void>
|
||
}
|
||
const polling = staleInternal.pollReadiness(staleOperation)
|
||
staleInternal.active = undefined
|
||
gate.resolve({ processGroupId: 456, inputWaiting: false })
|
||
await polling
|
||
;(staleOperation as unknown as {
|
||
settle(reason: 'timeout', status: TerminalSessionStatus, inherited: boolean): void
|
||
}).settle('timeout', { kind: 'running' }, false)
|
||
})
|
||
|
||
it('reschedules readiness for a new send after a stale remote inspection releases the poll slot', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const old = session.startSend({ text: '', submit: false })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||
let block = true
|
||
terminal.inspectForeground = async () => block
|
||
? await inspection.promise
|
||
: { processGroupId: 456, inputWaiting: false }
|
||
const internals = session as unknown as {
|
||
pollReadiness(operation: TerminalSendOperation): Promise<void>
|
||
settleActive(reason: 'timeout'): void
|
||
}
|
||
const stalePoll = internals.pollReadiness(old)
|
||
internals.settleActive('timeout')
|
||
await old.done
|
||
|
||
block = false
|
||
const current = session.startSend({ text: '', submit: false })
|
||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
inspection.resolve({ processGroupId: 456, inputWaiting: false })
|
||
await stalePoll
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
|
||
expect((await current.done).waitReason).toBe('stdin_read')
|
||
})
|
||
|
||
it('does not poll a successor before its own pre-write inspection and write complete', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
await initialize(session, terminal)
|
||
|
||
const old = session.startSend({ text: '', submit: false })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
const staleInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||
const successorInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||
let inspectCalls = 0
|
||
terminal.inspectForeground = async () => {
|
||
inspectCalls += 1
|
||
return inspectCalls === 1 ? await staleInspection.promise : await successorInspection.promise
|
||
}
|
||
const internals = session as unknown as {
|
||
pollReadiness(operation: TerminalSendOperation): Promise<void>
|
||
settleActive(reason: 'timeout'): void
|
||
}
|
||
const stalePoll = internals.pollReadiness(old)
|
||
internals.settleActive('timeout')
|
||
await old.done
|
||
|
||
const current = session.startSend({ text: 'successor', submit: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
staleInspection.resolve({ processGroupId: 456, inputWaiting: false })
|
||
await stalePoll
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
expect(inspectCalls).toBe(2)
|
||
expect(terminal.writes).toEqual([])
|
||
|
||
successorInspection.resolve({ processGroupId: 456, inputWaiting: false })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
expect(terminal.writes).toEqual(['successor\r'])
|
||
expect((await current.done).waitReason).toBe('stdin_read')
|
||
})
|
||
|
||
it('contains stale timer, write, inspection, and interrupt continuations', async () => {
|
||
vi.useFakeTimers()
|
||
const settle = (operation: TerminalSendOperation): void => {
|
||
;(operation as unknown as {
|
||
settle(reason: 'timeout', status: TerminalSessionStatus, inherited: boolean): void
|
||
}).settle('timeout', { kind: 'running' }, false)
|
||
}
|
||
|
||
const deadlineTerminal = new FakeTerminal()
|
||
const deadlineSession = new LocalPtySession(deadlineTerminal, config())
|
||
const deadlineOperation = deadlineSession.startSend({ text: '', submit: false })
|
||
;(deadlineSession as unknown as { active: TerminalSendOperation | undefined }).active = undefined
|
||
await vi.advanceTimersByTimeAsync(100)
|
||
settle(deadlineOperation)
|
||
|
||
const writeTerminal = new FakeTerminal()
|
||
const writeGate = Promise.withResolvers<undefined>()
|
||
writeTerminal.write = async () => { await writeGate.promise }
|
||
const writeSession = new LocalPtySession(writeTerminal, config())
|
||
const writeOperation = writeSession.startSend({ text: 'x', submit: false })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
;(writeSession as unknown as { closing: boolean }).closing = true
|
||
writeGate.resolve(undefined)
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
settle(writeOperation)
|
||
|
||
const beginTerminal = new FakeTerminal()
|
||
const beginGate = Promise.withResolvers<never>()
|
||
beginTerminal.inspectForeground = async () => await beginGate.promise
|
||
const beginSession = new LocalPtySession(beginTerminal, config())
|
||
const beginOperation = beginSession.startSend({ text: '', submit: false })
|
||
;(beginSession as unknown as { active: TerminalSendOperation | undefined }).active = undefined
|
||
beginGate.reject(new Error('stale begin failure'))
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
settle(beginOperation)
|
||
|
||
const scheduledTerminal = new FakeTerminal()
|
||
const scheduledSession = new LocalPtySession(scheduledTerminal, config())
|
||
const scheduledOperation = scheduledSession.startSend({ text: '', submit: false })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
const scheduledInternal = scheduledSession as unknown as {
|
||
schedulePoll(operation: TerminalSendOperation, delayMs?: number): void
|
||
settleActive(reason: 'timeout'): void
|
||
}
|
||
scheduledInternal.schedulePoll(scheduledOperation, 5)
|
||
scheduledInternal.settleActive('timeout')
|
||
await scheduledOperation.done
|
||
|
||
const pollTerminal = new FakeTerminal()
|
||
const pollSession = new LocalPtySession(pollTerminal, config())
|
||
const pollOperation = pollSession.startSend({ text: '', submit: false })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
const pollGate = Promise.withResolvers<never>()
|
||
pollTerminal.inspectForeground = async () => await pollGate.promise
|
||
const pollInternal = pollSession as unknown as {
|
||
active: TerminalSendOperation | undefined
|
||
pollReadiness(operation: TerminalSendOperation): Promise<void>
|
||
}
|
||
const stalePoll = pollInternal.pollReadiness(pollOperation)
|
||
pollInternal.active = undefined
|
||
pollGate.reject(new Error('stale poll failure'))
|
||
await stalePoll
|
||
settle(pollOperation)
|
||
|
||
const interruptTerminal = new FakeTerminal()
|
||
const interruptGate = Promise.withResolvers<never>()
|
||
interruptTerminal.signalForeground = async () => await interruptGate.promise
|
||
const interruptSession = new LocalPtySession(interruptTerminal, config())
|
||
const interruptOperation = interruptSession.startSend({ text: '', submit: false })
|
||
expect(interruptOperation.cancel()).toBe(true)
|
||
;(interruptSession as unknown as { active: TerminalSendOperation | undefined }).active = undefined
|
||
interruptGate.reject(new Error('stale interrupt failure'))
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
settle(interruptOperation)
|
||
})
|
||
})
|
||
|
||
describe('LocalPtySession bounds, signals, and teardown', () => {
|
||
it('validates pagination and enforces line/UTF-8 bounds', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const session = new LocalPtySession(
|
||
terminal,
|
||
config({ scrollbackLines: 3, scrollbackMaxBytes: 12, maxReadBytes: 6 }),
|
||
)
|
||
expect(session.read({})).toMatchObject({ text: '' })
|
||
await initialize(session, terminal)
|
||
const operation = session.startSend({ text: '', submit: false })
|
||
terminal.emitData('一\n二\n三\n四')
|
||
await vi.advanceTimersByTimeAsync(60)
|
||
expect((await operation.done).truncated).toBe(true)
|
||
const page = session.read({ offset: 0, count: 3 })
|
||
expect(Buffer.byteLength(page.text)).toBeLessThanOrEqual(6)
|
||
expect(page.truncated).toBe(true)
|
||
expect(session.read({ offset: 999 })).toMatchObject({ text: '', lineBegin: 999, lineEnd: 999 })
|
||
expect(() => session.read({ offset: -1 })).toThrow('offset')
|
||
expect(() => session.read({ count: 0 })).toThrow('count')
|
||
|
||
const tinyTerminal = new FakeTerminal()
|
||
const tiny = new LocalPtySession(tinyTerminal, config({ maxReadBytes: 1 }))
|
||
await initialize(tiny, tinyTerminal)
|
||
const tinyOperation = tiny.startSend({ text: '', submit: false })
|
||
tinyTerminal.emitData('一')
|
||
await vi.advanceTimersByTimeAsync(60)
|
||
await tinyOperation.done
|
||
expect(tiny.read({ offset: 0, count: 1 }).text).toBe('')
|
||
})
|
||
|
||
it('signals verified groups and refuses unresolved or shell-targeted hard kills', async () => {
|
||
const terminal = new FakeTerminal()
|
||
const inspector = new FakeInspector()
|
||
const session = makeSession(terminal, inspector, config())
|
||
expect(await session.signal('SIGINT')).toEqual({ delivered: true, targetPgid: 456 })
|
||
inspector.pgid = terminal.pid
|
||
await expect(session.signal('SIGKILL')).rejects.toThrow('terminate the terminal session')
|
||
inspector.pgid = undefined
|
||
await expect(session.signal('SIGTERM')).rejects.toThrow('cannot resolve')
|
||
})
|
||
|
||
it('closes idempotently and rejects new signals', async () => {
|
||
const terminal = new FakeTerminal()
|
||
terminal.throwKill = true
|
||
const session = new LocalPtySession(terminal, config({ disposeGraceMs: 1 }))
|
||
const closing = session.close('test')
|
||
expect(session.close('other')).toBe(closing)
|
||
await expect(closing).rejects.toThrow('PTY cleanup failed (test)')
|
||
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
|
||
await expect(session.signal('SIGTERM')).rejects.toThrow('closing')
|
||
})
|
||
|
||
it('reports cleanup failure without waiting for top-level exit and permits retry', async () => {
|
||
const terminal = new FakeTerminal()
|
||
terminal.autoExitOnKill = false
|
||
terminal.terminateError = new Error('terminal cleanup failed; surviving pids: 456')
|
||
const session = new LocalPtySession(terminal, config())
|
||
|
||
await expect(session.close('survivor')).rejects.toMatchObject({
|
||
message: 'PTY cleanup failed (survivor)',
|
||
cause: terminal.terminateError,
|
||
})
|
||
expect(terminal.kills).toEqual([])
|
||
|
||
terminal.terminateError = undefined
|
||
terminal.autoExitOnKill = true
|
||
await expect(session.close('retry')).resolves.toBeUndefined()
|
||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||
})
|
||
|
||
it('settles an active send as session_exit when closed mid-operation', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const session = new LocalPtySession(terminal, config({ disposeGraceMs: 50 }))
|
||
await initialize(session, terminal)
|
||
const operation = session.startSend({ text: 'run', submit: true })
|
||
// The shell returns to its prompt while the send is active; a running
|
||
// readiness poll would otherwise mis-settle this as stdin_read once close
|
||
// begins, so teardown must stop polling before its grace period.
|
||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||
terminal.autoExitOnKill = false
|
||
const closing = session.close('mid-send')
|
||
await vi.advanceTimersByTimeAsync(20)
|
||
terminal.emitExit(0, 15)
|
||
expect((await operation.done).waitReason).toBe('session_exit')
|
||
await closing
|
||
})
|
||
|
||
it('settles a closing send when provider termination cancels inspection', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const session = new LocalPtySession(terminal, config())
|
||
await initialize(session, terminal)
|
||
const write = Promise.withResolvers<undefined>()
|
||
terminal.write = async () => { await write.promise }
|
||
const writeOperation = session.startSend({ text: 'pending write', submit: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
await session.close('pending write')
|
||
expect((await writeOperation.done).waitReason).toBe('session_exit')
|
||
// The rejection lands after close released the send; it must stay contained.
|
||
write.reject(new Error('write rejected during close'))
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
})
|
||
|
||
it('settles a closing send when provider termination cancels a pending inspection', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const session = new LocalPtySession(terminal, config())
|
||
await initialize(session, terminal)
|
||
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||
terminal.inspectForeground = async () => await inspection.promise
|
||
const terminate = terminal.terminate.bind(terminal)
|
||
terminal.terminate = async () => {
|
||
inspection.reject(new Error('terminal terminated'))
|
||
await terminate()
|
||
}
|
||
const operation = session.startSend({ text: 'pending inspection', submit: true })
|
||
await Promise.resolve()
|
||
|
||
await session.close('pending inspection')
|
||
|
||
expect((await operation.done).waitReason).toBe('session_exit')
|
||
expect(terminal.writes).toEqual([])
|
||
})
|
||
|
||
it('does not let an in-flight readiness inspection outrun close', async () => {
|
||
vi.useFakeTimers()
|
||
const terminal = new FakeTerminal()
|
||
const session = new LocalPtySession(terminal, config())
|
||
await initialize(session, terminal)
|
||
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
|
||
const originalInspect = terminal.inspectForeground.bind(terminal)
|
||
let inspections = 0
|
||
terminal.inspectForeground = async () => {
|
||
inspections += 1
|
||
return inspections === 1 ? await originalInspect() : await inspection.promise
|
||
}
|
||
const operation = session.startSend({ text: 'pending readiness', submit: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||
await vi.advanceTimersByTimeAsync(10)
|
||
expect(inspections).toBe(2)
|
||
|
||
const termination = Promise.withResolvers<undefined>()
|
||
terminal.terminate = async () => {
|
||
await termination.promise
|
||
terminal.emitExit(0, 15)
|
||
}
|
||
const closing = session.close('in-flight readiness')
|
||
let settled = false
|
||
void operation.done.then(() => { settled = true })
|
||
inspection.resolve({ processGroupId: 456, inputWaiting: true })
|
||
await Promise.resolve()
|
||
await Promise.resolve()
|
||
expect(settled).toBe(false)
|
||
|
||
termination.resolve(undefined)
|
||
await closing
|
||
expect((await operation.done).waitReason).toBe('session_exit')
|
||
})
|
||
|
||
})
|