mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-09 04:02:35 +00:00
test(ci): stabilize storage and subprocess lifecycle checks
This commit is contained in:
@@ -124,31 +124,49 @@ describe('sqlite backend specifics', () => {
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
// Repeated durable DDL across four connections needs an instrumented I/O budget.
|
||||
it('leaves a failed materialization unstamped so a repaired medium reopens', async () => {
|
||||
const path = await freshDbPath()
|
||||
// Obstruct table creation: an index squatting on the unit_globals name
|
||||
// makes CREATE TABLE IF NOT EXISTS throw AFTER the units table exists.
|
||||
const setup = new DatabaseSync(path)
|
||||
setup.exec('CREATE TABLE squatter (x TEXT)')
|
||||
setup.exec('CREATE INDEX unit_globals ON squatter(x)')
|
||||
setup.close()
|
||||
try {
|
||||
setup.exec('CREATE TABLE squatter (x TEXT)')
|
||||
setup.exec('CREATE INDEX unit_globals ON squatter(x)')
|
||||
} finally {
|
||||
setup.close()
|
||||
}
|
||||
|
||||
const broken = backendAt(path)
|
||||
await expect(broken.kv.open(DESCRIPTOR)).rejects.toThrow(/already an index/)
|
||||
await broken.close()
|
||||
try {
|
||||
await expect(broken.kv.open(DESCRIPTOR)).rejects.toThrow(/already an index/)
|
||||
} finally {
|
||||
await broken.close()
|
||||
}
|
||||
|
||||
// Clear the obstruction; the medium must still be version 0, not a
|
||||
// half-materialized database stamped as current.
|
||||
const repair = new DatabaseSync(path)
|
||||
expect((repair.prepare('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(0)
|
||||
repair.exec('DROP INDEX unit_globals')
|
||||
repair.close()
|
||||
try {
|
||||
expect((repair.prepare('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(0)
|
||||
expect(repair.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'units'").get())
|
||||
.toEqual({ name: 'units' })
|
||||
repair.exec('DROP INDEX unit_globals')
|
||||
expect(repair.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'unit_globals'").get())
|
||||
.toBeUndefined()
|
||||
} finally {
|
||||
repair.close()
|
||||
}
|
||||
|
||||
const backend = backendAt(path)
|
||||
const unit = await backend.kv.open(DESCRIPTOR)
|
||||
await unit.putRecord('records', 'k', { n: 1 })
|
||||
await backend.close()
|
||||
})
|
||||
try {
|
||||
const unit = await backend.kv.open(DESCRIPTOR)
|
||||
await unit.putRecord('records', 'k', { n: 1 })
|
||||
expect((await unit.loadAll()).tables['records']).toEqual({ k: { n: 1 } })
|
||||
} finally {
|
||||
await backend.close()
|
||||
}
|
||||
}, 90_000)
|
||||
|
||||
it('rejects unparsable stored JSON with malformed-medium', async () => {
|
||||
const path = await freshDbPath()
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
|
||||
import * as acp from '../src/index.ts'
|
||||
import * as acpRun from '../src/run.ts'
|
||||
import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, disposeAcpChild, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
|
||||
@@ -941,40 +942,54 @@ describe('dsh-subagent-acp', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async ({ task }) => {
|
||||
// The child traps SIGTERM and keeps its event loop alive, so a graceful
|
||||
// term alone would hang dispose forever. With a short grace, dispose must
|
||||
// escalate to SIGKILL and return once the process is actually gone.
|
||||
it('dispose reaps a child that ignores EOF with the host force-termination outcome', async ({ onTestFinished, task }) => {
|
||||
// POSIX must escalate past the armed SIGTERM trap; Windows force-terminates
|
||||
// the process tree without delivering a catchable signal.
|
||||
let child: ReturnType<typeof spawnSubprocess> | undefined
|
||||
const active: { run?: Awaited<ReturnType<typeof startAcpRun>> } = {}
|
||||
let exited = false
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-trap-'))
|
||||
const ready = join(tmp, 'trap-armed')
|
||||
try {
|
||||
const spec: AcpRunSpec = {
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready },
|
||||
// Short on BOTH tiers: the trap ignores EOF and SIGTERM, so dispose must
|
||||
// burn the EOF window, then the SIGTERM window, then SIGKILL — keep each
|
||||
// small so the whole ladder finishes well within the 4000ms bound.
|
||||
disposeEofGraceMs: 150,
|
||||
disposeGraceMs: 150,
|
||||
spawn: spawnSubprocess,
|
||||
onTestFinished(async () => {
|
||||
try {
|
||||
if (!exited) child?.terminateForHostExit()
|
||||
const cleanup = await Promise.allSettled([active.run?.dispose(), child?.waitForExit(), child?.done])
|
||||
const failures: unknown[] = []
|
||||
for (const result of cleanup) {
|
||||
if (result.status === 'rejected') failures.push(result.reason)
|
||||
}
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'ACP test cleanup failed')
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
const run = await startAcpRun(request(), spec)
|
||||
// Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a
|
||||
// sleep) — otherwise SIGTERM races the trap install and the default handler
|
||||
// terminates the child, never exercising the escalation.
|
||||
await waitForFile(ready, task.timeout)
|
||||
// Don't await result (the child hangs). Dispose must still return promptly
|
||||
// via the SIGKILL escalation — bound it so a regression (no escalation)
|
||||
// fails loud instead of hanging the suite.
|
||||
await expect(Promise.race([
|
||||
run.dispose(),
|
||||
new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return — no SIGKILL escalation')) }, 4000) }),
|
||||
])).resolves.toBeUndefined()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
})
|
||||
const ready = join(tmp, 'trap-armed')
|
||||
const spec: AcpRunSpec = {
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
cwd: process.cwd(),
|
||||
permission: 'reject',
|
||||
env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready },
|
||||
disposeEofGraceMs: 150,
|
||||
disposeGraceMs: 150,
|
||||
spawn: (spec) => {
|
||||
child = spawnSubprocess(spec)
|
||||
return child
|
||||
},
|
||||
}
|
||||
const run = await startAcpRun(request(), spec)
|
||||
active.run = run
|
||||
await waitForFile(ready, task.timeout)
|
||||
// Process reaping uses the lane's test/hook budget, not a second grace timer.
|
||||
await expect(run.dispose()).resolves.toBeUndefined()
|
||||
expect(await child!.waitForExit()).toBe(true)
|
||||
exited = true
|
||||
const outcome = await child!.done
|
||||
if (process.platform === 'win32') {
|
||||
expect(outcome.signal).toBeNull()
|
||||
expect(outcome.exitCode).not.toBeNull()
|
||||
expect(outcome.exitCode).not.toBe(0)
|
||||
} else {
|
||||
expect(outcome.signal).toBe('SIGKILL')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1375,37 +1390,38 @@ describe('dsh-subagent-acp', () => {
|
||||
expect(errors).toEqual([rawMessage])
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async ({ task }) => {
|
||||
// Same trap scenario as the direct startAcpRun escalation test, but the
|
||||
// graces arrive via the PLUGIN CONFIG through the registered provider — so a
|
||||
// regression that stops threading config into AcpRunSpec (falling back to
|
||||
// the 6s/3s defaults) blows past the 4000ms bound and fails loud.
|
||||
it('plugin-config dispose graces reach the real ACP run', async ({ onTestFinished, task }) => {
|
||||
const ctx = new Context()
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'acp-cfg-trap-'))
|
||||
onTestFinished(async () => {
|
||||
try {
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
const start = vi.spyOn(acpRun, 'startAcpRun')
|
||||
onTestFinished(() => { start.mockRestore() })
|
||||
const ready = join(tmp, 'trap-armed')
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
permission: 'reject',
|
||||
env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready },
|
||||
disposeEofGraceMs: 150,
|
||||
disposeGraceMs: 150,
|
||||
})
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
await waitForFile(ready, task.timeout)
|
||||
await expect(Promise.race([
|
||||
run.dispose(),
|
||||
new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return — config graces not threaded to the run')) }, 4000) }),
|
||||
])).resolves.toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
rmSync(tmp, { recursive: true, force: true })
|
||||
}
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(acp, {
|
||||
providerName: 'acp',
|
||||
command: process.execPath,
|
||||
args: [mockServer],
|
||||
permission: 'reject',
|
||||
env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready },
|
||||
disposeEofGraceMs: 150,
|
||||
disposeGraceMs: 150,
|
||||
})
|
||||
const run = await ctx.subagents.start('acp', request())
|
||||
expect(start).toHaveBeenCalledExactlyOnceWith(expect.anything(), expect.objectContaining({
|
||||
disposeEofGraceMs: 150,
|
||||
disposeGraceMs: 150,
|
||||
}))
|
||||
await waitForFile(ready, task.timeout)
|
||||
await expect(run.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a dispose grace outside the Node timer range at load', async () => {
|
||||
|
||||
Reference in New Issue
Block a user