refactor: build one HTTP stack per endpoint instead of per connection - #264
Draft
jonstacks wants to merge 1 commit into
Draft
refactor: build one HTTP stack per endpoint instead of per connection#264jonstacks wants to merge 1 commit into
jonstacks wants to merge 1 commit into
Conversation
jonstacks
force-pushed
the
stacks/http-stack-per-endpoint
branch
from
August 4, 2026 16:11
f503361 to
843c39b
Compare
httpServe built a complete HTTP stack for every inbound edge connection: an http.Transport, a ReverseProxy, an http.Server, a listener shim and the goroutines behind them, all discarded when the connection ended. The socket and goroutine leaks fixed in the previous two commits were symptoms of that shape; this removes the shape. net/http documents http.Transport as something to reuse rather than create as needed, and httpx.ServeConnServer exists precisely so one http.Server can serve individually-accepted connections (golang/go#36673) - its listener shim buffers connections and keys per-connection metadata by conn because it was written to be shared. Both were being used one-per-connection. The practical cost was that no inbound connection could ever reuse an upstream connection, so each one paid a fresh TCP and TLS handshake to the upstream. A forwarder now builds its stack on the first HTTP connection and tears it down when its forward loop exits. Twenty-five sequential inbound connections now open one upstream connection instead of twenty-five; three hundred concurrent ones open roughly forty. Making the transport shareable required dropping the explicit ServerName. It pinned the transport to whichever upstream was configured when it was built; leaving it unset lets net/http derive it from each request's URL, which is both correct across UpdateUpstream and a prerequisite for sharing. The TLS config still has to be non-nil even when the caller supplied none, or net/http quietly enables automatic HTTP/2 negotiation for https upstreams. Two behavior changes worth noting: - UpdateUpstream now takes effect on the next request rather than the next inbound connection, because the reverse proxy reads the upstream per request. This is the more useful behavior: the ngrok edge can hold an inbound connection open long enough that a per-connection snapshot would keep routing to a stale upstream well after the caller retargeted it. The interface documentation is updated to match. - Endpoint teardown now closes in-flight forwarded connections instead of leaving them to drain, since the shared server is closed once the forward loop stops accepting. The tunnel carrying those connections is going away at that point regardless. Also sets ReadHeaderTimeout on the server, which previously had no timeouts at all, and raises MaxIdleConnsPerHost from the stdlib default of 2, which is far too low for something whose whole job is forwarding to one upstream. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jonstacks
force-pushed
the
stacks/http-stack-per-endpoint
branch
from
August 10, 2026 14:36
843c39b to
685ceb5
Compare
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.
Summary
The two leaks fixed in the base PR were symptoms. The cause is that
httpServebuilt an entire HTTP stack per inbound edge connection:http.Transport— and therefore an upstream connection poolhttputil.ReverseProxyhttp.ServerchanListenershim to feed that server its single connection…all discarded when the connection ended. This PR removes that shape: one stack per endpoint, built on the first forwarded HTTP connection and torn down when the forward loop exits.
Why per-connection was the wrong shape
Two signals, both pointing the same way:
net/httpdocumentshttp.Transportas something to reuse rather than create as needed. It is safe for concurrent use and it is the thing that holds the connection pool. Constructing one per connection is close to the single most explicit "don't" in the package docs.internal/httpxwas written to be shared.chanListenerhas a 64-slot buffered connection channel and async.Mapof per-connection metadata keyed by conn, andClose()ranges over all live connections. You only need a buffer and a map if you expect many connections.ServeConnServerexists to work around the absence ofhttp.Server.ServeConn(proposal: net/http: add a ServeConn method to http.Server to handle net.Conn golang/go#36673) precisely so that one server can serve individually-accepted connections. It was being used one-server-per-connection.The cost beyond the leak
Because the connection pool was thrown away every time, no inbound connection could ever reuse an upstream connection. Every single one paid a fresh TCP — and for
httpsupstreams, TLS — handshake to the upstream. For an operator forwarding to a cluster service, this is likely the more expensive half of the bug.Measured with the offline harness:
What made sharing possible
The blocker was
buildHTTPTransportbakingServerNameinto the TLS config from the upstream URL. That pins a transport to whichever upstream happened to be configured when it was built, so it cannot be shared across an upstream change.Leaving
ServerNameunset fixes this:http.Transportderives SNI per request from the request URL. Verified before committing to the design — one shared transport correctly sentalpha.testthenbeta.test, and pooled a third request onto the first connection.One trap worth flagging for review. The tempting next step is to drop the TLS config entirely and pass
nil. Don't. A nilTLSClientConfig(with no custom dialer andForceAttemptHTTP2false) makesnet/httpenable automatic HTTP/2 negotiation forhttpsupstreams — a protocol change nobody asked for. The config has to stay non-nil but empty. There is a regression test pinning this.Behavior changes
Two, both intentional:
1.
UpdateUpstreamnow takes effect on the next request, not the next connection.The shared reverse proxy reads
e.upstreamURL.Load()per request rather than capturing it per connection. This is the more useful behavior: ngrok's edge can hold an inbound connection open long enough that a per-connection snapshot would keep routing to a stale upstream well after the caller retargeted it. Requests already in flight are unaffected either way.The
UpdateableEndpointForwarderdoc comment is updated to match, and there is a test that pins the new semantics over an already-open keep-alive connection. This is user-visible and should be called out in the changelog.2. Endpoint teardown now closes in-flight forwarded connections rather than letting them drain.
closeHTTPStackcallsserver.Close(), which closes active connections. Previously each connection owned its own server and was unaffected by endpoint teardown. The tunnel carrying those connections is going away at that point regardless, so this seemed right — but it is a judgement call and worth a second opinion.Also included
ReadHeaderTimeouton the server, which previously had no timeouts at all.MaxIdleConnsPerHost: 100. The stdlib default is 2, which is very low for something whose whole job is forwarding to one upstream. This is a tuning knob rather than correctness — easy to drop or change if you'd rather not pick a number here.BaseContextreturning the endpoint's context, the idiomatic way to give handlers a server-lifetime context.Notes for review
start(), for two reasons: an endpoint that only forwards raw TCP should not spawn an HTTP server goroutine, andisHTTP()depends on the mutable upstream URL, soUpdateUpstreamcan switch an endpoint between HTTP and TCP at any point.httpServecloses the connection and returns if the endpoint is already torn down, rather than resurrecting a stack during shutdown.defer stop()from the base PR'shttpxchange is load-bearing here. Threading a real cancelable context intoServeConnactivates the second leak mode described in that PR, so this change depends on it.Verification
Full online integration suite passes with
NGROK_TEST_ONLINE=1, includingTestWebSocketUpgrade.New tests cover upstream connection reuse, 300-way concurrency against the 64-slot
chanListenerbuffer, WebSocket upgrade through the shared stack,UpdateUpstreamover an open connection, and the two transport-configuration guards. Each was confirmed to fail against a per-connection stack (25 sockets, +100 goroutines) before passing here.🤖 Generated with Claude Code