diff --git a/package-lock.json b/package-lock.json index e9811cb..2f1127d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,9 +66,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "license": "MIT", "dependencies": { "@babel/types": "^7.29.8" @@ -559,9 +559,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -579,9 +576,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -599,9 +593,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -619,9 +610,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -639,9 +627,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -659,9 +644,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -879,9 +861,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -899,9 +878,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -919,9 +895,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -939,9 +912,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1637,53 +1607,53 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", - "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz", + "integrity": "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.8", - "@vue/shared": "3.5.41", + "@babel/parser": "^7.29.9", + "@vue/shared": "3.5.44", "entities": "^7.0.1", "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" } }, "node_modules/@vue/compiler-dom": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", - "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz", + "integrity": "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==", "license": "MIT", "dependencies": { - "@vue/compiler-core": "3.5.41", - "@vue/shared": "3.5.41" + "@vue/compiler-core": "3.5.44", + "@vue/shared": "3.5.44" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", - "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz", + "integrity": "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.8", - "@vue/compiler-core": "3.5.41", - "@vue/compiler-dom": "3.5.41", - "@vue/compiler-ssr": "3.5.41", - "@vue/shared": "3.5.41", + "@babel/parser": "^7.29.9", + "@vue/compiler-core": "3.5.44", + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-ssr": "3.5.44", + "@vue/shared": "3.5.44", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", - "postcss": "^8.5.19", - "source-map-js": "^1.2.1" + "postcss": "^8.5.29", + "source-map-js": "^1.2.2" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", - "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", + "integrity": "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.41", - "@vue/shared": "3.5.41" + "@vue/compiler-dom": "3.5.44", + "@vue/shared": "3.5.44" } }, "node_modules/@vue/language-core": { @@ -1703,51 +1673,51 @@ } }, "node_modules/@vue/reactivity": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", - "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz", + "integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==", "license": "MIT", "dependencies": { - "@vue/shared": "3.5.41" + "@vue/shared": "3.5.44" } }, "node_modules/@vue/runtime-core": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", - "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.44.tgz", + "integrity": "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.41", - "@vue/shared": "3.5.41" + "@vue/reactivity": "3.5.44", + "@vue/shared": "3.5.44" } }, "node_modules/@vue/runtime-dom": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", - "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz", + "integrity": "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==", "license": "MIT", "dependencies": { - "@vue/reactivity": "3.5.41", - "@vue/runtime-core": "3.5.41", - "@vue/shared": "3.5.41", + "@vue/reactivity": "3.5.44", + "@vue/runtime-core": "3.5.44", + "@vue/shared": "3.5.44", "csstype": "^3.2.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", - "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.44.tgz", + "integrity": "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==", "license": "MIT", "dependencies": { - "@vue/compiler-ssr": "3.5.41", - "@vue/runtime-dom": "3.5.41", - "@vue/shared": "3.5.41" + "@vue/compiler-ssr": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/shared": "3.5.44" } }, "node_modules/@vue/shared": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", - "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz", + "integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==", "license": "MIT" }, "node_modules/@vueuse/core": { @@ -2727,9 +2697,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2751,9 +2718,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2775,9 +2739,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2799,9 +2760,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2931,9 +2889,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -3140,9 +3098,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", "funding": [ { "type": "opencollective", @@ -3159,9 +3117,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -3320,9 +3278,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -3751,9 +3709,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3775,9 +3730,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3799,9 +3751,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3823,9 +3772,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3979,16 +3925,16 @@ "license": "MIT" }, "node_modules/vue": { - "version": "3.5.41", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", - "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz", + "integrity": "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==", "license": "MIT", "dependencies": { - "@vue/compiler-dom": "3.5.41", - "@vue/compiler-sfc": "3.5.41", - "@vue/runtime-dom": "3.5.41", - "@vue/server-renderer": "3.5.41", - "@vue/shared": "3.5.41" + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-sfc": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/server-renderer": "3.5.44", + "@vue/shared": "3.5.44" }, "peerDependencies": { "typescript": "*" diff --git a/src/communications/viewer_connection.ts b/src/communications/viewer_connection.ts index 5a81a5d..2690c9b 100644 --- a/src/communications/viewer_connection.ts +++ b/src/communications/viewer_connection.ts @@ -6,10 +6,30 @@ interface ViewerConnectionOptions extends ViewerWebSocketOptions { onError: (error: Error) => void; } +// A caller loading a large scene (e.g. one element per WebSocket message, no batching +// on the send side either - see compas_threejs's Outbox) can burst thousands of +// messages at once. options.dispatch does real work per message (protobuf decode plus +// a Three.js scene mutation) - draining the whole burst synchronously inside one +// onmessage callback pins the main thread for the entire burst, which is what used to +// make the tab look/become unresponsive (and, past the browser's own patience, drop +// the WebSocket entirely) for a large model. Spending only a slice of each animation +// frame on drained messages, and picking up where it left off next frame, keeps every +// frame responsive - the burst still finishes in roughly the same wall-clock time, it +// just no longer blocks anything else (rendering, input, the connection's own +// liveness) while doing it. +const FRAME_BUDGET_MS = 8; + export class ViewerConnection { private socket: WebSocket | null = null; private retryTimer: ReturnType | null = null; private stopped = true; + // Queue + head index rather than Array.shift() per message - shift() is O(n), which + // would make draining a multi-thousand-message burst O(n^2). Reset to empty once + // fully drained (see drain()) so a long-lived connection doesn't hold onto an + // ever-growing backing array across many bursts over its lifetime. + private readonly inbox: Uint8Array[] = []; + private inboxHead = 0; + private drainHandle: number | null = null; constructor(private readonly options: ViewerConnectionOptions) {} @@ -41,6 +61,12 @@ export class ViewerConnection { clearTimeout(this.retryTimer); this.retryTimer = null; } + if (this.drainHandle !== null) { + cancelAnimationFrame(this.drainHandle); + this.drainHandle = null; + } + this.inbox.length = 0; + this.inboxHead = 0; const socket = this.socket; this.socket = null; if (socket) { @@ -57,7 +83,8 @@ export class ViewerConnection { socket.binaryType = "arraybuffer"; socket.onmessage = (event: MessageEvent) => { if (event.data instanceof ArrayBuffer) { - this.options.dispatch(new Uint8Array(event.data)); + this.inbox.push(new Uint8Array(event.data)); + this.scheduleDrain(); } }; socket.onerror = () => { @@ -71,6 +98,54 @@ export class ViewerConnection { }; } + private scheduleDrain(): void { + if (this.drainHandle !== null) return; + this.drainHandle = requestAnimationFrame(() => this.drain()); + } + + /** + * Dispatches queued messages, in order, for up to FRAME_BUDGET_MS before yielding + * back to the browser - then reschedules itself for the next frame if anything is + * still queued. See FRAME_BUDGET_MS's own docstring for why this exists. + */ + private drain(): void { + this.drainHandle = null; + const deadline = performance.now() + FRAME_BUDGET_MS; + // The finally block makes sure that whatever ends this slice - the budget, an + // empty inbox, or something throwing out of the loop (e.g. onError itself) - the + // drain either resets the inbox or reschedules for the next frame. Without it, a + // throw would leave messages queued with no drain pending, so the rest of a large + // model - including a final "stop loading" message - would never arrive. + try { + 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 is skipped and + // reported, rather than retried forever or ending the slice early. + // options.dispatch already reports most failures through its own onError + // callback instead of throwing. + this.inboxHead += 1; + try { + this.options.dispatch(message); + } catch (error) { + this.options.onError( + error instanceof Error ? error : new Error(String(error)), + ); + } + } + } finally { + if (this.inboxHead >= this.inbox.length) { + this.inbox.length = 0; + this.inboxHead = 0; + } else { + this.scheduleDrain(); + } + } + } + private buildUrl(): string { const query = new URLSearchParams(window.location.search); const host = this.options.host ?? query.get("ws_host") ?? "127.0.0.1"; diff --git a/tests/viewer_connection.test.ts b/tests/viewer_connection.test.ts new file mode 100644 index 0000000..0f5e0dc --- /dev/null +++ b/tests/viewer_connection.test.ts @@ -0,0 +1,149 @@ +/** @vitest-environment happy-dom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ViewerConnection } from "../src/communications/viewer_connection"; + +class MockWebSocket { + static readonly OPEN = 1; + readyState = 1; + binaryType = ""; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + onclose: (() => void) | null = null; + + constructor(public url: string) {} + + send(): void {} + close(): void {} +} + +function bytes(value: number): Uint8Array { + return new Uint8Array([value]); +} + +/** + * Covers ViewerConnection's own message-batching (see its FRAME_BUDGET_MS docstring): + * a burst of messages arriving in one synchronous tick - the shape a large model's + * load produces, one WebSocket message per element with no batching on the send side + * either - must drain across several animation frames rather than being dispatched + * (protobuf-decode-plus-scene-mutation, real work per message) all in the same + * onmessage callback, which is what used to pin the main thread for the whole burst. + */ +describe("ViewerConnection message batching", () => { + let sockets: MockWebSocket[]; + let rafCallbacks: FrameRequestCallback[]; + let nowValue: number; + + beforeEach(() => { + sockets = []; + rafCallbacks = []; + nowValue = 0; + vi.stubGlobal( + "WebSocket", + class extends MockWebSocket { + constructor(url: string) { + super(url); + sockets.push(this); + } + }, + ); + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + rafCallbacks.push(cb); + return rafCallbacks.length; + }); + vi.stubGlobal("cancelAnimationFrame", () => {}); + vi.spyOn(performance, "now").mockImplementation(() => nowValue); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + /** Runs exactly the callbacks scheduled so far, as one animation frame would. */ + function flushOneFrame(): void { + const callbacks = rafCallbacks; + rafCallbacks = []; + for (const callback of callbacks) callback(nowValue); + } + + it("drains a burst of messages across multiple frames, in order, not all at once", () => { + const dispatched: Uint8Array[] = []; + const connection = new ViewerConnection({ + dispatch: (message) => { + dispatched.push(message); + // Each dispatch "costs" 5ms of simulated time - with an 8ms frame budget, + // at most two fit per frame, forcing this test's burst across several frames. + nowValue += 5; + }, + onError: () => {}, + }); + connection.start(); + const socket = sockets[0]!; + + for (let i = 0; i < 5; i++) { + socket.onmessage?.({ data: bytes(i).buffer } as MessageEvent); + } + + // Nothing dispatched synchronously from onmessage, and only one frame scheduled + // for the whole burst - not one dispatch (or one rAF) per message. + expect(dispatched).toHaveLength(0); + expect(rafCallbacks).toHaveLength(1); + + flushOneFrame(); + expect(dispatched.length).toBeGreaterThan(0); + expect(dispatched.length).toBeLessThan(5); + + let guard = 0; + while (dispatched.length < 5 && guard < 10) { + flushOneFrame(); + guard += 1; + } + + expect(dispatched).toHaveLength(5); + expect(dispatched.map((message) => message[0])).toEqual([0, 1, 2, 3, 4]); + }); + + it("keeps draining on the next frame if onError throws while reporting a failed dispatch", () => { + const dispatched: Uint8Array[] = []; + const connection = new ViewerConnection({ + dispatch: (message) => { + if (message[0] === 0) throw new Error("bad message"); + dispatched.push(message); + }, + onError: () => { + throw new Error("onError failed too"); + }, + }); + connection.start(); + const socket = sockets[0]!; + for (let i = 0; i < 3; i++) { + socket.onmessage?.({ data: bytes(i).buffer } as MessageEvent); + } + + expect(() => flushOneFrame()).toThrow("onError failed too"); + // The throw ended the slice early, but a follow-up frame is still scheduled. + expect(rafCallbacks).toHaveLength(1); + + flushOneFrame(); + expect(dispatched.map((message) => message[0])).toEqual([1, 2]); + }); + + it("dispose() cancels a pending drain and drops whatever was still queued", () => { + const dispatched: Uint8Array[] = []; + const connection = new ViewerConnection({ + dispatch: (message) => dispatched.push(message), + onError: () => {}, + }); + connection.start(); + const socket = sockets[0]!; + socket.onmessage?.({ data: bytes(0).buffer } as MessageEvent); + expect(rafCallbacks).toHaveLength(1); + + connection.dispose(); + flushOneFrame(); + + expect(dispatched).toHaveLength(0); + }); +});