Skip to content

fix(viewer): drain WebSocket messages in frame-budgeted slices, not all at once - #32

Open
ericgozzi wants to merge 1 commit into
fix/ci-npm-pack-json-corruptionfrom
fix/viewer-connection-frame-budgeted-drain
Open

ericgozzi wants to merge 1 commit into
fix/ci-npm-pack-json-corruptionfrom
fix/viewer-connection-frame-budgeted-drain

Conversation

@ericgozzi

@ericgozzi ericgozzi commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Loading a large scene (one element per message, with no batching on the sending side) can deliver thousands of WebSocket messages at once. Each one was handled synchronously inside onmessage (protobuf decode plus a Three.js scene change), which blocked the main thread for the whole burst. The tab froze and could eventually drop the WebSocket connection.
  • ViewerConnection now queues incoming messages and drains them in order, in slices of up to FRAME_BUDGET_MS (8 ms) per requestAnimationFrame. The queue tracks a head index instead of calling Array.shift(), so draining isn't O(n²). An error while handling one message is caught so it can't block the messages after it. dispose() cancels any pending drain and drops the queue.

Stacked on #31; merge that first. Next in stack: #33.

Test plan

  • new tests/viewer_connection.test.ts (a drain spread over several frames; dispose() cancelling a pending drain)
  • format, lint, typecheck, test, build:app, build:library

🤖 Generated with Claude Code

…ll at once

A caller loading a large scene (one element per message, no send-side
batching) can burst thousands of WebSocket messages at once. Dispatching
each one synchronously inside onmessage - real work per message: protobuf
decode plus a Three.js scene mutation - pinned the main thread for the
whole burst, which made the tab look/become unresponsive and, past the
browser's own patience, drop the WebSocket connection entirely.

ViewerConnection now queues incoming messages and drains them, in order,
in up to FRAME_BUDGET_MS (8ms) slices per requestAnimationFrame, picking
up where it left off on the next frame. The queue uses a head index
instead of Array.shift() per message to avoid an O(n^2) drain, and a
throw from one message's dispatch is caught so it can't strand every
message queued after it. dispose() cancels a pending drain and drops
whatever is still queued.

Adds tests/viewer_connection.test.ts, covering the multi-frame drain
itself and dispose() cancelling a pending one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

1 participant