Skip to content

fix: stop leaking every inbound WebSocket message in the WASM WebApi#83

Merged
sanity merged 4 commits into
mainfrom
fix/wasm-ws-message-leak
Jul 10, 2026
Merged

fix: stop leaking every inbound WebSocket message in the WASM WebApi#83
sanity merged 4 commits into
mainfrom
fix/wasm-ws-message-leak

Conversation

@sanity

@sanity sanity commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Fixes freenet/freenet-core#4746 (issues are disabled on this repo).

Problem

WebApi::start's onmessage handler read every incoming Blob with a per-message FileReader whose onloadend closure was forget()-leaked. The leaked closure pins the FileReader, and FileReader.result pins 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 the JSEventListeners metric 1:1. Details and full methodology in freenet/freenet-core#4746.

Fix

Set binaryType = "arraybuffer" on the socket and decode e.data() synchronously in onmessage. 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:

  • Same wire format, same HostResult dispatch, same streaming reassembly path — only the frame-decode transport changed.
  • Decode now happens in the same microtask as message delivery instead of a FileReader callback; ordering between messages is unchanged (FileReader completions were already FIFO in practice, but ArrayBuffer delivery removes any reliance on that).
  • The per-connection 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 net clean; native tests pass; fmt clean.
  • The retention mechanism was pinpointed empirically (queryObjects census: FileReaders were the only growing object class, all readyState DONE, holding exactly the leaked bytes); this change removes that object class from the path by construction.

[AI-assisted - Claude]

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
@sanity sanity force-pushed the fix/wasm-ws-message-leak branch from 59314e8 to d04b5c5 Compare July 9, 2026 15:44
sanity and others added 2 commits July 9, 2026 10:46
…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
@sanity

sanity commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Review record (multi-model)

External (non-Claude): Codex CLI review --base main, run 3× — on the initial diff, after stripping the accidental generated-file churn, and on the final HEAD (f77f788). All passes: no correctness issues found.

Claude subagents, 3 independent adversarial lenses (skeptical bug-hunt / browser-API + WebSocket semantics / code-first + downstream-compat), each blind to the others:

  1. [HIGH, found by all three] Latent RefCell double-borrow panic in the StreamChunk Err arm: the scrutinee's reassembly.borrow_mut() temporary lives to the end of the match, so the arm's remove_stream re-borrow panics (BorrowMutError → WASM abort) on any malformed/duplicate/over-limit chunk from the node. Pre-existing on main (identical shape), not introduced here — fixed in this PR (fda66d5) by binding the outcome before the match, since it's in the exact block being rewritten.
  2. [Diff hygiene, found by two] Accidental flatbuffers regen (~4k lines, same failure as chore: bump mirror pin to a93179, add Matrix failure alerts #72/revert: undo accidental flatbuffers regen from #72 #73): build.rs regenerates tracked files with the local flatc on every build. Stripped from this PR (force-push d04b5c5); follow-up filed as stdlib build.rs regenerates tracked flatbuffers with local flatc — repeatedly causes accidental 4k-line regen commits freenet-core#4747.
  3. [Nit] binaryType set after handlers — safe (start() is synchronous) but moved before handler installation for robustness (fda66d5).

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 (binaryType = "arraybuffer", websocket-interface.ts:702) and the native impl (synchronous binary decode, regular.rs).

CI: green on the final HEAD except as noted in checks; the earlier wasm-clippy failure was a pre-existing clippy::question_mark in encoding.rs newly enforced by current stable clippy (main would fail it too) — fixed here (f77f788).

[AI-assisted - Claude]

@sanity sanity marked this pull request as ready for review July 9, 2026 15:55
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
@sanity sanity merged commit 8b53702 into main Jul 10, 2026
9 checks passed
@sanity sanity deleted the fix/wasm-ws-message-leak branch July 10, 2026 22:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stdlib WASM WebApi leaks every inbound WebSocket message (FileReader + full payload retained forever) — multi-GB River tabs

1 participant