Просмотр исходного кода

test(ci): stabilize storage and subprocess lifecycle checks

imccyu 3 дней назад
Родитель
Сommit
3a445b802d

+ 30 - 12
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()

+ 77 - 61
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<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 () => {