From 3a445b802ddb0bb3797bb83567232ae8a34153bb Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:14:13 +0800 Subject: [PATCH] test(ci): stabilize storage and subprocess lifecycle checks --- .../tests/sqlite-backend.spec.ts | 42 ++++-- .../subagent-acp/tests/subagent-acp.spec.ts | 138 ++++++++++-------- 2 files changed, 107 insertions(+), 73 deletions(-) diff --git a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts index f2fd65ee42..c5f5dd4ee3 100644 --- a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts +++ b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts @@ -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() diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index d88856a13e..1de31c00ae 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -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 | undefined + const active: { run?: Awaited> } = {} + 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 () => {