Skip to content

fix(providers): keep Node 26 requests working after a connection drops - #239

Merged
bman654 merged 1 commit into
mainfrom
fix/i233-h2-dispatcher
Sep 14, 2026
Merged

bman654 merged 1 commit into
mainfrom
fix/i233-h2-dispatcher

Conversation

@bman654

@bman654 bman654 commented Sep 14, 2026

Copy link
Copy Markdown
Owner

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 to
Upstream 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') as read ECONNRESET.

Root cause and reachability

Node 26's bundled undici 8 enables HTTP/2 for fetch(). In the verified failure shape, a peer
ends 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 connect event, and this branch did not install a replacement.

Change

  • Always install the pinned package undici dispatcher before CLI work. Use
    EnvHttpProxyAgent({ allowH2: false }) when proxy variables are present and
    Agent({ allowH2: false }) otherwise.
  • Preserve UpstreamUnreachableError.cause and append a missing network code from its cause chain
    to the displayed message, without duplicating a code already in the message.
  • Handle client-socket errors in passthrough CONNECT tunnels, including the malformed-authority
    400 reply, whose write could otherwise raise write EPIPE; destroying the client socket triggers
    the existing close handler that destroys the upstream socket.

This does not add an ERR_HTTP2_INVALID_SESSION retry. A retry would select the same poisoned
pooled 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 and
are 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 needs
npm i -g corepack or pnpm/action-setup and is still owed as a follow-up. The CLAUDE.md
invariants 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 installer
without proxy variables and observes 1.1. A second test routes an HTTPS fetch through a local
CONNECT 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 the
upstream socket to be destroyed.

Feature-deletion mutations were run as full-file tests:

  • deleting dispatcher installation: tests/outbound-proxy.test.ts failed 2/16 (HTTP/2 remained and
    the CONNECT proxy saw no request);
  • restoring the old error wrapper: tests/upstream-forward.test.ts failed 3/27;
  • deleting the CONNECT client error listener: tests/http-proxy-server.test.ts failed 1/39 with
    read ECONNRESET observed by the temporary uncaughtException listener; attaching it only after
    the 400 branch fails the malformed-authority test (write EPIPE);
  • ignoring a code carried directly on the cause: tests/upstream-forward.test.ts fails 2.

Runtime evidence

On macOS Darwin 25.6.0 arm64 with Node 24.14.1, proxy variables and NODE_EXTRA_CA_CERTS unset,
CLAUDE_CODE_ENTRYPOINT=cli, and an isolated CLODEX_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 main from the built dist/cli.js, ran its installer through
main(['--version']), instrumented node:http2.connect, and made five requests to
https://opencode.ai/. All five returned HTTP 200, zero HTTP/2 sessions were created, and zero
requests 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.ts help block. Open PR #48 touches later relay logic in
src/upstream-forward.ts and the same test file, but not this error class. There is no semantic
land 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

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
Comment thread tests/outbound-proxy.test.ts Dismissed
Comment thread tests/outbound-proxy.test.ts Dismissed
@bman654
bman654 merged commit 59810d0 into main Sep 14, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

opencode-go: "502 Cannot connect to API: The session has been destroyed" never recovers until the session is restarted

2 participants