// withDeadline — a settle guarantee for upstream calls.
//
// Both `doFetch` editions (server-bare.js over bare-http1, server.js over global fetch) reject only
// when the request emits an error. That covers a REFUSED connection (~5ms) but NOT an unreachable
// HOST: with packets blackholed, bare-http1's global agent (`agent.js:217`, timeout 5000) destroys
// the socket, and `client-connection.js:73-75` turns that into a bare `req.destroy()` — no error
// event, so nothing rejects and the proxied XRPC hangs until the caller gives up. Measured: still
// pending after 30s. Passing `timeout` per-request does not help either, because the agent merges
// its own opts LAST (`agent.js:128`), overwriting yours.
//
// So the deadline lives here, above the transport, and hands back an `onTimeout` hook so the caller
// can destroy the in-flight request rather than leak the socket.
export const UPSTREAM_TIMEOUT = 'UPSTREAM_TIMEOUT'
/**
* Race `promise` against a deadline.
*
* @param {Promise} promise the in-flight upstream call
* @param {number} timeoutMs deadline in ms; <= 0 (or non-finite) disables the guard
* @param {Function} [onTimeout] called ONCE if the deadline blows — destroy the request here
* @returns {Promise} the promise's value, or rejects with a UPSTREAM_TIMEOUT error
*/
export function withDeadline (promise, timeoutMs, onTimeout = null) {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise
return new Promise((resolve, reject) => {
let done = false
const timer = setTimeout(() => {
if (done) return
done = true
// Destroy the request FIRST, then reject: a caller that retries another upstream must not be
// racing a socket we have already given up on.
if (onTimeout) { try { onTimeout() } catch { /* a failed abort must not mask the timeout */ } }
const err = new Error(`upstream timed out after ${timeoutMs}ms`)
err.code = UPSTREAM_TIMEOUT
err.timeoutMs = timeoutMs
reject(err)
}, timeoutMs)
// NOT unref'd, deliberately. An unref'd deadline is skipped whenever nothing else holds the loop
// open — i.e. it stops guaranteeing a settle in exactly the idle case. It is always cleared below,
// so it outlives the call by nothing.
promise.then(
(v) => { if (done) return; done = true; clearTimeout(timer); resolve(v) },
// An upstream error is passed through VERBATIM. Masking ECONNREFUSED as a timeout would make
// "the box is off" and "the process is down" indistinguishable — they need different responses.
(e) => { if (done) return; done = true; clearTimeout(timer); reject(e) }
)
})
}
export default withDeadline