// withDeadline — the settle guarantee both doFetch editions were missing.
//
// WHY THIS EXISTS: `doFetch` rejects only on the request's 'error' event. When the upstream HOST is
// gone (VPN blackhole — packets dropped, no RST), bare-http1's global agent times the socket out at
// 5s and destroys it, which reaches the request as a plain `destroy()` with NO error. Nothing rejects,
// so the promise dangles and the proxied XRPC hangs until the CLIENT gives up. Measured against a
// blackholed host: still pending after 30s. A connection REFUSED rejects in ~5ms, so the failure is
// specific to "host unreachable", which is exactly the tabertron-is-off case.
//
// The fix is a deadline that (a) rejects, and (b) hands back a hook so the caller can destroy the
// in-flight request instead of leaking the socket.
import test from 'brittle'
import { withDeadline, UPSTREAM_TIMEOUT } from '../lib/deadline.js'
const never = () => new Promise(() => {})
const after = (ms, v) => new Promise((resolve) => setTimeout(() => resolve(v), ms))
test('passes a value through when it settles inside the deadline', async (t) => {
const r = await withDeadline(after(5, 'ok'), 200)
t.is(r, 'ok', 'the resolved value is returned untouched')
})
test('rejects with a typed timeout error when it does not', async (t) => {
await t.exception(() => withDeadline(never(), 30), /timed out/i, 'rejects rather than dangling')
try {
await withDeadline(never(), 30)
} catch (e) {
t.is(e.code, UPSTREAM_TIMEOUT, 'carries a code the proxy can branch on (failover vs pass-through)')
t.is(e.timeoutMs, 30, 'reports the deadline it blew')
}
})
test('calls the abort hook exactly once on timeout — the socket must not leak', async (t) => {
let aborts = 0
await t.exception(() => withDeadline(never(), 30, () => { aborts++ }), /timed out/i)
await after(60)
t.is(aborts, 1, 'aborted once, and not again after the rejection')
})
test('does NOT abort when the call finishes in time', async (t) => {
let aborts = 0
const r = await withDeadline(after(5, 'ok'), 200, () => { aborts++ })
await after(250) // outlive the original deadline — a cleared timer must not fire
t.is(r, 'ok')
t.is(aborts, 0, 'timer cleared on success')
})
test('an upstream error passes through unchanged, never masked as a timeout', async (t) => {
const boom = Object.assign(new Error('ECONNREFUSED'), { code: 'ECONNREFUSED' })
try {
await withDeadline(Promise.reject(boom), 200)
t.fail('should have rejected')
} catch (e) {
t.is(e.code, 'ECONNREFUSED', 'the real cause survives — refused is not a timeout')
}
})
test('a non-positive deadline disables the guard (opt-out stays possible)', async (t) => {
const r = await withDeadline(after(20, 'ok'), 0)
t.is(r, 'ok', 'ms<=0 means no deadline')
})