Repository navigation
Share one uTLS HTTP/2 connection per proxy across requests - #1
Conversation
Each Codex/Claude request built a fresh uTLS round tripper, so no connection was reused and every request left an idle proxy tunnel open until the server closed it (~7 min). Keep one round tripper per proxy URL for the process and close idle HTTP/2 connections after 90s, with 30s/15s health pings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0b5e416593
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| IdleConnTimeout: 90 * time.Second, | ||
| ReadIdleTimeout: 30 * time.Second, | ||
| PingTimeout: 15 * time.Second, |
There was a problem hiding this comment.
Remove post-connection HTTP/2 timeouts
For an established upstream connection that receives no frames for 30 seconds and then fails to acknowledge a ping within 15 seconds, these settings close the connection even if inference requests are still active, aborting all streams sharing it. This introduces a transport-imposed failure deadline into the Claude/Codex paths that deliberately construct clients with a zero timeout. Remove these post-connection deadlines; the repository explicitly prohibits them outside its enumerated exceptions, which do not include this HTTP/2 transport.
AGENTS.md reference: AGENTS.md:L59-L59
Useful? React with 👍 / 👎.
| } | ||
|
|
||
| var utlsRT http.RoundTripper = newUtlsRoundTripper(proxyURL) | ||
| var utlsRT http.RoundTripper = sharedUtlsRoundTripper(proxyURL) |
There was a problem hiding this comment.
Key shared connections by hostname and port
When two requests use the same proxy and protected hostname but different ports, the new process-wide transport reuses the first port's connection for the second request. RoundTrip computes the correct destination address, but getOrCreateConnection indexes connections and pending only by hostname, ignoring that address on a cache hit. This now affects independently constructed clients, including Claude credentials with different configured base_url ports, sending requests to the wrong upstream listener. Key connections and pending dials by the full destination address before sharing the transport across requests.
Useful? React with 👍 / 👎.
| } | ||
|
|
||
| var utlsRT http.RoundTripper = newUtlsRoundTripper(proxyURL) | ||
| var utlsRT http.RoundTripper = sharedUtlsRoundTripper(proxyURL) |
There was a problem hiding this comment.
Prevent a stalled dial from blocking all shared clients
If one connection attempt stalls during proxy CONNECT or the TLS handshake, sharing this transport makes every subsequent request to that hostname through the same proxy wait indefinitely, even if new connections would succeed. getOrCreateConnection leaves a pending[host] entry until createConnection returns, while that operation uses context-free Dial and Handshake, and waiting requests use an unconditional cond.Wait() that cannot observe cancellation. Previously, independently created clients did not share this blockage; now one stalled attempt can wedge all credentials using that proxy until restart. Make connection establishment and pending waits cancellation-aware before sharing this state process-wide.
Useful? React with 👍 / 👎.
| } | ||
|
|
||
| var utlsRT http.RoundTripper = newUtlsRoundTripper(proxyURL) | ||
| var utlsRT http.RoundTripper = sharedUtlsRoundTripper(proxyURL) |
There was a problem hiding this comment.
Reserve stream capacity before sharing an HTTP/2 connection
When concurrent requests race for the last available stream on a shared connection, both can pass getOrCreateConnection's CanTakeNewRequest() check before either starts its stream. The first request consumes the remaining capacity, and the second receives http2: client conn not usable from ClientConn.RoundTrip; this wrapper returns that error without retrying. Process-wide sharing introduces this race between otherwise independent Claude/Codex requests, whereas previously each client had its own connection. Use ReserveNewRequest() when selecting a connection, including newly created connections, so capacity selection and reservation are atomic.
Useful? React with 👍 / 👎.
…ections Review fixes for the shared uTLS connection: - One http2.Transport per proxy URL with a ConnPool that dials once per host:port, detached from any one request and bounded at 30s, and lets each waiting request give up on its own context. The transport supplies stream reservation and retries for GOAWAY, REFUSED_STREAM and unusable connections; a cancelled stream no longer drops the connection. - Proxy CONNECT dialer honours the context (dial, TLS, CONNECT exchange). - Fail loudly when ALPN is not h2. - Invalid proxy URLs fail every request instead of silently going direct or to the default transport, and are never cached. - Share the standard proxy transport per proxy URL too. - Claude OAuth client uses the shared uTLS transport; its copy is removed. - Tests go through NewUtlsHTTPClient with a fresh client per request. - Release 7.2.62+edgee.8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…xy-url early - Never read or close the body of a 2xx CONNECT reply; framing headers are ignored per RFC 9110 9.3.6 so tunnel bytes stay intact. - Treat a passed dial deadline as context.DeadlineExceeded even if ctx has not recorded it yet. - proxyutil.FailingTransport replaces the two error round-trippers and closes the request body. - Codex/xAI websocket dialer and the per-auth round-tripper provider fail on an invalid proxy instead of going direct. - proxy-url is validated at config load and on management PUT/PATCH and auth-file edits (400). - uTLS test root CAs are copied into the pool under the shared mutex. - Version strings and README rebase range bumped to edgee.8; release gate covers proxyutil, auth/claude and the shared-connection race tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AGENTS.md forbids timeouts after a connection is established; idle agents must keep their connection. The server's own idle close (chatgpt.com ~6.5 min) is redialled transparently on the next request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tighten proxy-url checks - Retire a shared HTTP/2 connection when a request's context ends and the connection received no bytes meanwhile (dead tunnel), without any timer. - Replay an idempotent request once after an abrupt close (unexpected EOF). - Close the request body when no connection was obtained. - Key shared transports by the normalised proxy setting. - Wrap shared dial errors as "utls: shared dial to <addr> failed". - proxyutil.Parse requires a hostname, a 1-65535 port when given, and a port for SOCKS5. - util.SetProxy and the Antigravity model fetch fail loudly on a bad proxy. - Auth-file PATCH validates the whole patch (proxy_url must be a valid string, no nested writes) before mutating a deep copy; uploads reject a bad proxy_url with 400. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both shared pools are keyed by the normalised proxy setting plus the auth ID, so subscription accounts never share a connection. Callers with no auth (config-level clients, login flows) use their own key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… connections, tighten proxy checks - Send requests on the shared connections directly: retry only what the server confirmed it never processed (unusable conn, GOAWAY above last stream, REFUSED_STREAM); replay an abrupt close or PROTOCOL_ERROR once, only for idempotent or keyed requests. - Close each attempt's body exactly once, including rewound retry bodies. - A request whose context already ended reserves nothing and never evicts a connection. - Claude token refreshes use the account's own shared connection. - Drop an account's shared transports when it is removed or its proxy changes. - Reject malformed host:port in proxy URLs (socks5://h:1080:80). - A null proxy_url in an auth-file PATCH clears the live proxy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…peer PROTOCOL_ERROR - Remove OnAuthReleased and the per-account pool release: it raced in-flight requests and global proxy changes. Upstreams close idle tunnels themselves, so a stale pool holds memory. - Wait x/net's 1s, 2s, 4s... (plus jitter) between retries, stopping when the request ends. - Replay after PROTOCOL_ERROR only when the server sent the reset; a malformed response detected locally means the server already answered. - Drop the not-established branch: x/net already removes that connection from the pool. - Tests for GOAWAY below/covering a POST, an idempotency-keyed POST, a malformed response and the backoff schedule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Restore MarkDead on 'client conn could not be established': x/net leaves such a conn reservable, so every later request failed on it without redialling. - Rewind the body before the backoff wait, return a GetBody failure as its own error, and close the rewound body if the wait is cancelled. - Tests: redial after a conn dies before its first stream; the RoundTrip backoff is used and stops on cancel with both bodies closed; a rewind error returns without waiting. Added to the release race gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…op closed conns - Track response bodies: a body ending by its context with nothing received since the last read retires the conn, and releases it when done. - Close a retired conn with ClientConn.Close once no live-context request uses it, instead of Shutdown, which blocks behind a writer stuck on a full send buffer. - Mark a conn dead when its read loop stops; drop such conns from the pool on reserve and after a failed round trip. - Treat x/net's forced-close error as an abrupt close (safe requests only). - README: one pool per proxy URL and account. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A request reserved a stream, then counted itself live in a separate step. A retire in between saw zero live requests and closed the conn under the new request, aborting a plain POST the server had already received. tryReserve now does both under the conn's mutex, and retire and release decide to close under the same mutex, so a conn retire has chosen to close is never handed out and is never closed under a reserved request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two parallel tests could share one transport through a reused proxy port, so TestUtlsClientDropsClosedConnectionsFromThePool sometimes counted another test's conn. Test-only change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… requests With 25 streams per connection and 40 requests in flight, and retry backoff off in tests, one request could be refused above last-stream-id on 7 connections in a row and run out of retries. Test only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Why
Each Codex/Claude request built a fresh uTLS client, so connections were never reused and each request left an idle proxy tunnel open for about 7 minutes. Through the home proxy, that used up its 1,024 file-descriptor limit.
What
http2.Transportper proxy URL for the whole process. No idle or ping timeouts (AGENTS.md:59); a connection the server closes is redialled on the next request.ClientConnPooldials once per host:port. The dial runs separately from any single request and is limited to 30s. Each waiting request can give up when its own context ends.base_urland the image endpoints.Tests
go test -racepasses onhelps,proxyutil,auth/claudeandcmd/server. Every test builds a fresh client per request throughNewUtlsHTTPClient. Each test below was checked to fail when its fix is reverted:The full
go test ./...has one failure,internal/api TestModelsWithClientVersionReturnsCodexCatalog. It also fails on the parent commit 7da1f0f, so it is unrelated.Live proof (ovh, through the switchboard and the Pi)
50 concurrent requests to chatgpt.com all returned over HTTP/2 (no login was sent) and used 1 tunnel. With no client idle timeout, chatgpt.com closed it after 6m31s-6m46s idle; the next request succeeded on 1 new tunnel.
After merge
Tag
v7.2.62-edgee.8on the merged commit. Then point Self-Hostedflake.nixat that tag instead of the commit. Branchcliproxyapi-shared-utls-connectioncurrently pins d6a12af.Cache growth: one entry per proxy and account; an account's entries are dropped when it is removed or its proxy changes.
Round 2 (c65ae5c)
context.DeadlineExceeded.proxyutil.FailingTransportcloses the request body on invalid-proxy errors (replaces two copies).proxy-urlvalidated at config load and on management PUT/PATCH and auth-file edits (400).Round 3 (4bb0249)
directfor no proxy) plus the auth ID (88b50d2, owner decision): each subscription account reuses its own connection and accounts never share one. Callers with no auth (config-level clients, login flows) use their own key.utls: shared dial to <addr> failed: ....proxyutil.Parserequires a hostname, a port in 1-65535 when given, and a port for SOCKS5.util.SetProxyand the Antigravity model-hints fetch fail loudly on a bad proxy instead of going direct.proxy_urlmust be a valid string, noproxy_url.*writes) and applies it to a deep copy; uploads reject a badproxy_urlwith 400.Round 4 (d6a12af)
ClientConninstead ofhttp2.Transport's retry loop. Retried (up to 6): requests the server confirmed it never processed — conn unusable before the stream opened, GOAWAY above the last stream id, REFUSED_STREAM. Replayed once, only for idempotent methods or an idempotency key: abrupt close or peer PROTOCOL_ERROR. A plain POST is never sent twice.NewClaudeAuthForAccount); login flows keep the account-less pool. Codex refresh builds its own transport per refresh, so it never mixes accounts.cliproxyauth.OnAuthReleased: on auth removal or a proxy URL change, that account's shared transports are dropped and their connections shut down after open streams finish.socks5://localhost:1080:80) are rejected.proxy_url: nullclears the live proxy, matching the saved file.🤖 Generated with Claude Code
Round 5 (fbf365c)
OnAuthReleasedhook and per-account release: it raced in-flight requests and global proxy changes. Pools are never evicted; a stale one holds memory only, because the upstream closes its idle tunnel (chatgpt.com measured at about 6.5 min).received from peer); a locally detected malformed response is not replayed.Round 6
http2: client conn could not be established): x/net leaves it reservable, so later requests failed on it without redialling. Regression testTestReviewNotEstablishedConnIsReused.GetBodyis returned as its own error, and the rewound body is closed if the wait is cancelled.Round 7 (aa5be62)
ClientConn.Closeonce no request with a live context uses it.Shutdowncould block behind a writer stuck on a full TCP send buffer and leave the socket open.force closed via ClientConn.Closeerror counts as an abrupt close, so safe requests on the other streams of a cut conn are replayed once. A plain POST still is not.Round 8 (15cc44b)