Skip to content

fix(v3): stop the dev asset proxy exhausting the host's ephemeral ports - #5933

Merged
leaanthony merged 2 commits into
wailsapp:masterfrom
phuclh:fix/dev-proxy-connection-reuse
Aug 11, 2026
Merged

leaanthony merged 2 commits into
wailsapp:masterfrom
phuclh:fix/dev-proxy-connection-reuse

Conversation

@phuclh

@phuclh phuclh commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Description

The wails3 dev asset 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.

NewAssetFileServer builds its http.Transport with only DialContext set, so MaxIdleConnsPerHost falls back to DefaultMaxIdleConnsPerHost, which is 2:

proxy.Transport = &retryTransport{
    base: &http.Transport{
        DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { ... },
    },
    ...
}

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_WAIT holding 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 with EADDRNOTAVAIL and 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 dev dies with unable to connect to frontend server. Please check it is running while Vite in the same terminal happily prints ready in 336 ms.
  • The app log fills with [ExternalAssetHandler] Proxy error {"error": "dial tcp4 127.0.0.1:9245: connect: can't assign requested address"}.
  • Vite's overlay reports [vite] failed to connect to websocket and draws a localhost vs 127.0.0.1 diagram. This is a red herring — Vite is healthy throughout.
  • Unrelated programs start failing: browsers load some sites and not others, CLI tools fail with EADDRNOTAVAIL. It looks like the router died.

EADDRNOTAVAIL is 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:

Metric Value
TIME_WAIT sockets to the Vite port 13,511
Distinct ephemeral ports consumed 16,299 / 16,384 (99.5%)
Ephemeral range (net.inet.ip.portrange.first–last) 49152–65535
net.inet.tcp.msl 15000 ms → 30 s per socket
Also held in LAST_ACK / FIN_WAIT_1 2,169 / 1,650
ESTABLISHED to the Vite port 0

That 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.go is byte-identical from v3.0.0-beta.3 through v3.0.0-beta.6 and current master, so upgrading does not help. Linux hides the bug: a wider ephemeral range and a shorter TIME_WAIT absorb the churn.

The change

MaxIdleConnsPerHost: 512 and IdleConnTimeout: 90s on 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, and IdleConnTimeout reaps whatever is not in use. MaxIdleConns is 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 isConnectionError and two tests.

Things I deliberately did not change

The retry policy. retryTransport still 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. Adding cannot assign requested address to 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 existing connectex match is broad — Go wraps ConnectEx failures with it — but ordinary Windows port exhaustion is not affected, because Go binds the local port before calling ConnectEx and a bind failure is reported as bind: … (net/fd_windows.go).

Two pre-existing issues in retryTransport are 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

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • WEP (proposal only; no implementation)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How Has This Been Tested?

Regression test

TestDevProxyReusesUpstreamConnections in the new v3/internal/assetserver/build_dev_test.go counts 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.

Build upstream connections for 1000 requests Result
Before this PR 597 FAIL (limit 128)
After this PR 46–70 across 10 consecutive runs PASS

The threshold is concurrency * 2 = 128, roughly an order of magnitude clear of both outcomes, so scheduler noise cannot make it flaky.

TestIsConnectionErrorIgnoresAddressExhaustion in the same file is a characterization guard, not a fix — it passes on master by design, and exists so that the tempting "fix" of retrying EADDRNOTAVAIL fails 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:

Build TIME_WAIT ESTABLISHED
Before this PR 1118 4
After this PR 0 104

(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/... — green

  • go test -race ./internal/assetserver/ -count=2 — green

  • go vet and gofmt — clean

  • go 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_WAIT are 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:

  • (v2 only) I have updated website/src/pages/changelog.mdx with details of this PR (v3 changelog entries are added automatically) — n/a for v3; added an entry to v3/UNRELEASED_CHANGELOG.md to control the wording
  • My code follows the general coding style of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Summary by CodeRabbit

  • Bug Fixes

    • Improved the development asset proxy’s connection reuse and idle connection management.
    • Reduced the risk of ephemeral-port exhaustion and related connection failures during large frontend development sessions.
    • Refined retry handling to avoid repeatedly retrying address-exhaustion errors.
  • Tests

    • Added coverage for connection reuse and retry classification.
  • Documentation

    • Documented the connection-exhaustion fix and retry behavior.

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.
@github-actions github-actions Bot added Documentation Improvements or additions to documentation v3 labels Aug 9, 2026
@coderabbitai

coderabbitai Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f2b70d5-d157-48c4-bd4b-de1e9e2559de

📥 Commits

Reviewing files that changed from the base of the PR and between 9a0181b and 8ccbb61.

📒 Files selected for processing (1)
  • v3/UNRELEASED_CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • v3/UNRELEASED_CHANGELOG.md

Walkthrough

The 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.

Changes

Development proxy connection handling

Layer / File(s) Summary
Connection error classification
v3/internal/assetserver/build_dev.go, v3/internal/assetserver/build_dev_test.go
The proxy excludes unavailable-address and exhausted-port errors from retry handling. Tests cover platform-specific error classification.
Upstream connection pooling
v3/internal/assetserver/build_dev.go, v3/internal/assetserver/build_dev_test.go, v3/UNRELEASED_CHANGELOG.md
The proxy retains up to 512 idle connections per host and closes them after 90 seconds. A concurrent test verifies upstream connection reuse. The changelog records the fix.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers: leaanthony

Poem

A rabbit tunes the proxy flow,
Idle links remain ready to go.
Exhausted ports do not retry,
Reused connections serve requests nearby.
The changelog records why.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the fix for ephemeral-port exhaustion in the v3 development asset proxy.
Description check ✅ Passed The description clearly explains the bug, implementation, testing, platform, scope, and checklist completion.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@taliesin-ai taliesin-ai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@leaanthony

Copy link
Copy Markdown
Member

Thanks for this!! 🙏

@leaanthony
leaanthony enabled auto-merge (squash) August 11, 2026 11:26
@leaanthony
leaanthony merged commit 9a8800b into wailsapp:master Aug 11, 2026
19 of 20 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in Wails Release Train Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Documentation Improvements or additions to documentation v3

Projects

No open projects
Archived in project

Development

Successfully merging this pull request may close these issues.

3 participants