refactor(runtime): compose consumers over fs and subprocess
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { IPty, IPtyForkOptions } from 'node-pty'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { Inbox, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
|
||||
@@ -11,12 +11,18 @@ import PtyService, { PtyBackendCleanupError, PtySessionId } from '@deepseek-ai/d
|
||||
import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local'
|
||||
import * as ptyLocal from '@deepseek-ai/dsh-pty-local'
|
||||
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
|
||||
import type { ProcessInspector } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
|
||||
import type { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts'
|
||||
import { SubprocessService } from '@deepseek-ai/dsh-subprocess'
|
||||
import type {
|
||||
SubprocessHandle,
|
||||
SubprocessSpawnSpec,
|
||||
SubprocessTerminalHandle,
|
||||
SubprocessTerminalSpawnSpec,
|
||||
} from '@deepseek-ai/dsh-subprocess'
|
||||
|
||||
class EmptySandbox extends SandboxProvider {
|
||||
confine(_argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
|
||||
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
|
||||
return { argv: [], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +31,7 @@ class RecordingSandbox extends SandboxProvider {
|
||||
|
||||
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
|
||||
this.calls.push({ argv, policy })
|
||||
return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
|
||||
return { argv: ['/sandbox', '--', ...argv], enforcement: 'full', denialSignatures: [], runnerFailureSignatures: [] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,28 +44,37 @@ function config(): ResolvedConfig {
|
||||
}
|
||||
}
|
||||
|
||||
function agent(ctx: Context, cwd?: string): Agent {
|
||||
function agent(ctx: Context): Agent {
|
||||
const id = SessionId('agent')
|
||||
const session = Session.create(id, undefined, { version: 0, id, createdAt: 0, ...cwd === undefined ? {} : { cwd } })
|
||||
return {
|
||||
id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx,
|
||||
send: () => {},
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
const inspector = {
|
||||
foregroundPgid: () => undefined,
|
||||
isStdinWaiting: () => false,
|
||||
processTree: () => [],
|
||||
isAlive: () => false,
|
||||
signalGroup() {},
|
||||
signalProcess() {},
|
||||
} satisfies ProcessInspector
|
||||
function terminalHandle(): SubprocessTerminalHandle {
|
||||
const output = new PassThrough()
|
||||
return {
|
||||
pid: 123,
|
||||
output,
|
||||
done: Promise.resolve({ exitCode: 0, signal: null }),
|
||||
write: async () => {},
|
||||
inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }),
|
||||
signalForeground: async () => 123,
|
||||
terminate: () => { output.end() },
|
||||
waitForExit: async () => true,
|
||||
}
|
||||
}
|
||||
|
||||
class StubSubprocessService extends SubprocessService {
|
||||
readonly cwd = '/tmp'
|
||||
readonly runtimeRoot = '/tmp/dsh-runtime'
|
||||
async resolveExecutable(command: string): Promise<string> { return command }
|
||||
spawn(_spec: SubprocessSpawnSpec): SubprocessHandle { throw new Error('unused') }
|
||||
async spawnTerminal(_spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
|
||||
return terminalHandle()
|
||||
}
|
||||
}
|
||||
|
||||
function spec(owner: Agent, signal?: AbortSignal) {
|
||||
return {
|
||||
@@ -81,12 +96,11 @@ function stubLocalSession(initialize: () => Promise<void> = () => Promise.resolv
|
||||
}
|
||||
|
||||
function registerStubLocalBackend(ctx: Context, createSession: () => LocalPtySession) {
|
||||
return ctx.inject(['pty', 'sandbox', 'sandboxPolicy'], (providerCtx) => {
|
||||
return ctx.inject(['pty', 'sandbox', 'sandboxPolicy', 'subprocess'], (providerCtx) => {
|
||||
providerCtx.pty.registerBackend(new LocalPtyBackend(
|
||||
providerCtx,
|
||||
{ ...config(), backendType: 'stub' },
|
||||
inspector,
|
||||
(() => ({})) as never,
|
||||
async () => terminalHandle(),
|
||||
createSession,
|
||||
))
|
||||
})
|
||||
@@ -97,7 +111,7 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/tmp' })
|
||||
const backend = new LocalPtyBackend(ctx, config(), inspector)
|
||||
const backend = new LocalPtyBackend(ctx, config(), async () => terminalHandle())
|
||||
const controller = new AbortController()
|
||||
const abortReason = new Error('spawn aborted')
|
||||
controller.abort(abortReason)
|
||||
@@ -109,11 +123,11 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
const spawnTerminal = (() => ({} as IPty)) as never
|
||||
const spawnTerminal = async (): Promise<SubprocessTerminalHandle> => terminalHandle()
|
||||
|
||||
const closed = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
const failed = { initialize: () => Promise.reject(new Error('startup failed')), close: closed } as unknown as LocalPtySession
|
||||
const backend = new LocalPtyBackend(ctx, config(), inspector, spawnTerminal, () => failed)
|
||||
const backend = new LocalPtyBackend(ctx, config(), spawnTerminal, () => failed)
|
||||
await expect(backend.spawn(spec(agent(ctx)))).rejects.toThrow('startup failed')
|
||||
expect(closed).toHaveBeenCalledWith('PTY startup failed')
|
||||
|
||||
@@ -123,7 +137,7 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
initialize: () => Promise.reject(startupFailure),
|
||||
close: () => Promise.reject(cleanupFailure),
|
||||
} as unknown as LocalPtySession
|
||||
const aggregate = new LocalPtyBackend(ctx, config(), inspector, spawnTerminal, () => doublyFailed)
|
||||
const aggregate = new LocalPtyBackend(ctx, config(), spawnTerminal, () => doublyFailed)
|
||||
await expect(aggregate.spawn(spec(agent(ctx)))).rejects.toEqual(expect.objectContaining({
|
||||
name: 'PtyBackendCleanupError',
|
||||
spawnError: startupFailure,
|
||||
@@ -131,79 +145,72 @@ describe('LocalPtyBackend startup rollback', () => {
|
||||
} satisfies Partial<PtyBackendCleanupError>))
|
||||
})
|
||||
|
||||
it('resolves session mode and root together before wrapping the shell', async () => {
|
||||
it('wraps confined argv, scrubs the environment, and returns initialized sessions', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(RecordingSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/deployment-fallback' })
|
||||
const terminal = {} as IPty
|
||||
let spawned: { file: string; args: string[]; options: IPtyForkOptions } | undefined
|
||||
const spawnTerminal = ((file: string, args: string[], options: IPtyForkOptions) => {
|
||||
spawned = { file, args, options }
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' })
|
||||
const terminal = terminalHandle()
|
||||
let spawned: SubprocessTerminalSpawnSpec | undefined
|
||||
const spawnTerminal = async (spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> => {
|
||||
spawned = spec
|
||||
return terminal
|
||||
}) as never
|
||||
}
|
||||
const initialized = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
const session = { initialize: initialized } as unknown as LocalPtySession
|
||||
const backend = new LocalPtyBackend(
|
||||
ctx,
|
||||
{ ...config(), shellArgs: ['-i'] },
|
||||
inspector,
|
||||
spawnTerminal,
|
||||
() => session,
|
||||
)
|
||||
const previous = process.env.PTY_TEST_SECRET
|
||||
process.env.PTY_TEST_SECRET = 'must-not-leak'
|
||||
const owner = agent(ctx, '/session-workspace')
|
||||
setSandboxMode(owner.session, 'workspace-write')
|
||||
try {
|
||||
expect(await backend.spawn(spec(owner))).toBe(session)
|
||||
expect(await backend.spawn({ ...spec(agent(ctx)), cwd: '/work' })).toBe(session)
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.PTY_TEST_SECRET
|
||||
else process.env.PTY_TEST_SECRET = previous
|
||||
}
|
||||
|
||||
expect(spawned).toMatchObject({
|
||||
file: '/sandbox',
|
||||
args: ['--', '/bin/bash', '-i'],
|
||||
options: {
|
||||
name: 'dumb', cols: 80, rows: 24, cwd: '/session-workspace',
|
||||
env: {
|
||||
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ', BASH_SILENCE_DEPRECATION_WARNING: '1',
|
||||
DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1',
|
||||
},
|
||||
argv: ['/sandbox', '--', '/bin/bash', '-i'],
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/work',
|
||||
graceMs: 10,
|
||||
env: {
|
||||
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ', BASH_SILENCE_DEPRECATION_WARNING: '1',
|
||||
DSH_SHELL: '1', DSH_SESSION_ID: 'agent', DSH_PTY_SESSION_ID: 'pty-1',
|
||||
},
|
||||
})
|
||||
expect(spawned?.options.env?.PTY_TEST_SECRET).toBeUndefined()
|
||||
expect(spawned?.env?.PTY_TEST_SECRET).toBeUndefined()
|
||||
expect(initialized).toHaveBeenCalledWith(undefined)
|
||||
expect((ctx.sandbox as RecordingSandbox).calls).toEqual([{
|
||||
argv: ['/bin/bash', '-i'],
|
||||
policy: { mode: 'workspace-write', workspaceRoot: '/session-workspace' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('composes the default local session around a spawned terminal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
|
||||
let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
|
||||
const terminal = {
|
||||
pid: 123, cols: 80, rows: 24, process: 'bash', handleFlowControl: false,
|
||||
onData(listener: (data: string) => void) {
|
||||
queueMicrotask(() => { listener('\x1b]133;D;0\x07dsh> ') })
|
||||
return { dispose() {} }
|
||||
const output = new PassThrough()
|
||||
const outcome = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
|
||||
const terminal: SubprocessTerminalHandle = {
|
||||
pid: 123,
|
||||
output,
|
||||
done: outcome.promise,
|
||||
write: async () => {},
|
||||
inspectForeground: async () => ({ processGroupId: 123, inputWaiting: true }),
|
||||
signalForeground: async () => 123,
|
||||
terminate() {
|
||||
output.end()
|
||||
outcome.resolve({ exitCode: null, signal: 'SIGTERM' })
|
||||
},
|
||||
onExit(listener: (event: { exitCode: number; signal?: number }) => void) {
|
||||
exitListener = listener
|
||||
return { dispose() {} }
|
||||
},
|
||||
write() {},
|
||||
kill() { exitListener?.({ exitCode: 0, signal: 15 }) },
|
||||
resize() {}, clear() {}, pause() {}, resume() {},
|
||||
} as IPty
|
||||
waitForExit: async () => true,
|
||||
}
|
||||
queueMicrotask(() => { output.write(Buffer.from('\x1b]133;D;0\x07dsh> ')) })
|
||||
const backend = new LocalPtyBackend(
|
||||
ctx,
|
||||
config(),
|
||||
{ ...inspector, foregroundPgid: () => terminal.pid },
|
||||
() => terminal,
|
||||
async () => terminal,
|
||||
)
|
||||
const session = await backend.spawn(spec(agent(ctx)))
|
||||
expect(session.motd).toBe('dsh> ')
|
||||
@@ -217,7 +224,7 @@ describe('pty-local plugin shape', () => {
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(ptyLocal) as Record<string, unknown>
|
||||
expect(unwrapped.name).toBe('pty-local')
|
||||
expect(unwrapped.inject).toEqual(['pty', 'sandbox', 'sandboxPolicy'])
|
||||
expect(unwrapped.inject).toEqual(['pty', 'sandbox', 'sandboxPolicy', 'subprocess'])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -227,6 +234,7 @@ describe('pty-local plugin shape', () => {
|
||||
await ctx.plugin(PtyService)
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
await ctx.plugin(StubSubprocessService)
|
||||
const fiber = await ctx.plugin(ptyLocal, config())
|
||||
expect(ctx.pty.listBackends()).toEqual(['shell'])
|
||||
await fiber.dispose()
|
||||
@@ -240,11 +248,12 @@ describe('pty-local plugin shape', () => {
|
||||
await ctx.plugin(PtyService)
|
||||
await ctx.plugin(EmptySandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
await ctx.plugin(StubSubprocessService)
|
||||
await ctx.plugin(ptyLocal, config())
|
||||
|
||||
const session = ctx.sessions.create(SessionId('unowned-mode'))
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
}).not.toThrow()
|
||||
expect(() => { setSandboxMode(session, 'read-only') }).not.toThrow()
|
||||
})
|
||||
@@ -256,17 +265,13 @@ describe('pty-local plugin shape', () => {
|
||||
await ctx.plugin(PtyService)
|
||||
await ctx.plugin(RecordingSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
await ctx.plugin(StubSubprocessService)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('mode-owner'))
|
||||
const ownerFiber = await ctx.plugin(() => {})
|
||||
const owner: Agent = {
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx: ownerFiber.ctx,
|
||||
send: () => {},
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(owner)
|
||||
const providerFiber = await registerStubLocalBackend(ctx, () => stubLocalSession())
|
||||
@@ -275,7 +280,7 @@ describe('pty-local plugin shape', () => {
|
||||
const unrelated = ctx.sessions.create(SessionId('unrelated-mode'))
|
||||
expect(() => { setSandboxMode(unrelated, 'read-only') }).not.toThrow()
|
||||
expect(() => {
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
}).not.toThrow()
|
||||
|
||||
expect(() => { setSandboxMode(session, 'danger-full-access') }).not.toThrow()
|
||||
@@ -304,17 +309,13 @@ describe('pty-local plugin shape', () => {
|
||||
await ctx.plugin(PtyService)
|
||||
await ctx.plugin(RecordingSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
|
||||
await ctx.plugin(StubSubprocessService)
|
||||
|
||||
const session = ctx.sessions.create(SessionId('pending-mode-owner'))
|
||||
const ownerFiber = await ctx.plugin(() => {})
|
||||
const owner: Agent = {
|
||||
id: session.id, options: {}, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
||||
status: 'idle',
|
||||
ctx: ownerFiber.ctx,
|
||||
send: () => {},
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, cancel() {},
|
||||
runMaintenance: task => task(new AbortController().signal),
|
||||
whenIdle: () => Promise.resolve(),
|
||||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
|
||||
followup: () => {}, steer: () => {}, inject: () => {}, send: () => {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
ctx.agents.register(owner)
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { PtySendOperation } from '@deepseek-ai/dsh-pty'
|
||||
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import * as ptyLocal from '@deepseek-ai/dsh-pty-local'
|
||||
|
||||
const roots: string[] = []
|
||||
@@ -57,6 +58,7 @@ async function harness(
|
||||
await ctx.plugin(PtyService)
|
||||
await ctx.plugin(PassthroughSandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root })
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
const fiber = await ctx.plugin(ptyLocal, {
|
||||
pollIntervalMs: 10,
|
||||
exactProbeAfterMs: 20,
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
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, 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(' ')}`
|
||||
}
|
||||
|
||||
function syscall(number: number, ...args: number[]): string {
|
||||
const six = [...args]
|
||||
while (six.length < 6) six.push(0)
|
||||
return `${number} ${six.slice(0, 6).map(value => `0x${value.toString(16)}`).join(' ')}`
|
||||
}
|
||||
|
||||
function fakeInternals() {
|
||||
const files = new Map<string, string>()
|
||||
const dirs = new Map<string, string[]>()
|
||||
const memories = new Map<string, Buffer>()
|
||||
const fds = new Map<number, string>()
|
||||
const kills: Array<[number, NodeJS.Signals]> = []
|
||||
let nextFd = 10
|
||||
let ps = ''
|
||||
let tpgid = '0'
|
||||
const internals: ProcessInspectorInternals = {
|
||||
readFile(path) {
|
||||
const value = files.get(path)
|
||||
if (value === undefined) throw new Error(`missing ${path}`)
|
||||
return value
|
||||
},
|
||||
readDir(path) {
|
||||
const value = dirs.get(path)
|
||||
if (value === undefined) throw new Error(`missing ${path}`)
|
||||
return value
|
||||
},
|
||||
open(path) {
|
||||
if (!memories.has(path)) throw new Error(`missing ${path}`)
|
||||
const fd = nextFd++
|
||||
fds.set(fd, path)
|
||||
return fd
|
||||
},
|
||||
read(fd, buffer, length, position) {
|
||||
const path = fds.get(fd)
|
||||
if (path === undefined) throw new Error('bad fd')
|
||||
const source = memories.get(path)
|
||||
if (source === undefined) throw new Error('missing memory')
|
||||
return source.copy(buffer, 0, position, Math.min(source.length, position + length))
|
||||
},
|
||||
close(fd) { fds.delete(fd) },
|
||||
exec(_file, args) {
|
||||
if (args.includes('tpgid=')) return tpgid
|
||||
return ps
|
||||
},
|
||||
kill(pid, signal) { kills.push([pid, signal]) },
|
||||
}
|
||||
return {
|
||||
internals, files, dirs, memories, kills,
|
||||
setPs(value: string) { ps = value },
|
||||
setTpgid(value: string) { tpgid = value },
|
||||
}
|
||||
}
|
||||
|
||||
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', 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'])
|
||||
fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500'))
|
||||
fake.files.set('/proc/11/stat', stat(11, 21, 30, -1, '501'))
|
||||
fake.files.set('/proc/12/stat', stat(12, 22, 30, -1, '502', 10))
|
||||
fake.files.set('/proc/13/stat', stat(13, 23, 30, -1, '503', 12))
|
||||
const inspector = createProcessInspector('linux', 'x64', fake.internals)
|
||||
expect(inspector.foregroundPgid(10)).toBe(40)
|
||||
expect(inspector.foregroundPgid(11)).toBeUndefined()
|
||||
expect(inspector.foregroundPgid(99)).toBeUndefined()
|
||||
expect(inspector.processTree(10)).toEqual([
|
||||
{ pid: 13, started: '503' },
|
||||
{ pid: 12, started: '502' },
|
||||
{ pid: 10, started: '500' },
|
||||
])
|
||||
expect(inspector.processTree(99)).toEqual([])
|
||||
expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(true)
|
||||
expect(inspector.isAlive({ pid: 10, started: 'old' })).toBe(false)
|
||||
inspector.signalGroup(40, 'SIGINT')
|
||||
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', () => {
|
||||
const fake = fakeInternals()
|
||||
fake.dirs.set('/proc', ['100', '101'])
|
||||
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
|
||||
fake.files.set('/proc/101/stat', stat(101, 77, 100, 77, '2'))
|
||||
fake.dirs.set('/proc/100/task', ['100'])
|
||||
fake.dirs.set('/proc/101/task', ['101', '102'])
|
||||
const inspector = createProcessInspector('linux', 'x64', fake.internals)
|
||||
|
||||
fake.files.set('/proc/100/task/100/syscall', 'running')
|
||||
fake.files.set('/proc/101/task/101/syscall', '-1 0x0')
|
||||
fake.files.set('/proc/101/task/102/syscall', syscall(0, 0))
|
||||
expect(inspector.isStdinWaiting(77)).toBe(true)
|
||||
|
||||
fake.files.set('/proc/101/task/102/syscall', syscall(270, 1, 0x10))
|
||||
const fdSet = Buffer.alloc(0x11)
|
||||
fdSet[0x10] = 1
|
||||
fake.memories.set('/proc/101/mem', fdSet)
|
||||
expect(inspector.isStdinWaiting(77)).toBe(true)
|
||||
|
||||
const poll = Buffer.alloc(8)
|
||||
poll.writeInt32LE(0, 0)
|
||||
poll.writeInt16LE(1, 4)
|
||||
fake.files.set('/proc/101/task/102/syscall', syscall(7, 0x20, 1))
|
||||
fake.memories.set('/proc/101/mem', Buffer.concat([Buffer.alloc(0x20), poll]))
|
||||
expect(inspector.isStdinWaiting(77)).toBe(true)
|
||||
|
||||
fake.files.set('/proc/101/task/102/syscall', syscall(232, 5, 0, 1))
|
||||
fake.files.set('/proc/101/fdinfo/5', 'pos: 0\ntfd: 0 events: 19\n')
|
||||
expect(inspector.isStdinWaiting(77)).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed on unsupported, malformed, unreadable, or non-stdin waits', () => {
|
||||
const fake = fakeInternals()
|
||||
fake.dirs.set('/proc', ['100'])
|
||||
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
|
||||
fake.dirs.set('/proc/100/task', ['100'])
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(0, 2))
|
||||
expect(createProcessInspector('linux', 'mips', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0))
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 0))
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0, 1))
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(232, 9, 0, 1))
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(999))
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
|
||||
fake.files.set('/proc/100/task/100/syscall', 'not-a-number 0x0')
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
fake.dirs.delete('/proc/100/task')
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
fake.dirs.set('/proc', ['100', '200'])
|
||||
fake.files.set('/proc/200/stat', stat(200, 88, 200, 88, '2'))
|
||||
expect(createProcessInspector('linux', 'x64', fake.internals).isStdinWaiting(77)).toBe(false)
|
||||
})
|
||||
|
||||
it('contains unreadable syscall, memory, and fdinfo boundaries', () => {
|
||||
const fake = fakeInternals()
|
||||
fake.dirs.set('/proc', ['100'])
|
||||
fake.files.set('/proc/100/stat', stat(100, 77, 100, 77, '1'))
|
||||
fake.dirs.set('/proc/100/task', ['100'])
|
||||
const inspector = createProcessInspector('linux', 'x64', fake.internals)
|
||||
expect(inspector.isStdinWaiting(77)).toBe(false)
|
||||
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(270, 1, 0x10))
|
||||
expect(inspector.isStdinWaiting(77)).toBe(false)
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(232, 5, 0, 1))
|
||||
expect(inspector.isStdinWaiting(77)).toBe(false)
|
||||
|
||||
const noStdinPoll = Buffer.alloc(0x28)
|
||||
noStdinPoll.writeInt32LE(2, 0x20)
|
||||
noStdinPoll.writeInt16LE(1, 0x24)
|
||||
fake.memories.set('/proc/100/mem', noStdinPoll)
|
||||
fake.files.set('/proc/100/task/100/syscall', syscall(7, 0x20, 1))
|
||||
expect(inspector.isStdinWaiting(77)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('macOS process inspector', () => {
|
||||
it('reads tpgid and process trees, contains cycles, and identity-fences signals', () => {
|
||||
const fake = fakeInternals()
|
||||
fake.setTpgid('55\n')
|
||||
fake.setPs(' 10 1 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n 12 11 Mon Jul 21 10:00:02 2026\n 13 99 Mon Jul 21 10:00:03 2026\nmalformed\n')
|
||||
const inspector = createProcessInspector('darwin', 'arm64', fake.internals)
|
||||
expect(inspector.foregroundPgid(10)).toBe(55)
|
||||
expect(inspector.isStdinWaiting(55)).toBe(false)
|
||||
expect(inspector.processTree(10)).toEqual([
|
||||
{ pid: 12, started: 'Mon Jul 21 10:00:02 2026' },
|
||||
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
|
||||
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
|
||||
])
|
||||
expect(inspector.processTree(99)).toEqual([])
|
||||
expect(inspector.isAlive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true)
|
||||
inspector.signalGroup(55, 'SIGTSTP')
|
||||
inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL')
|
||||
inspector.signalProcess({ pid: 12, started: 'missing' }, 'SIGTERM')
|
||||
expect(fake.kills).toEqual([[-55, 'SIGTSTP'], [11, 'SIGKILL']])
|
||||
|
||||
fake.setPs(' 10 11 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n')
|
||||
expect(inspector.processTree(10)).toEqual([
|
||||
{ pid: 11, started: 'Mon Jul 21 10:00:01 2026' },
|
||||
{ pid: 10, started: 'Mon Jul 21 10:00:00 2026' },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns undefined for missing or invalid foreground groups and rejects unsupported platforms', () => {
|
||||
const fake = fakeInternals()
|
||||
fake.setTpgid('-1')
|
||||
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
|
||||
fake.internals.exec = () => { throw new Error('gone') }
|
||||
expect(createProcessInspector('darwin', 'arm64', fake.internals).foregroundPgid(1)).toBeUndefined()
|
||||
expect(() => createProcessInspector('win32', 'x64', fake.internals)).toThrow('unsupported platform win32')
|
||||
})
|
||||
})
|
||||
@@ -1,62 +1,17 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { IDisposable, IPty } from 'node-pty'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { LocalPtySession } from '@deepseek-ai/dsh-pty-local/src/session.ts'
|
||||
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-local/src/config.ts'
|
||||
import type { ProcessIdentity, ProcessInspector } from '@deepseek-ai/dsh-pty-local/src/process-inspector.ts'
|
||||
import type { PtySendOperation, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty'
|
||||
|
||||
class FakeTerminal {
|
||||
pid = 123
|
||||
cols = 80
|
||||
rows = 24
|
||||
process = 'bash'
|
||||
handleFlowControl = false
|
||||
writes: string[] = []
|
||||
kills: string[] = []
|
||||
throwWrite = false
|
||||
throwKill = false
|
||||
autoExitOnKill = true
|
||||
private dataListeners = new Set<(data: string) => void>()
|
||||
private exitListeners = new Set<(event: { exitCode: number; signal?: number }) => void>()
|
||||
|
||||
readonly onData = (listener: (data: string) => void): IDisposable => {
|
||||
this.dataListeners.add(listener)
|
||||
return { dispose: () => this.dataListeners.delete(listener) }
|
||||
}
|
||||
|
||||
readonly onExit = (listener: (event: { exitCode: number; signal?: number }) => void): IDisposable => {
|
||||
this.exitListeners.add(listener)
|
||||
return { dispose: () => this.exitListeners.delete(listener) }
|
||||
}
|
||||
|
||||
emitData(data: string): void {
|
||||
for (const listener of this.dataListeners) listener(data)
|
||||
}
|
||||
|
||||
emitExit(exitCode = 0, signal?: number): void {
|
||||
for (const listener of this.exitListeners) listener({ exitCode, ...signal === undefined ? {} : { signal } })
|
||||
}
|
||||
|
||||
write(data: string): void {
|
||||
if (this.throwWrite) throw new Error('write failed')
|
||||
this.writes.push(data)
|
||||
}
|
||||
|
||||
kill(signal?: string): void {
|
||||
if (this.throwKill) throw new Error('kill failed')
|
||||
this.kills.push(signal ?? 'SIGHUP')
|
||||
if (this.autoExitOnKill) this.emitExit(0, signal === 'SIGKILL' ? 9 : 15)
|
||||
}
|
||||
|
||||
resize() {}
|
||||
clear() {}
|
||||
pause() {}
|
||||
resume() {}
|
||||
|
||||
asPty(): IPty {
|
||||
return this
|
||||
}
|
||||
}
|
||||
import type {
|
||||
SubprocessOutcome,
|
||||
SubprocessTerminalHandle,
|
||||
SubprocessTerminalSignal,
|
||||
} from '@deepseek-ai/dsh-subprocess'
|
||||
import type {
|
||||
ProcessIdentity,
|
||||
ProcessInspector,
|
||||
} from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
|
||||
|
||||
class FakeInspector implements ProcessInspector {
|
||||
pgid: number | undefined = 456
|
||||
@@ -84,6 +39,89 @@ class FakeInspector implements ProcessInspector {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
quiescent = true
|
||||
waitError: Error | 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: Uint8Array): Promise<void> {
|
||||
if (this.throwWrite) throw new Error('write failed')
|
||||
this.writes.push(Buffer.from(data).toString('utf8'))
|
||||
}
|
||||
|
||||
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(): void {
|
||||
if (this.throwKill) throw new Error('kill failed')
|
||||
this.kills.push('SIGTERM')
|
||||
if (this.autoExitOnKill) this.emitExit(0, 15)
|
||||
}
|
||||
|
||||
async waitForExit(): Promise<boolean> {
|
||||
if (this.waitError !== undefined) throw this.waitError
|
||||
return this.quiescent
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -108,13 +146,15 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
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 })
|
||||
expect(terminal.writes).toEqual(['python3', '\r'])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(terminal.writes).toEqual(['python3\r'])
|
||||
inspector.pgid = 789
|
||||
terminal.emitData('Python\r\n>>> ')
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
@@ -126,7 +166,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
inspector.waiting = true
|
||||
@@ -148,7 +188,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({
|
||||
const session = makeSession(terminal, inspector, config({
|
||||
exactProbeAfterMs: 50,
|
||||
idleSilenceMs: 100,
|
||||
timeoutMs: 200,
|
||||
@@ -173,7 +213,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
inspector.pgid = undefined
|
||||
|
||||
@@ -193,7 +233,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
|
||||
const exiting = session.startSend({ text: 'exit', submit: true })
|
||||
terminal.emitExit(7, 9)
|
||||
expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 7, signal: 'SIGKILL' } })
|
||||
expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGKILL' } })
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
|
||||
})
|
||||
|
||||
@@ -201,13 +241,15 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
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> ')
|
||||
@@ -229,14 +271,14 @@ describe('LocalPtySession readiness and output', () => {
|
||||
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.asPty(), new FakeInspector(), config())
|
||||
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.asPty(), new FakeInspector(), config())
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
await initialize(session, terminal)
|
||||
const operation = session.startSend({ text: '', submit: false })
|
||||
const operationInternal = operation as unknown as {
|
||||
@@ -247,11 +289,17 @@ describe('LocalPtySession readiness and output', () => {
|
||||
const sessionInternal = session as unknown as {
|
||||
pollReadiness(operation: PtySendOperation): void
|
||||
interrupt(operation: PtySendOperation): void
|
||||
schedulePoll(operation: PtySendOperation): void
|
||||
polling: boolean
|
||||
statusValue: PtySessionStatus
|
||||
appendOutput(text: string): void
|
||||
}
|
||||
sessionInternal.appendOutput('')
|
||||
sessionInternal.pollReadiness({} as PtySendOperation)
|
||||
sessionInternal.schedulePoll({} as PtySendOperation)
|
||||
sessionInternal.polling = true
|
||||
sessionInternal.schedulePoll(operation)
|
||||
sessionInternal.polling = false
|
||||
sessionInternal.interrupt({} as PtySendOperation)
|
||||
sessionInternal.statusValue = { kind: 'exited', exitCode: 2, signal: null }
|
||||
sessionInternal.pollReadiness(operation)
|
||||
@@ -259,13 +307,15 @@ describe('LocalPtySession readiness and output', () => {
|
||||
operationInternal.settle('timeout', { kind: 'running' }, false)
|
||||
|
||||
const unknownTerminal = new FakeTerminal()
|
||||
const unknown = new LocalPtySession(unknownTerminal.asPty(), new FakeInspector(), config())
|
||||
const unknown = new LocalPtySession(unknownTerminal, config())
|
||||
unknownTerminal.emitExit(1, 999)
|
||||
expect(unknown.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
|
||||
await vi.waitFor(() => {
|
||||
expect(unknown.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
|
||||
})
|
||||
|
||||
const cancelTerminal = new FakeTerminal()
|
||||
const cancelInspector = new FakeInspector()
|
||||
const cancel = new LocalPtySession(cancelTerminal.asPty(), cancelInspector, config())
|
||||
const cancel = makeSession(cancelTerminal, cancelInspector, config())
|
||||
await initialize(cancel, cancelTerminal)
|
||||
const cancellable = cancel.startSend({ text: '', submit: false })
|
||||
cancelInspector.throwGroup = true
|
||||
@@ -275,7 +325,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
|
||||
const missingGroupTerminal = new FakeTerminal()
|
||||
const missingGroupInspector = new FakeInspector()
|
||||
const missingGroup = new LocalPtySession(missingGroupTerminal.asPty(), missingGroupInspector, config())
|
||||
const missingGroup = makeSession(missingGroupTerminal, missingGroupInspector, config())
|
||||
await initialize(missingGroup, missingGroupTerminal)
|
||||
missingGroupInspector.pgid = undefined
|
||||
const unresolved = missingGroup.startSend({ text: '', submit: false })
|
||||
@@ -286,7 +336,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
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())
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
let settled = false
|
||||
const initializing = session.initialize().then(() => { settled = true })
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
@@ -296,7 +346,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
await initializing
|
||||
|
||||
const timeoutTerminal = new FakeTerminal()
|
||||
const timeout = new LocalPtySession(timeoutTerminal.asPty(), new FakeInspector(), config())
|
||||
const timeout = new LocalPtySession(timeoutTerminal, config())
|
||||
const timedOut = expect(timeout.initialize()).rejects.toThrow('startup timeout')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await timedOut
|
||||
@@ -306,7 +356,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.pgid = undefined
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('startup cancelled')
|
||||
|
||||
@@ -320,7 +370,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
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.asPty(), new FakeInspector(), config())
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
let settled = false
|
||||
const initializing = session.initialize().then(() => { settled = true })
|
||||
|
||||
@@ -338,7 +388,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const operation = session.startSend({ text: 'run', submit: true })
|
||||
@@ -359,7 +409,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({ handoffGraceMs: 40 }))
|
||||
const session = makeSession(terminal, inspector, config({ handoffGraceMs: 40 }))
|
||||
await initialize(session, terminal)
|
||||
|
||||
const operation = session.startSend({ text: 'run', submit: true })
|
||||
@@ -380,7 +430,7 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
const session = makeSession(terminal, inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const operation = session.startSend({ text: 'bash -i', submit: true })
|
||||
@@ -390,6 +440,156 @@ describe('LocalPtySession readiness and output', () => {
|
||||
|
||||
expect((await operation.done).waitReason).toBe('inferred_idle')
|
||||
})
|
||||
|
||||
it('contains terminal transport failures and preserves the first failure', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
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 })
|
||||
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('rejects invalid UTF-8 in a data chunk and at stream end', async () => {
|
||||
const chunkTerminal = new FakeTerminal()
|
||||
const chunkSession = new LocalPtySession(chunkTerminal, config())
|
||||
const chunkOperation = chunkSession.startSend({ text: '', submit: false })
|
||||
chunkTerminal.emitBytes(Uint8Array.from([0xff]))
|
||||
await expect(chunkOperation.done).rejects.toThrow('PTY emitted invalid UTF-8')
|
||||
|
||||
const endTerminal = new FakeTerminal()
|
||||
const endSession = new LocalPtySession(endTerminal, config())
|
||||
const endOperation = endSession.startSend({ text: '', submit: false })
|
||||
endTerminal.emitBytes(Uint8Array.from([0xe2]))
|
||||
endTerminal.emitExit()
|
||||
await expect(endOperation.done).rejects.toThrow('PTY ended with invalid UTF-8')
|
||||
})
|
||||
|
||||
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: PtySendOperation): 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: PtySendOperation | undefined
|
||||
pollReadiness(operation: PtySendOperation): 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: PtySessionStatus, inherited: boolean): void
|
||||
}).settle('timeout', { kind: 'running' }, false)
|
||||
})
|
||||
|
||||
it('contains stale timer, write, inspection, and interrupt continuations', async () => {
|
||||
vi.useFakeTimers()
|
||||
const settle = (operation: PtySendOperation): void => {
|
||||
;(operation as unknown as {
|
||||
settle(reason: 'timeout', status: PtySessionStatus, 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: PtySendOperation | 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: PtySendOperation | 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: PtySendOperation, 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: PtySendOperation | undefined
|
||||
pollReadiness(operation: PtySendOperation): 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: PtySendOperation | undefined }).active = undefined
|
||||
interruptGate.reject(new Error('stale interrupt failure'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
settle(interruptOperation)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
@@ -397,8 +597,7 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(
|
||||
terminal.asPty(),
|
||||
new FakeInspector(),
|
||||
terminal,
|
||||
config({ scrollbackLines: 3, scrollbackMaxBytes: 12, maxReadBytes: 6 }),
|
||||
)
|
||||
expect(session.read({})).toMatchObject({ text: '' })
|
||||
@@ -415,7 +614,7 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
expect(() => session.read({ count: 0 })).toThrow('count')
|
||||
|
||||
const tinyTerminal = new FakeTerminal()
|
||||
const tiny = new LocalPtySession(tinyTerminal.asPty(), new FakeInspector(), config({ maxReadBytes: 1 }))
|
||||
const tiny = new LocalPtySession(tinyTerminal, config({ maxReadBytes: 1 }))
|
||||
await initialize(tiny, tinyTerminal)
|
||||
const tinyOperation = tiny.startSend({ text: '', submit: false })
|
||||
tinyTerminal.emitData('一')
|
||||
@@ -427,32 +626,38 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
it('signals verified groups and refuses unresolved or shell-targeted hard kills', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
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('use terminal_close')
|
||||
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, contains signal races, and reports survivors', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.members = [{ pid: 123, started: 'a' }]
|
||||
inspector.alive.add(123)
|
||||
inspector.throwProcess = true
|
||||
terminal.throwKill = true
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 1 }))
|
||||
terminal.quiescent = false
|
||||
const session = new LocalPtySession(terminal, config({ disposeGraceMs: 1 }))
|
||||
const closing = session.close('test')
|
||||
expect(session.close('other')).toBe(closing)
|
||||
await expect(closing).rejects.toThrow('surviving pids: 123')
|
||||
await expect(closing).rejects.toThrow('did not reach quiescence')
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
|
||||
})
|
||||
|
||||
it('reports cleanup failure without waiting for top-level exit', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
terminal.autoExitOnKill = false
|
||||
terminal.waitError = new Error('terminal cleanup failed; surviving pids: 456')
|
||||
const session = new LocalPtySession(terminal, config())
|
||||
|
||||
await expect(session.close('survivor')).rejects.toThrow('surviving pids: 456')
|
||||
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.asPty(), new FakeInspector(), config({ disposeGraceMs: 50 }))
|
||||
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
|
||||
@@ -467,94 +672,4 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
await closing
|
||||
})
|
||||
|
||||
it('keeps the shell alive until SIGKILL recipients leave the process table', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.removeOnSignal = false
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 20 }))
|
||||
|
||||
let settled = false
|
||||
const closing = session.close('test').then(() => { settled = true })
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(inspector.processes).toContainEqual([124, 'SIGKILL'])
|
||||
expect(terminal.kills).toEqual([])
|
||||
expect(settled).toBe(false)
|
||||
|
||||
inspector.alive.delete(124)
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await closing
|
||||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('rescans for descendants forked during TERM before stopping the shell', async () => {
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
let reads = 0
|
||||
inspector.processTree = () => {
|
||||
reads += 1
|
||||
if (reads === 1) {
|
||||
inspector.alive.add(124)
|
||||
return [{ pid: 124, started: 'first' }]
|
||||
}
|
||||
if (reads === 2) {
|
||||
inspector.alive.add(125)
|
||||
return [{ pid: 125, started: 'late' }]
|
||||
}
|
||||
return []
|
||||
}
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
|
||||
await session.close('test')
|
||||
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [125, 'SIGKILL']])
|
||||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('retains captured survivors that are reparented out of the teardown rescan', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const captured = { pid: 124, started: 'captured' }
|
||||
let reads = 0
|
||||
inspector.alive.add(captured.pid)
|
||||
inspector.processTree = () => reads++ === 0 ? [captured] : []
|
||||
inspector.signalProcess = (identity, signal) => {
|
||||
inspector.processes.push([identity.pid, signal])
|
||||
if (signal === 'SIGKILL') inspector.alive.delete(identity.pid)
|
||||
}
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 20 }))
|
||||
|
||||
const closing = session.close('test')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await closing
|
||||
|
||||
expect(inspector.processes).toEqual([[124, 'SIGTERM'], [124, 'SIGKILL']])
|
||||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
|
||||
it('allows teardown to retry after a descendant-survivor failure', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.members = [{ pid: 124, started: 'child' }]
|
||||
inspector.alive.add(124)
|
||||
inspector.removeOnSignal = false
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config({ disposeGraceMs: 10 }))
|
||||
|
||||
const first = session.close('first')
|
||||
const rejected = expect(first).rejects.toThrow('surviving pids: 124')
|
||||
await vi.advanceTimersByTimeAsync(25)
|
||||
await rejected
|
||||
expect(terminal.kills).toEqual([])
|
||||
|
||||
inspector.alive.delete(124)
|
||||
const second = session.close('retry')
|
||||
expect(second).not.toBe(first)
|
||||
await second
|
||||
expect(terminal.kills).toEqual(['SIGTERM'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user