Repository navigation
Conversation
…n the stack A message listener can run a nested event loop tick (Bun.build() with an async plugin setup, a macro). The nested tick reads the socket again and a second handle_data_loop ran over the outer loop's half-updated state. With a frame split across two reads that dispatched a phantom empty message and, with permessage-deflate, fed an empty message to the shared inflate stream so the next frame failed with Invalid compressed data. The parser now has one entry, ingest(), which queues bytes that arrive while it is on the stack and parses them after the outer loop returns. This is the guard feed() already had for the JS stream transport. While bytes are queued the socket is paused, so a listener that waits for long does not buffer the peer's stream without bound. A close that arrives while the parser is on the stack is reported after the bytes already read are parsed, and terminate() from a listener stops reading at once.
Collaborator
Author
|
Updated 11:17 PM PT - Nov 10th, 2026
⏳ @robobun, your commit 2ea3cec is still building in
|
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.
Fixes #48524
Problem
messagelistener that runs a nested event loop tick (Bun.build()with an async pluginsetup(), a macro) makes the WebSocket client read the socket again from inside the frame parser. With a frame split across two TCP reads the client then dispatches a phantom emptymessage, and with permessage-deflate the next frame fails withInvalid compressed data(close 1002, 1006 on main).handle_data_loop(src/http_jsc/websocket_client.rs). It keeps the parse cursor on the stack and writes it back toselfonly when it returns. The nestedhandle_datastarted a second loop from the stale state (NeedBody, 0 bytes left) and calledconsumewith an empty message.Fix
ingest(). A call that arrives while a parse is on the stack queues its bytes ininitial_data. The outer call parses them, in order, after its own loop returns.feed()(the JS stream transport) already had this guard.handle_data,handle_tunnel_dataandparse_initial_datanow share it.handle_close,handle_tunnel_close, the close timeout) is reported after the bytes already read are parsed.terminate()from a listener stops reading at once, so nothing read before it is delivered after it.test/js/web/websocket/websocket-client-short-read.test.ts, eight new cases (text and permessage-deflate), all fail on Bun 1.4.2 and pass with this change. Also all oftest/js/web/websocket/andtest/js/first_party/ws/.Background
on_datahandler. The handler may run JS. JS may tick the loop again (wait_for_promise), which dispatches the same socket again before the outer handler returns.RecvCursoris that handler's parse state: the receive state, the bytes left in the frame, and the rest of the read. Each step of the loop runs listeners throughdispatch_data, so the cursor is live during every listener.selfbefore each listener: the nested loop still runs, its result is clobbered when the outer loop writes its cursor back, and it adds stores per message. Considered a per-depth receive buffer in uSockets so the rest of the outer read survives a nested tick: that makes the loop tolerate re-entry, which event loop: never re-enter from inside a dispatch callback (de-block expect().resolves and HTMLRewriter.transform, then assert) #33261 rules out, so it is not in this PR. The queue here costs nothing per message and is the shapefeed()and the h2 frame parser already use.Downsides
feed()already had and the order Node'swshas.parsingload and branch, two stores, twoCell::take. No new allocation. Binary size and allocation counts: MEASURE_PLACEHOLDER.Notes
expect(p).rejects.toThrow()inbun test(the issue's repro). bun test: Vitest compatibility #44833 removed that on main. On main:Bun.build()with an async pluginsetup()(src/runtime/api/JSBundler.rs), which Stop running the event loop inside JavaScript calls #46545 removes, and macros that return a promise, which stay.consumeof an empty message also runsdecompresson empty input, which feeds only the00 00 ff fftrailer to the shared inflate stream. The next real frame then fails withZ_DATA_ERROR.cancel()(terminate()) callsstop_reading()when a parse is on the stack. Thenhandle_closefinishes at once and the outer loop returns onClosed. Outside a parse nothing changes:wsmode still parses the bytes that arrived with the 101.stream_closed(JS stream transport) is not deferred:wsemitscloseas soon as it returns, so queued bytes would be dispatched on a closed socket. It finishes at once and drops the queue, as before.pause()stay ininitial_data.resume()delivers them through the existingdeliverInitialDatatask. A connection that already ended parses them regardless, ashandle_closedid before.test/js/web/websocket/(54 files, 3929 pass, the twows.postman-echo.comtests fail for lack of network),test/js/first_party/ws/,test/js/bun/net/socket.test.ts,test/js/node/net/node-net.test.ts,test/js/bun/http/serve.test.ts.Bun.serveWebSocket parser in uWS (Finalize WebSocket frame state before the frame's callback runs #46093), node IPC advanced mode (src/runtime/ipc.rs), the HTTP server body parser. The h2 frame parser already has its own queue (rewrite_tail).stream_closednot deferred;terminate()stops reading; queue backpressure; tests for FIN with nothing queued and forterminate(); a marker that pins the nested tick; the uSockets half dropped per event loop: never re-enter from inside a dispatch callback (de-block expect().resolves and HTMLRewriter.transform, then assert) #33261). Rejected: a wss:// test variant and a depth-3 free-list test (both for the dropped uSockets half), an unbounded-queue concern (the pause addresses it).