fix(runtime): close lifecycle teardown races

This commit is contained in:
Tianyi Cui
2026-07-29 02:40:02 +08:00
parent c17727d43d
commit 32bce339da
20 changed files with 233 additions and 36 deletions

View File

@@ -187,6 +187,51 @@ describe('LocalPtyBackend startup rollback', () => {
expect(initialized).toHaveBeenCalledWith(undefined)
})
it('forwards setup cancellation only while terminal allocation is unpublished', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const publishedController = new AbortController()
let publishedSignal: AbortSignal | undefined
const published = new LocalPtyBackend(
ctx,
config(),
async (spawnSpec) => {
publishedSignal = spawnSpec.signal
return terminalHandle()
},
() => stubLocalSession(),
)
await published.spawn(spec(agent(ctx), publishedController.signal))
expect(publishedSignal).toBeDefined()
expect(publishedSignal).not.toBe(publishedController.signal)
publishedController.abort(new Error('originating turn ended'))
expect(publishedSignal?.aborted).toBe(false)
const pendingController = new AbortController()
const seen = Promise.withResolvers<AbortSignal>()
const pending = new LocalPtyBackend(
ctx,
config(),
async spawnSpec => await new Promise<SubprocessTerminalHandle>((_resolve, reject) => {
const setupSignal = spawnSpec.signal as AbortSignal
seen.resolve(setupSignal)
const onAbort = (): void => {
reject(setupSignal.reason instanceof Error ? setupSignal.reason : new Error(String(setupSignal.reason)))
}
setupSignal.addEventListener('abort', onAbort, { once: true })
}),
() => stubLocalSession(),
)
const spawning = pending.spawn(spec(agent(ctx), pendingController.signal))
const pendingSignal = await seen.promise
const reason = new Error('cancel pending allocation')
pendingController.abort(reason)
await expect(spawning).rejects.toBe(reason)
expect(pendingSignal.aborted).toBe(true)
})
it('composes the default local session around a spawned terminal', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)

View File

@@ -1,4 +1,4 @@
import { mkdtempSync, realpathSync, rmSync } from 'node:fs'
import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
@@ -156,6 +156,47 @@ describe('pty-local real shell', () => {
expect(() => process.kill(pid, 0)).toThrow()
}, 10_000)
it('reaps a disowned same-session descendant after the shell exits naturally', async () => {
const { ctx, root, agent } = await harness('danger-full-access')
const created = await ctx.pty.spawn(agent, { type: 'shell' })
const pidFile = join(root, 'disowned.pid')
let pid: number | undefined
try {
const background = ctx.pty.startSend(agent, created.sessionId, {
text: `sh -c 'trap "" TERM; printf "%s" "$$" > "$1"; sleep 60' dsh "${pidFile}" & disown`,
submit: true,
})
await background.done
const pidDeadline = Date.now() + 2_000
let childPid = 0
while (childPid === 0 && Date.now() < pidDeadline) {
if (existsSync(pidFile)) childPid = Number(readFileSync(pidFile, 'utf8'))
if (childPid > 0) break
await new Promise(resolve => setTimeout(resolve, 10))
}
expect(existsSync(pidFile), ctx.pty.read(agent, created.sessionId, { offset: 0, count: 100 }).text).toBe(true)
expect(childPid).toBeGreaterThan(0)
pid = childPid
expect(() => process.kill(childPid, 0)).not.toThrow()
await ctx.pty.startSend(agent, created.sessionId, { text: 'exit', submit: true }).done
const deadline = Date.now() + 2_000
while (ctx.pty.list(agent)[0]?.status.kind !== 'exited' && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 10))
}
expect(ctx.pty.list(agent)[0]?.status.kind).toBe('exited')
await ctx.pty.kill(agent, created.sessionId)
expect(() => process.kill(childPid, 0)).toThrow()
} finally {
if (pid !== undefined) {
try {
process.kill(pid, 'SIGKILL')
} catch (_alreadyReaped) {
// Product cleanup is the expected path; this only contains a failed regression.
}
}
}
}, 10_000)
it('cancels a slow-starting raw-mode foreground process with a real SIGINT', async () => {
const { ctx, agent } = await harness('danger-full-access', {
idleSilenceMs: 10_000,

View File

@@ -27,6 +27,7 @@ class FakeInspector implements ProcessInspector {
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: PtySignal) {
if (this.throwGroup) throw new Error('group failed')
@@ -325,6 +326,32 @@ describe('LocalPtySession readiness and output', () => {
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 }
terminal.signalForeground = async () => { throw new Error('interrupt failed') }
const operation = session.startSend({ text: 'slow write', submit: true })
await Promise.resolve()
await Promise.resolve()
const rejected = expect(operation.done).rejects.toThrow('interrupt failed')
expect(operation.cancel()).toBe(true)
await rejected
expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow('active send')
writeGate.resolve(undefined)
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('handles startup exit, unknown exit signals, cancel-write failure, and stale polls', async () => {
vi.useFakeTimers()
const startupTerminal = new FakeTerminal()