Repository navigation
fix(v3): stop the dev asset proxy exhausting the host's ephemeral ports - #5933
Conversation
NewAssetFileServer builds the dev proxy's http.Transport with only DialContext set, so MaxIdleConnsPerHost falls back to DefaultMaxIdleConnsPerHost, which is 2. A dev page pulls hundreds of unbundled ES modules concurrently, so most of those requests get a connection that is closed rather than pooled the moment it is returned, and each close leaves a TIME_WAIT holding one of the proxy's own ephemeral ports for 2*MSL - 30s on macOS. On a large frontend that drains the host's entire ephemeral range. The app then fails with "unable to connect to frontend server" while the dev server is perfectly healthy, and every other process on the machine starts failing with EADDRNOTAVAIL too. Measured on a 5.8 MB Vue 3 frontend: 13,511 TIME_WAIT sockets to the Vite port, 16,299 of 16,384 ephemeral ports consumed, and zero ESTABLISHED connections to that port. Only a reboot cleared it. Give the transport a real idle pool: MaxIdleConnsPerHost of 512, which is cheap because it only ever targets the one dev-server host, plus a 90s IdleConnTimeout to reap what is not in use. MaxIdleConns stays zero (no global limit) since the per-host cap already bounds this transport. TestDevProxyReusesUpstreamConnections counts the TCP connections the upstream actually accepts: 597 for 1000 requests before, 46-70 after. At the OS level, over 2000 requests, netstat goes from 1118 TIME_WAIT and 4 ESTABLISHED to 0 and 104. isConnectionError is left alone deliberately. Adding EADDRNOTAVAIL to it would be the intuitive response to this bug and would make things worse, so there is now a comment saying so and a test guarding the behaviour.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
WalkthroughThe development asset proxy now limits idle upstream connections and closes them after 90 seconds. Address-exhaustion errors bypass retry handling. Tests cover error classification and connection reuse under concurrent load. The changelog records the fix. ChangesDevelopment proxy connection handling
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
taliesin-ai
left a comment
There was a problem hiding this comment.
Validated head 9a0181b for Beta 3. Connection reuse and address-exhaustion classification passed 10 repeated focused runs; the focused tests also passed under the race detector twice. The CGO-disabled production-tag assetserver build, broader root assetserver tests, go vet, and diff check passed. A no-commit merge with current origin/master a0fae83 required only preserving both UNRELEASED_CHANGELOG entries; all critical tests passed on that synthetic integration. The supported CodeRabbit CLI review completed with no findings. The remaining merge conflict is mechanical and must be resolved on the contributor branch without dropping either changelog entry.
|
Thanks for this!! 🙏 |
Description
The
wails3 devasset proxy exhausts the host's entire ephemeral port range on a large frontend, at which point every process on the machine — not just the Wails app — starts failing to make outbound connections.NewAssetFileServerbuilds itshttp.Transportwith onlyDialContextset, soMaxIdleConnsPerHostfalls back toDefaultMaxIdleConnsPerHost, which is 2:A dev page pulls hundreds of unbundled ES modules concurrently. With room for two idle connections, most of those requests get a connection that is closed rather than pooled the moment it is returned, and each close leaves a
TIME_WAITholding one of the proxy's own ephemeral ports for 2×MSL (30 s on macOS). On a large frontend that drains the host's ephemeral range; subsequent dials fail withEADDRNOTAVAILand the proxy returns 502.What it looks like
The symptom does not look like a networking problem, which is what makes it expensive to diagnose:
wails3 devdies withunable to connect to frontend server. Please check it is runningwhile Vite in the same terminal happily printsready in 336 ms.[ExternalAssetHandler] Proxy error {"error": "dial tcp4 127.0.0.1:9245: connect: can't assign requested address"}.[vite] failed to connect to websocketand draws alocalhostvs127.0.0.1diagram. This is a red herring — Vite is healthy throughout.EADDRNOTAVAIL. It looks like the router died.EADDRNOTAVAILis always local — the kernel refusing to allocate a source port. It is never a network, DNS or ISP fault, and that single fact is the fastest way to recognise this.Measured on macOS 26.2 (Darwin 25.2.0) with a Vue 3 frontend that bundles to 5.8 MB in production, and therefore serves well over a thousand separate modules in dev:
TIME_WAITsockets to the Vite portnet.inet.ip.portrange.first–last)net.inet.tcp.mslLAST_ACK/FIN_WAIT_1ESTABLISHEDto the Vite portThat last row is the proof of no connection reuse: 13,511 closed connections against a single listener with none held open. The backlog did not drain on its own — only a reboot cleared it.
build_dev.gois byte-identical fromv3.0.0-beta.3throughv3.0.0-beta.6and currentmaster, so upgrading does not help. Linux hides the bug: a wider ephemeral range and a shorterTIME_WAITabsorb the churn.The change
MaxIdleConnsPerHost: 512andIdleConnTimeout: 90son the dev proxy's transport. That is the whole behaviour change. The transport only ever targets the one dev-server host, so a generous pool is cheap, andIdleConnTimeoutreaps whatever is not in use.MaxIdleConnsis deliberately left at zero (no global limit) — the per-host cap already bounds this single-target transport.The rest of the diff is a comment on
isConnectionErrorand two tests.Things I deliberately did not change
The retry policy.
retryTransportstill makes 50 attempts at a fixed 50 ms. Those retries are a secondary amplifier — each is a fresh dial consuming another ephemeral port, and 59e2ab8 raised the count from 3 to 50 with no stated reason — but they only fire after failures begin, and pooling removes the concurrency pressure that produced the refusals in the first place. I had a patch reducing the count with exponential backoff and dropped it: I cannot reproduce the intermittent accept-queue rejection that 02ed49f and 59e2ab8 were tuned for, and fewer attempts is materially worse for that case. Happy to revisit separately if a maintainer knows what the 50 was for.isConnectionError's matching. It is unchanged. Addingcannot assign requested addressto it would be the intuitive response to this bug and would make things worse, so I added a comment saying so, and a test that guards the current behaviour. Note that the existingconnectexmatch is broad — Go wrapsConnectExfailures with it — but ordinary Windows port exhaustion is not affected, because Go binds the local port before callingConnectExand a bind failure is reported asbind: …(net/fd_windows.go).Two pre-existing issues in
retryTransportare out of scope here and I will raise them separately: it re-sends requests whose body has already been consumed, and it retries non-idempotent methods on error text alone, bypassing the standard transport's replay safeguards.No production impact — the whole file is behind
//go:build !production.Fixes # (no existing issue found; happy to open one if you would prefer it tracked)
Type of change
How Has This Been Tested?
Regression test
TestDevProxyReusesUpstreamConnectionsin the newv3/internal/assetserver/build_dev_test.gocounts how many TCP connections the upstream server actually accepts, which is the quantity the bug is about. It needs no GUI, no Vite and no webview.The threshold is
concurrency * 2= 128, roughly an order of magnitude clear of both outcomes, so scheduler noise cannot make it flaky.TestIsConnectionErrorIgnoresAddressExhaustionin the same file is a characterization guard, not a fix — it passes onmasterby design, and exists so that the tempting "fix" of retryingEADDRNOTAVAILfails the build.OS-level socket measurement
The unit test counts accepted connections. To confirm that translates into the ephemeral-port consumption users actually suffer, I ran the same 64-way load (2000 requests) through the proxy and then read the real socket table with
netstat -an -p tcp, filtered to the upstream port:TIME_WAITESTABLISHED(Both endpoints of a loopback connection appear in
netstat, so 104 rows is ~52 connections held open.) Same signature as the field report — thousands of closed sockets with nothing held open, versus a small steady pool and no churn.Also run
go test ./internal/assetserver/...— greengo test -race ./internal/assetserver/ -count=2— greengo vetandgofmt— cleango build -tags production ./internal/assetserver/— green (the changed file is dev-only, confirmed unaffected)Windows
macOS
Linux
Tested on macOS. Linux is unlikely to show a measurable difference — its wider ephemeral range and shorter
TIME_WAITare why the bug hides there. Windows has a smaller default ephemeral range than Linux and should benefit as macOS does, but I have not measured it.Test Configuration
macOS 26.2 (Darwin 25.2.0), arm64, Go 1.26.5, Wails
v3.0.0-beta.3, Vite 4.5.5, Vue 3 frontend (5.8 MB production bundle, >1000 modules served individually in dev).Checklist:
website/src/pages/changelog.mdxwith details of this PR (v3 changelog entries are added automatically) — n/a for v3; added an entry tov3/UNRELEASED_CHANGELOG.mdto control the wordingSummary by CodeRabbit
Bug Fixes
Tests
Documentation