fix: stop leaking every inbound WebSocket message in the WASM WebApi#83
Conversation
The onmessage handler read each incoming Blob with a per-message FileReader whose onloadend closure was forget()-leaked. The leaked closure pinned the FileReader, and FileReader.result pinned the full decoded payload, so every inbound message's bytes were retained for the life of the tab. Long-lived WebApi consumers (River) grew to multi-GB tab memory; measured 506 leaked FileReaders retaining 65 MB after ~80 minutes on a mostly-idle 3-room River tab. Set binaryType = arraybuffer and decode e.data() synchronously in onmessage, eliminating the per-message FileReader and closure entirely. Non-binary frames now surface a connection error instead of a crash. Fixes freenet/freenet-core#4746 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TuDtkvz6sBoVeVkwpk97CY
59314e8 to
d04b5c5
Compare
…efore handlers The receive_chunk borrow_mut() in the match scrutinee lives until the end of the match, so the Err arm's remove_stream re-borrow panicked (BorrowMutError -> WASM abort) on any malformed/duplicate stream chunk. Pre-existing on main, surfaced by PR review. Also set binaryType before installing handlers so the ordering is self-evidently safe rather than relying on start() being synchronous. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TuDtkvz6sBoVeVkwpk97CY
…as blocking CI) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TuDtkvz6sBoVeVkwpk97CY
Review record (multi-model)External (non-Claude): Codex CLI Claude subagents, 3 independent adversarial lenses (skeptical bug-hunt / browser-API + WebSocket semantics / code-first + downstream-compat), each blind to the others:
Verified by reviewers, no action needed: binaryType/onmessage ordering safety; text-frame handling (old code panicked via unchecked casts, new path reports a connection error); no new main-thread blocking (decode was always synchronous, now one copy fewer); strict FIFO delivery (the FileReader hop had no cross-message ordering guarantee); no re-entrancy/borrow hazards from user callbacks; API-compatible with all consumers — and the change brings the Rust browser impl into parity with the TS SDK ( CI: green on the final HEAD except as noted in checks; the earlier wasm-clippy failure was a pre-existing [AI-assisted - Claude] |
Release 0.8.3: WASM WebApi inbound-WebSocket-message leak fix (#83) plus the BorrowMutError-abort fix on malformed/duplicate stream chunks that the same PR carries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CD8VBzb8qsDvdcPdF6TNQG
Fixes freenet/freenet-core#4746 (issues are disabled on this repo).
Problem
WebApi::start'sonmessagehandler read every incoming Blob with a per-messageFileReaderwhoseonloadendclosure wasforget()-leaked. The leaked closure pins the FileReader, andFileReader.resultpins the full decoded payload, so every inbound WebSocket message's bytes were retained for the life of the tab. Long-lived consumers (River) grow to multi-GB tab memory in both Chrome and Firefox.Measured live against a real node (headless Chrome 149 driven over CDP, 3-room signed-in River tab): 506 leaked FileReaders retaining 65 MB of ArrayBuffers after ~80 mostly-idle minutes; +15 per tab-switch-back (River refreshes all rooms on
visibilitychange), +12 per reconnect; FileReader count tracks theJSEventListenersmetric 1:1. Details and full methodology in freenet/freenet-core#4746.Fix
Set
binaryType = "arraybuffer"on the socket and decodee.data()synchronously inonmessage. This removes the Blob → FileReader async hop entirely, so there is no per-message closure to leak and no FileReader to pin payloads. A non-binary frame (which the old code would have crashed on via unchecked casts) now reports a connection error through the normal error handler.Behavioral notes:
HostResultdispatch, same streaming reassembly path — only the frame-decode transport changed.forget()s (onmessage/onerror/onopen/onclose, ~4 closures per reconnect) remain; they're bounded and small, noted in #4746 as a follow-up.Verification
cargo check/clippy --target wasm32-unknown-unknown --features netclean; native tests pass; fmt clean.readyState DONE, holding exactly the leaked bytes); this change removes that object class from the path by construction.[AI-assisted - Claude]