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. |
…tier `webcmd web` lived in two places: `web fetch` was a hardcoded fast path in main.ts, and `web fetch-browser` was an adapter in clis/web that the published tarball never shipped (`clis/` is not in package.json `files`). The split produced a recurring class of bug rather than isolated ones. The whole ladder now lives in src/fetch and ships in dist/: - `web fetch` walks plain HTTP -> impit -> browser in one command. A blocked page is rendered and returned instead of raising an error that names a second command. `--browser false` opts out; escalation is local-mode only, since hosted mode executes adapters server-side. - `web fetch-browser` keeps the article-export pipeline (--output, --download-images, --wait-for, --diagnose) for callers who want files. - Both are registered in the core registry, so help, `list`, completions and the manifests carry them with no plugin installed. - The fast path stays for plain fetches but hands `-h`/`-f`/`--trace` to the registered command, and renders the standard error envelope instead of leaking a raw Node stack trace. clis/web is deleted; build-manifest now emits core-registered commands with no modulePath, since there is no adapter file under clis/ to resolve. Also fixes the challenge classifier, which flattened every response header into one string and grepped it. A CSP naming cdnjs.cloudflare.com, or `server: cloudflare` on a healthy 200, was read as a bot challenge — so example.com and news.ycombinator.com both burned two retries and failed with FETCH_BLOCKED. Header evidence is now limited to headers describing the response itself, and markers are split into decisive (cf-mitigated, "just a moment") vs corroborating (cloudflare, captcha), the latter requiring a 403/429/503. Fixes #246, #247, #252, #264 Fixes #283 (classifier half; the safe-proxy EPIPE half landed in #265) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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