Skip to content

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

Merged
ericgozzi merged 3 commits into
fix/ci-npm-pack-json-corruptionfrom
fix/viewer-connection-frame-budgeted-drain
Oct 13, 2026
Merged

ericgozzi merged 3 commits 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>

@gonzalocasas gonzalocasas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, it's an interesting solution

Comment thread src/communications/viewer_connection.ts Outdated
Comment on lines +114 to +138
while (this.inboxHead < this.inbox.length && performance.now() < deadline) {
const message = this.inbox[this.inboxHead]!;
// Advance past this message BEFORE dispatching it, and catch a throw from
// dispatch() itself - one malformed/unsupported message must never silently
// strand every message queued after it (which would otherwise happen here: an
// uncaught throw exits this loop without rescheduling, so the drain simply never
// resumes). options.dispatch already reports most failures through its own
// onError callback instead of throwing, but this is the difference between one
// skipped message and the rest of a large model - including a final "stop
// loading" message - never arriving at all.
this.inboxHead += 1;
try {
this.options.dispatch(message);
} catch (error) {
this.options.onError(
error instanceof Error ? error : new Error(String(error)),
);
}
}
if (this.inboxHead >= this.inbox.length) {
this.inbox.length = 0;
this.inboxHead = 0;
} else {
this.scheduleDrain();
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want to be suuuuper picky, you could wrap the scheduling of next draining into a try..finally, something like:

try {
  // here the while() and all that stuff
} finally { 
    if (this.inboxHead >= this.inbox.length) {
      this.inbox.length = 0;
      this.inboxHead = 0;
    } else {
      this.scheduleDrain();
    }
}

ericgozzi and others added 2 commits October 13, 2026 10:36
…hrows

Wrap the drain loop in try/finally so a throw escaping it (e.g. from
onError while reporting a failed dispatch) can no longer leave messages
queued with no drain pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clears two high-severity advisories that fail audit:prod:
GHSA-g2v6-rqmx-r4w6 (@vue/server-renderer) and GHSA-68fv-2mgg-jv7q
(source-map-js). Lockfile-only, generated with npm 10 to match CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ericgozzi
ericgozzi merged commit 71e4d1c into main Oct 13, 2026
2 checks passed
@ericgozzi
ericgozzi deleted the fix/viewer-connection-frame-budgeted-drain branch October 13, 2026 08:54
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.

2 participants