fix: honour the web fetch timeout budget end to end (#249) - #265
Conversation
`--timeout` set a deadline the retry ladder respected, but the teardown did not: `proxy.close()` calls `server.close()`, which stays pending until every connection drains, and impit leaves keep-alive CONNECT tunnels open. A 5s budget against news.ycombinator.com took 68s — the ladder finished in 3.2s and the rest was the close waiting for the OS to drop the tunnels. The same dangling sockets then crashed the process with an unhandled 'error' event. Track every socket the proxy opens, including the upstream halves the HTTP server never sees, and destroy them in close(). Swallow socket errors so a destroyed peer cannot take down the process. Map an aborted fetch to the structured TimeoutError instead of leaking a DOMException. Measured after: 3.4s for the same command, and a host that never responds now fails at the deadline with `TIMEOUT: web fetch timed out after 3s`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🟢 No documentation gap found — medium confidenceThe automated review found no documentation gap in the supplied changes. This review is advisory and does not block merging. |
Maintainer review: changes requestedIssue #249 is valid, and the diagnosis is substantially correct. The retry ladder already shares a deadline, but Two lifetime/error gaps remain. 1. A DNS completion can create a socket after cleanupBoth the HTTP and CONNECT paths await DNS before creating their upstream connection.
There is no closed/closing state. If DNS resolution is still pending when cleanup starts, the handler can resume afterward and create/track a new upstream socket. Because the set was already drained, that late socket is not destroyed by the close pass and may again keep the process alive or issue an outbound connection after the fetch budget has expired. Please add a closing barrier:
The smallest regression test is an injected lookup that does not resolve until after 2. The timeout normalization test models the wrong error shape
With the pinned Impit version, a deadline can surface as a generic Please add a representative Impit-shaped timeout test (or a focused integration diagnostic using the pinned client), then normalize that known shape without turning unrelated network failures into timeouts. An elapsed-deadline check at the catch boundary may be safer than broad message matching, provided it cannot mislabel an early failure. Existing testsThe idle CONNECT test is useful and should stay: it proves that already-established tunnels no longer make The “closes the safe proxy when the ladder throws” test verifies the existing Scope and securityThis remains SSRF-sensitive code. The proposed socket destruction does not weaken address validation, but the late-resolution path must not be allowed to open a connection after teardown. Preserve DNS validation and all existing private-address checks. Today this affects client-owned Once the closing-state race and real timeout normalization are covered, the implementation direction is sound. |
…ines Mark the proxy closing before draining sockets: track() destroys anything presented afterwards, and both request paths re-check the flag after their awaited DNS lookup, so a resolution that lands during teardown can no longer dial upstream or leave an untracked handle behind. close() is now idempotent. Also treat any failure at or past the deadline as the structured TIMEOUT, since impit surfaces its own deadline as a generic Error; failures with budget left are passed through unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both gaps addressed in 1. Closing barrier for the late-DNS race
Regression test is the injected lookup you described: it parks the CONNECT handler mid-await, 2. Real timeout normalizationTook the elapsed-deadline approach rather than message matching: the existing Two tests: one rejecting with an impit-shaped generic Existing testsKept both — the idle CONNECT test and the ladder-throws test. Agreed neither substitutes for the delayed-lookup case. SecurityDNS validation and every private-address check are unchanged; the closing state only ever prevents a connection, never permits one. |
Fixes #249.
The problem
--timeoutis meant to bound the wholeweb fetchcommand. It didn't: a 10-second budget took 68 seconds and then crashed the process.Root cause
Not the retry ladder — that already honoured the deadline. Instrumenting
webFetchshows all the fetching finished in 3.2s:The other 65 seconds were spent closing the proxy.
proxy.close()waits for every connection to drain, and impit leaves connections open, so the cleanup infinallyignored the budget we had just enforced. Those same leftover connections later fired an unhandled'error'event, which killed the process instead of returning a usable error.What I changed
src/fetch/safe-proxy.ts— keep a list of every socket the proxy opens (including the upstream halves the HTTP server never sees) and destroy them all inclose(). Closing now returns immediately.src/fetch/safe-proxy.ts— socket'error'handlers destroy the socket instead of re-throwing, so a dead peer can no longer crash the process.src/fetch/client.ts— an aborted fetch (AbortError/TimeoutError) is now converted into our structuredTimeoutError, so hitting the budget givescode: TIMEOUTand theTEMPFAILexit code instead of a rawDOMException.Proof
web fetch --url https://news.ycombinator.com --timeout 5--timeout 3TimeoutError: web fetch timed out after 3sNew test
src/fetch/safe-proxy.test.tsopens a tunnel to a server that never replies and assertsclose()finishes in under a second — it hangs until the test timeout without this change. Two more inclient.test.ts: the proxy is closed when the ladder throws, and an aborted fetch surfaces asTIMEOUT.Full suite passes except
tests/e2e/plugin-management.test.ts, which fails identically onmain(fixed separately in #267).Not in this PR
FETCH_BLOCKED— that is a separate defect ([Bug]: web fetch challenge detection false-positives on CSP headers (news.ycombinator.com fails with FETCH_BLOCKED) #264, fixed in fix: stop CSP allow-lists triggering challenge detection (#264) #266). This PR bounds the time regardless of how many tiers run.timeoutcovers the request, not the body stream). Left aponytail:comment naming the limit rather than building a race for a case we have not seen.🤖 Generated with Claude Code