Files
deepseek-harness/packages/hooks/hook-protocol/tests/detached.spec.ts
T
Tianyi Cui 9ca31ab193 fix(hooks): drain detached hook runs on bridge dispose
The emit-shaped hook points (SessionStart, SubagentStart, SubagentStop)
run fire-and-forget: no seam awaits the run chain, so disposing a bridge
could strand a live hook process and let a late continuation inject into
a disposed context. The floating continuation also made the coverage
gate racy: the only coverage of the SubagentStart continuation's
no-context branch arm rode on an un-awaited .then, and on a loaded CI
runner the fork's per-file coverage snapshot beat it — master run
28798191671 failed the 100% branch gate on hooks-claude/src/index.ts at
99.03% (uncovered line 336) with the identical tree passing the PR run
three minutes earlier.

New shared primitive createDetachedRuns() in dsh-hook-protocol: a bridge
tracks each detached run chain, passes the tracker's abort signal to
runHook, and registers drain() as its effect disposer — drain aborts
still-running hook processes (a kill via the bash seam, not a wait out
to the 10-minute default hook timeout), then resolves once every chain
has settled. fiber.dispose() resolving now means the bridge's detached
work is quiescent (docs/defensive-patterns.md).

The subagent marker test disposes the bridge as its sync point, so the
formerly racy branch arm is executed deterministically before the file's
coverage snapshot; new tests pin abort-on-dispose promptness for both
bridges and the tracker's settle/drain contract in hook-protocol.
2026-07-06 23:27:54 +08:00

69 lines
2.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { createDetachedRuns } from '@deepseek-ai/dsh-hook-protocol'
/** A promise settled from outside, so a test controls exactly when a tracked run finishes. */
function deferred(): { promise: Promise<void>; resolve: () => void; reject: (error: Error) => void } {
let resolve!: () => void
let reject!: (error: Error) => void
const promise = new Promise<void>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
describe('createDetachedRuns', () => {
it('starts with an unfired signal; drain fires it (so still-running hook processes get killed)', async () => {
const detached = createDetachedRuns()
expect(detached.signal.aborted).toBe(false)
await detached.drain()
expect(detached.signal.aborted).toBe(true)
expect(String(detached.signal.reason)).toContain('hook bridge disposed')
})
it('drain with nothing tracked resolves immediately', async () => {
await expect(createDetachedRuns().drain()).resolves.toBeUndefined()
})
it('drain waits for a tracked run to settle', async () => {
const detached = createDetachedRuns()
const run = deferred()
detached.track(run.promise)
let drained = false
const draining = detached.drain().then(() => { drained = true })
// Give the drain every chance to (wrongly) resolve before the run settles.
await new Promise(resolve => setTimeout(resolve, 10))
expect(drained).toBe(false)
run.resolve()
await draining
expect(drained).toBe(true)
})
it('drain waits for a run tracked WHILE a prior wave was settling', async () => {
const detached = createDetachedRuns()
const first = deferred()
const second = deferred()
detached.track(first.promise)
// The late run enters the registry from the first run's own continuation —
// after drain() snapshotted its first wave.
void first.promise.then(() => { detached.track(second.promise) })
let drained = false
const draining = detached.drain().then(() => { drained = true })
first.resolve()
await new Promise(resolve => setTimeout(resolve, 10))
expect(drained).toBe(false)
second.resolve()
await draining
expect(drained).toBe(true)
})
it('a rejected tracked run is absorbed by the settlement bookkeeping (drain still resolves)', async () => {
const detached = createDetachedRuns()
const run = deferred()
detached.track(run.promise)
// The caller-side handler every bridge attaches; the tracker's own
// bookkeeping must not depend on it, but an UNHANDLED rejection would fail
// the test run, which is exactly the guarantee under test.
run.promise.catch(() => {})
run.reject(new Error('hook run boom'))
await expect(detached.drain()).resolves.toBeUndefined()
})
})