Preview: forward the HMR subprotocol, and never reset a connecting upstream - #90
Merged
Merged
Conversation
…stream A Vite-family dev server (Astro, Nuxt, SvelteKit, plain Vite) upgrades an HMR request only when it names `vite-hmr`, and the previewer proxy dropped `Sec-WebSocket-Protocol` in BOTH directions: the app's 101 answered the browser with no echo, which Chromium fails outright, and the bridge stripped the header before dialing the dev server, which left the upgrade unanswered rather than refused. The app now echoes the browser's first choice and the bridge forwards the whole list to Bun as `protocols`. The bridge also closed an upstream socket whose tunnel ended while its handshake was still in flight. Bun aborts an unfinished handshake with a RESET, and Node hands an `upgrade` to its listeners with its own error handler already removed — so a dev server that ignored the upgrade holds that socket with no error listener at all and the reset lands as an unhandled `read ECONNRESET` that exits the process. Measured on Node 26 against Astro. Such a socket is now parked and closed once it opens, bounded by `WS_ABANDONED_MAX`. `openUpstream` wraps the WebSocket constructor, which throws `SyntaxError` for a subprotocol that is not an RFC 6455 token. Nothing between the relay's message listener and there catches, and an uncaught exception shuts the whole host down — so the one tunnel is refused instead. Known limits, recorded in bridge/CLAUDE.md: the park has no TTL and its cap is per-manager, past the cap eviction performs the very reset the park exists to avoid, and the echo stays optimistic — a server that picks a later entry is logged, and one that echoes nothing cannot be detected at all (Bun reports `ws.protocol` as the first entry offered). Claude-Session: https://claude.ai/code/session_01Ca7qjBqVbkUGQ71TewJZN5
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.
Vite/Astro HMR never connected through the previewer proxy. The browser console showed
ws://localhost:4321/?token=... failedand[vite] failed to connect to websocket, and probing the proxy also took the dev server down.Three distinct faults, all verified against a live Astro dev server before the fix:
1. The app's 101 answered with no subprotocol echo
Chromium fails a handshake outright when it sent
Sec-WebSocket-Protocoland the response carries none ("Sent non-empty 'Sec-WebSocket-Protocol' header but no response was received").PreviewProxyServernow hands the browser's own list towebSocketHandler, so the 101 echoes the browser's first choice.2. The bridge stripped the subprotocol before dialing the dev server
Sec-WebSocket-Protocolwas inWS_HOP_BY_HOP_HEADERS, so the upstream request never namedvite-hmr. Every Vite-family dev server (Astro, Nuxt, SvelteKit, plain Vite) upgrades an HMR request only when it names that protocol, and one without it is left unanswered rather than refused — which is why the failure had no error anywhere to point at. The list is now forwarded to Bun asprotocols.3. Closing a tunnel mid-handshake reset the socket, and that killed the dev server
This is what crashed Astro during diagnosis:
Bun aborts a socket that never finished its handshake with a RESET, and Node hands an
upgraderequest to its listeners with its own error handler already removed — so a dev server that ignored the upgrade (which fault 2 guaranteed) holds that socket with no error listener at all, and the reset exits the process. Measured on Node 26.releaseUpstreamnow parks a still-connecting socket and closes it once it opens, bounded byWS_ABANDONED_MAX. A parked socket is markedabandoned, so nothing it later says is routed under a tunnel id that may already name a newer tunnel.Also, from review
openUpstreamwraps theWebSocketconstructor. Bun throwsSyntaxErrorfor a subprotocol that is not an RFC 6455 token (the commonbearer.<base64>=auth-smuggling pattern is enough —=is not a token character), nothing between the relay's message listener andonWsOpencatches, andindex.tsanswers an uncaught exception by shutting the host down. One previewed page would have killed every agent on the machine. The one tunnel is refused instead.Testing
bridge/tests/tunnel-manager-ws-subprotocol.test.ts— 7 new tests: the subprotocol reaches a Vite-shaped upstream, multi-entry order is preserved, none is sent when none was asked for, a mid-handshake close sends no reset and closes gracefully after a late 101, a parked socket never speaks for a reused tunnel id, the park cap evicts the oldest, and a constructor-refused subprotocol ends one tunnel rather than the host.app/test/services/preview_proxy_server_ws_test.dart— 4 new tests over the raw bytes of the 101 (dart:io's client tolerates a missing echo, Chromium does not, so only the wire proves it).tsc --noEmitclean,flutter test19/19 on both proxy files,flutter analyzeclean.Known limits (documented in
bridge/CLAUDE.md, not fixed here)TunnelManager. Bun raises no handshake timeout (measured:readyStatestayed 0 past 45s), and each checkout teardown discards the manager and builds a fresh budget, so parked sockets can accumulate across manager lifetimes.ws.protocolas the first entry offered either way. Closing that needs a new wire field and a reordered handshake.PreviewService.dispose()cancels each tunnel's subscription before closing its sink, soonDonenever fires and the bridge keeps an open upstream. Pre-existing, in a file this PR does not touch.https://claude.ai/code/session_01Ca7qjBqVbkUGQ71TewJZN5