fix(providers): keep Node 26 requests working after a connection drops - #239
Merged
Merged
Conversation
Node 26's bundled undici 8 negotiates HTTP/2 in fetch() and keeps a dead pooled session after a fatal TLS alert, so every later request to that origin failed until restart (#233). Always install package undici's dispatcher with HTTP/2 disabled, proxy env or not. Keep the network code in "Upstream unreachable" messages, and give the passthrough CONNECT tunnel's client socket an error listener so a client reset no longer reaches uncaughtException. Fixes #233
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Provider requests on Node 26 no longer get stuck failing until clodex restarts after one dropped
connection. Connection failures also report their underlying network code, and resetting a client
connection through clodex no longer emits a stray process-level exception.
User-visible failure
On Node 26, a provider origin could become unusable for the lifetime of the clodex process. Every
later request failed immediately with
ERR_HTTP2_INVALID_SESSION; the raw relay reduced this toUpstream unreachable: fetch failed, while the SDK path surfaced "The session has been destroyed."Restarting clodex was the only recovery. Separately, a client reset on a non-intercepted CONNECT
tunnel could reach
process.on('uncaughtException')asread ECONNRESET.Root cause and reachability
Node 26's bundled undici 8 enables HTTP/2 for
fetch(). In the verified failure shape, a peerends a
pooled HTTP/2 TLS connection with a fatal alert. Node marks that session destroyed but never emits
close, so undici does not evict it and every retry selects the same dead session.This is reachable in supported configurations because both OpenCode Go transports use the global
fetch dispatcher: the Anthropic-format raw relay and the OpenAI-compatible AI SDK path. Before this
change, clodex installed its pinned package dispatcher only when a proxy environment variable was
present, leaving proxy-less Node 26 processes on the bundled dispatcher. The CONNECT reset is a
separate proxy-tunnel bug: Node removes its server-side socket error handler after handing the
socket to the
connectevent, and this branch did not install a replacement.Change
undicidispatcher before CLI work. UseEnvHttpProxyAgent({ allowH2: false })when proxy variables are present andAgent({ allowH2: false })otherwise.UpstreamUnreachableError.causeand append a missing network code from its cause chainto the displayed message, without duplicating a code already in the message.
400 reply, whose write could otherwise raise
write EPIPE; destroying the client socket triggersthe existing close handler that destroys the upstream socket.
This does not add an
ERR_HTTP2_INVALID_SESSIONretry. A retry would select the same poisonedpooled session, while disabling HTTP/2 makes that session unreachable. It adds no SDK-path-specific
code: the AI SDK route is repaired by the same global dispatcher, because it calls global
fetch(
src/provider-factory.ts:45-52). The proxy-mode first-party Anthropic passthrough (https.Agent,src/http-proxy/server.ts:67) and the ChatGPT OAuth WebSocket (ws) use their own transports andare unaffected. The endpoint-mode Anthropic-format relay does move to the package dispatcher; that
is the same HTTP/1.1 it already used on Node 22 and 24.
It also does not add a Node 26 CI leg. Node 25+ no longer bundles corepack, so the current CI
recipe (
actions/setup-node+corepack enable) does not run on 26 unchanged; a Node 26 leg needsnpm i -g corepackorpnpm/action-setupand is still owed as a follow-up. TheCLAUDE.mdinvariants list gains a bullet so a routine cleanup does not re-gate the install on proxy env or
drop the explicit
allowH2: false(inert on undici 7, decisive on an undici 8 bump).Discriminating tests and mutations
The dispatcher test starts a local HTTP/2 TLS server with HTTP/1.1 fallback. It first installs an
HTTP/2-enabled dispatcher and observes
req.httpVersion === '2.0', then runs clodex's installerwithout proxy variables and observes
1.1. A second test routes an HTTPS fetch through a localCONNECT proxy and observes both the CONNECT target and HTTP/1.1 at the destination. The error tests
cover cause identity, the nested fetch-error code, no duplicate code, and a non-Error cause. The
tunnel test resets a real CONNECT client, observes no
uncaughtException, and waits for theupstream socket to be destroyed.
Feature-deletion mutations were run as full-file tests:
tests/outbound-proxy.test.tsfailed 2/16 (HTTP/2 remained andthe CONNECT proxy saw no request);
tests/upstream-forward.test.tsfailed 3/27;tests/http-proxy-server.test.tsfailed 1/39 withread ECONNRESETobserved by the temporaryuncaughtExceptionlistener; attaching it only afterthe 400 branch fails the malformed-authority test (
write EPIPE);tests/upstream-forward.test.tsfails 2.Runtime evidence
On macOS Darwin 25.6.0 arm64 with Node 24.14.1, proxy variables and
NODE_EXTRA_CA_CERTSunset,CLAUDE_CODE_ENTRYPOINT=cli, and an isolatedCLODEX_HOME:pnpm typecheck: passed;pnpm test: 118 files, 2,673 tests passed;pnpm build: passed.The full Vitest suite also passed on Node 26.8.2 in the same environment: 118 files and 2,673 tests.
A live Node 26.8.2 proof imported
mainfrom the builtdist/cli.js, ran its installer throughmain(['--version']), instrumentednode:http2.connect, and made five requests tohttps://opencode.ai/. All five returned HTTP 200, zero HTTP/2 sessions were created, and zerorequests failed.
Boundaries not verified
I could not reproduce the original CDN's fatal TLS alert against the live service or run an
authenticated OpenCode Go inference because no OpenCode Go credential was available. The live proof
therefore covers the production-built dispatcher and public origin, while the local TLS test covers
protocol selection deterministically.
Concurrent work
Open PR #237 touches an unrelated
src/cli.tshelp block. Open PR #48 touches later relay logic insrc/upstream-forward.tsand the same test file, but not this error class. There is no semanticland order; #48 may need to refresh its test imports after either ordering.
Failure and rollback
If dispatcher installation throws, clodex warns and continues with Node's existing dispatcher, as it
did before; startup remains available. Reverting this change restores the old transport selection,
error text, and CONNECT behavior, including the Node 26 poisoned-session exposure.
Fixes #233