proxy: forward pipelined TLS-terminated requests in the order the client sent them - #609
Open
iamhuman-cheolheelee wants to merge 1 commit into
Conversation
…ent sent them Since anthropics#561 the requests on one client connection share one upstream socket, and the agent sends them in the order forwardUpstream hands them over. That is the order in which they finish their awaits (filterRequest, SigV4 body buffering, address vetting), so a request pipelined behind a slow one could go upstream first. Under Node the inner server also holds a pipelined response back until the one ahead of it has finished; once the held response passes the high-water mark the pipe from upstream pauses, the overtaking request keeps the agent's only socket, and both requests hang. Chain the hand-offs: each request waits for the one ahead of it to reach the agent, or be dropped, before calling https.request. Filtering, body buffering and vetting still run concurrently. The turn passes at hand-off, not when the response has finished: the agent's single socket already serialises the responses, and every return and throw settles the promise, so no path can leave the next request waiting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
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.
On a TLS-terminated connection, a request pipelined behind a slow one goes upstream first. Since #561 both share one upstream socket, so under Node, which the CLI runs on, a large response to the overtaking request hangs both. The Node build from just before #561 completes the same exchange.
Root cause
Since #561 each client connection gets a keep-alive agent with
maxSockets: 1(src/sandbox/tls-terminate-proxy.ts:340-343). The agent sends requests in the orderhttps.requestis called (:638). The request handler startsforwardUpstreamfor each request as soon as it is parsed (:237), and each one first awaitsfilterRequest(:454), SigV4 body buffering (:536) and the vetted address (:626). The request that finishes those first reaches the agent first, so one pipelined behind a slow request overtakes it. The comment at:318-322says the one socket keeps the client's order, but it only keeps the order in which requests reach the agent.Under Node, two more things go wrong:
upRes.pipe(res)pauses and that request never frees the agent's only socket, so the earlier request never gets it and both hang.requestbefore the previous request's body has ended. A request behind one being buffered for SigV4 re-signing therefore overtakes it even with a fast filter; Bun keeps the order here.Fix
Chain the hand-offs:
forwardUpstreamGuardedreturns its promise, and the request handler passes each request the previous request's promise.forwardUpstreamawaits that promise just beforehttps.request. Filtering, body buffering and vetting still run concurrently; only the hand-off to the agent is ordered.forwardUpstreamsettles, i.e. once the request is handed to the agent or dropped. The guard's.catchturns a throw into a settle, so no path leaves the next request waiting.:318-322now says where the order comes from.Rejected alternative: holding the turn until the response has finished. The agent's single socket already serialises the responses, and every deny, error and stale-socket path would have to release the turn.
Edge cases covered
Testing
test/sandbox/tls-terminate-pipelining.test.ts, 6 tests. The two Node cases run the builtdist/proxy in anodechild, because neither shows under Bun. CI builds beforenpm test.npx eslint .,npm run typecheck,npm run build:java-agent,npm run build,npm test(1204 pass, 689 skip, 0 fail),node test/utils/which-node-test.mjs.Notes
cc @dylan-conway
🤖 Generated with Claude Code