Files
deepseek-harness/packages/util/timeout/tests/timeout.spec.ts
T
Dudu-0223 8615e019d3 feat(timeout): add dsh-timeout and converge bash + web_fetch onto it
Timeout timing/classification was re-implemented three ways across the
tool-bearing capabilities, with the fusion of timeout+cancel and the
timeout-vs-cancel reason recovery being the error-prone parts. Extract that
shared half into a zero-dependency @deepseek-ai/dsh-timeout library
(clampTimeout/deadline/timeoutOf/TimeoutReason) and leave the non-shareable
hard-kill in each capability, per the timeout-library RFC.

bash: run() owns the deadline; runBash drops its killTimer and no longer
classifies (SpawnSpec/SpawnOutcome lose timeoutMs/timedOut/aborted), so the
public timedOut/aborted booleans become mutually-exclusive first-abort
classifications. web_fetch: the hand-rolled controller/timer/listener/
signal.reason dance is replaced by provider-owned deadline/timeoutOf, keeping
the WEB_FETCH_TIMEOUT / WEB_ABORTED contract. fs stays timeout-free (README
states why).
2026-07-06 16:25:04 +08:00

161 lines
5.8 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout'
describe('TimeoutReason', () => {
it('is an Error carrying the code and elapsed ms', () => {
const reason = new TimeoutReason('BASH_TIMEOUT', 100)
expect(reason).toBeInstanceOf(Error)
expect(reason.name).toBe('TimeoutReason')
expect(reason.code).toBe('BASH_TIMEOUT')
expect(reason.timeoutMs).toBe(100)
expect(reason.message).toBe('BASH_TIMEOUT after 100ms')
})
})
describe('clampTimeout', () => {
it('fills the default when the hint is absent', () => {
expect(clampTimeout(undefined, 120_000, 600_000)).toBe(120_000)
})
it('caps the hint at max', () => {
expect(clampTimeout(999_999, 120_000, 600_000)).toBe(600_000)
})
it('keeps a valid hint under the cap', () => {
expect(clampTimeout(5_000, 120_000, 600_000)).toBe(5_000)
})
it('caps the default itself when the default exceeds max', () => {
// min(def, max) applies even with no hint — a misconfigured backend never
// exceeds its own cap.
expect(clampTimeout(undefined, 900_000, 600_000)).toBe(600_000)
})
it('rejects a non-finite hint with the caller-provided name', () => {
expect(() => clampTimeout(Number.NaN, 100, 200, 'bash-local: request.timeoutMs'))
.toThrow(/bash-local: request\.timeoutMs must be a positive finite number/)
expect(() => clampTimeout(Number.POSITIVE_INFINITY, 100, 200))
.toThrow(/timeoutMs must be a positive finite number/)
})
it('rejects a non-positive hint', () => {
expect(() => clampTimeout(0, 100, 200)).toThrow(/must be a positive finite number/)
expect(() => clampTimeout(-1, 100, 200)).toThrow(/must be a positive finite number/)
})
})
describe('deadline — timeout arm', () => {
afterEach(() => { vi.useRealTimers() })
it('aborts on timeout with a TimeoutReason after the elapsed ms', () => {
vi.useFakeTimers()
using d = deadline(undefined, 100, 'BASH_TIMEOUT')
expect(d.signal.aborted).toBe(false)
vi.advanceTimersByTime(100)
expect(d.signal.aborted).toBe(true)
const reason = timeoutOf(d.signal)
expect(reason).toBeInstanceOf(TimeoutReason)
expect(reason?.code).toBe('BASH_TIMEOUT')
expect(reason?.timeoutMs).toBe(100)
})
it('[Symbol.dispose] clears the timer so no abort fires afterward', () => {
vi.useFakeTimers()
const d = deadline(undefined, 100, 'BASH_TIMEOUT')
d[Symbol.dispose]()
vi.advanceTimersByTime(1_000)
expect(d.signal.aborted).toBe(false)
expect(timeoutOf(d.signal)).toBeUndefined()
})
})
describe('deadline — fuse with upstream', () => {
it('aborts on upstream cancellation, classified as NOT a timeout', () => {
const upstream = new AbortController()
using d = deadline(upstream.signal, 60_000, 'BASH_TIMEOUT')
upstream.abort('user cancelled')
expect(d.signal.aborted).toBe(true)
expect(timeoutOf(d.signal)).toBeUndefined()
})
it('cancel wins when it fires before the timeout', () => {
vi.useFakeTimers()
try {
const upstream = new AbortController()
using d = deadline(upstream.signal, 100, 'BASH_TIMEOUT')
upstream.abort('user cancelled') // fires first, before the 100ms timer
vi.advanceTimersByTime(200)
expect(d.signal.aborted).toBe(true)
// AbortSignal.any adopts the FIRST source's reason: cancel won, so no
// TimeoutReason even though the timer later elapsed.
expect(timeoutOf(d.signal)).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
it('timeout wins when it fires before upstream cancellation', () => {
vi.useFakeTimers()
try {
const upstream = new AbortController()
using d = deadline(upstream.signal, 100, 'WEB_FETCH_TIMEOUT')
vi.advanceTimersByTime(100) // timer fires first
upstream.abort('too late')
expect(timeoutOf(d.signal)?.code).toBe('WEB_FETCH_TIMEOUT')
} finally {
vi.useRealTimers()
}
})
it('forwards a pre-aborted upstream signal immediately', () => {
const upstream = new AbortController()
upstream.abort('already gone')
using d = deadline(upstream.signal, 60_000, 'BASH_TIMEOUT')
expect(d.signal.aborted).toBe(true)
expect(timeoutOf(d.signal)).toBeUndefined()
})
})
describe('deadline — timeoutMs <= 0 (no-timeout sentinel)', () => {
afterEach(() => { vi.useRealTimers() })
it('arms no timer and forwards only the upstream signal', () => {
vi.useFakeTimers()
const upstream = new AbortController()
using d = deadline(upstream.signal, 0, 'BASH_TIMEOUT')
vi.advanceTimersByTime(1_000_000)
expect(d.signal.aborted).toBe(false) // no timer ever armed
upstream.abort('kill')
expect(d.signal.aborted).toBe(true)
expect(timeoutOf(d.signal)).toBeUndefined() // never a timeout
})
it('returns a never-aborting signal with a no-op disposer when there is no upstream', () => {
vi.useFakeTimers()
const d = deadline(undefined, 0, 'BASH_TIMEOUT')
expect(() => { d[Symbol.dispose]() }).not.toThrow()
vi.advanceTimersByTime(1_000_000)
expect(d.signal.aborted).toBe(false)
expect(timeoutOf(d.signal)).toBeUndefined()
})
it('treats a negative timeout the same as zero', () => {
const d = deadline(undefined, -5, 'BASH_TIMEOUT')
expect(d.signal.aborted).toBe(false)
d[Symbol.dispose]()
})
})
describe('timeoutOf', () => {
it('classifies a bare reason carrier that holds a TimeoutReason', () => {
const reason = new TimeoutReason('WEB_FETCH_TIMEOUT', 50)
expect(timeoutOf({ reason })).toBe(reason)
})
it('returns undefined for a non-timeout reason', () => {
expect(timeoutOf({ reason: new Error('other') })).toBeUndefined()
expect(timeoutOf({ reason: 'user cancelled' })).toBeUndefined()
expect(timeoutOf({})).toBeUndefined()
})
})