Files
deepseek-harness/packages/util/timeout/tests/timeout.spec.ts
T
Dudu-0223 6beed9a883 test: make the timeout-wins race deterministic under fake timers
CI (node 24/26) failed on the exact-boundary construction: advanceTimersByTime(100)
then an immediate upstream.abort() let the manual abort win the race on some
runtimes, so timeoutOf returned undefined. Advance unambiguously past the deadline
and assert the timeout classification before firing the late abort — that late
abort is now asserted as a no-op, which is the real first-cause-wins invariant.
2026-07-06 19:55:46 +08:00

186 lines
7.2 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(150) // past the 100ms deadline: the timer fires first
expect(d.signal.aborted).toBe(true)
expect(timeoutOf(d.signal)?.code).toBe('WEB_FETCH_TIMEOUT')
// A later upstream abort is a no-op on the already-aborted fused signal:
// AbortSignal.any keeps the FIRST cause, so the timeout classification stands.
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()
})
it('matches only the requested code when one is given', () => {
const reason = new TimeoutReason('BASH_TIMEOUT', 100)
expect(timeoutOf({ reason }, 'BASH_TIMEOUT')).toBe(reason)
expect(timeoutOf({ reason }, 'WEB_FETCH_TIMEOUT')).toBeUndefined()
})
})
describe('deadline — nested deadlines', () => {
it("does not misclassify an outer deadline's timeout as the inner code", () => {
// The upstream handed to the inner deadline is ITSELF a deadline that has
// already timed out (outer). AbortSignal.any preserves the outer reason;
// scoping timeoutOf to the inner code keeps the inner capability from
// reporting the outer timeout as its own — it reads as an upstream cancel.
const outer = new AbortController()
outer.abort(new TimeoutReason('OUTER_TIMEOUT', 30))
using inner = deadline(outer.signal, 60_000, 'BASH_TIMEOUT')
expect(inner.signal.aborted).toBe(true)
expect(timeoutOf(inner.signal, 'BASH_TIMEOUT')).toBeUndefined() // not ours → upstream-cancel path
expect(timeoutOf(inner.signal)?.code).toBe('OUTER_TIMEOUT') // but IS a timeout, unscoped
})
})