From 7c5355b9dfcf2a1e235b6bea748155aaaee853a6 Mon Sep 17 00:00:00 2001 From: Rophy Tsai Date: Fri, 21 Aug 2026 22:46:06 +0000 Subject: [PATCH 01/12] fix(web): sanitize server-supplied status messages (XSS) Replace innerHTML with textContent for server-supplied text in msgbox(). Apply styling via element.style instead of inline HTML. Closes #6 --- flutter/web/js/src/ui.js | 11 +++- flutter/web/js/src/ui.test.ts | 100 ++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 flutter/web/js/src/ui.test.ts diff --git a/flutter/web/js/src/ui.js b/flutter/web/js/src/ui.js index 4463340228e..e95ca5d32af 100644 --- a/flutter/web/js/src/ui.js +++ b/flutter/web/js/src/ui.js @@ -70,6 +70,13 @@ if (app) { func(); } + function setStatusText(text, isError) { + const el = document.querySelector('div#text'); + el.textContent = text; + el.style.fontWeight = 'bold'; + el.style.color = isError ? 'red' : ''; + } + function msgbox(type, title, text) { if (!globals.getConn()) return; if (type == 'input-password') { @@ -82,11 +89,11 @@ if (app) { } else if (type == 'error') { document.querySelector('div#status').style.display = 'block'; document.querySelector('div#canvas').style.display = 'none'; - document.querySelector('div#text').innerHTML = '
' + text + '
'; + setStatusText(text, true); } else { document.querySelector('div#password').style.display = 'none'; document.querySelector('div#status').style.display = 'block'; - document.querySelector('div#text').innerHTML = '
' + text + '
'; + setStatusText(text, false); } } diff --git a/flutter/web/js/src/ui.test.ts b/flutter/web/js/src/ui.test.ts new file mode 100644 index 00000000000..c772a77e0cf --- /dev/null +++ b/flutter/web/js/src/ui.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("./style.css", () => ({})); +vi.mock("./connection", () => ({})); +vi.mock("./globals", () => ({ + newConn: vi.fn(() => ({ + setMsgbox: vi.fn(), + setDraw: vi.fn(), + start: vi.fn(), + login: vi.fn(), + })), + getConn: vi.fn(() => ({ + login: vi.fn(), + })), + close: vi.fn(), + draw: vi.fn(), +})); + +describe("ui.js XSS prevention", () => { + beforeEach(async () => { + document.body.innerHTML = '
'; + + (globalThis as any).YUVCanvas = { + attach: vi.fn(() => ({ drawFrame: vi.fn() })), + }; + (window as any).init = vi.fn(); + + vi.resetModules(); + + vi.doMock("./style.css", () => ({})); + vi.doMock("./connection", () => ({})); + vi.doMock("./globals", () => ({ + newConn: vi.fn(() => ({ + setMsgbox: vi.fn(), + setDraw: vi.fn(), + start: vi.fn(), + login: vi.fn(), + })), + getConn: vi.fn(() => ({ + login: vi.fn(), + })), + close: vi.fn(), + draw: vi.fn(), + })); + + await import("./ui.js"); + }); + + it("should render status text safely, not as HTML", () => { + const textEl = document.querySelector("div#text") as HTMLElement; + expect(textEl).not.toBeNull(); + + const malicious = ''; + textEl.textContent = malicious; + + expect(textEl.innerHTML).not.toContain(" { + const fs = await import("fs"); + const path = await import("path"); + const source = fs.readFileSync( + path.resolve(__dirname, "ui.js"), + "utf-8" + ); + + const lines = source.split("\n"); + for (const line of lines) { + if (line.match(/querySelector\(['"]div#text['"]\)\.innerHTML/)) { + expect(line).not.toMatch(/\+\s*text/); + expect(line).not.toMatch(/\$\{text\}/); + } + } + }); + + it("error status should have red color styling", () => { + const textEl = document.querySelector("div#text") as HTMLElement; + textEl.textContent = "test error"; + textEl.style.fontWeight = "bold"; + textEl.style.color = "red"; + + expect(textEl.style.color).toBe("red"); + expect(textEl.style.fontWeight).toBe("bold"); + }); + + it("non-error status should not have red color", () => { + const textEl = document.querySelector("div#text") as HTMLElement; + textEl.textContent = "connecting"; + textEl.style.fontWeight = "bold"; + textEl.style.color = ""; + + expect(textEl.style.color).toBe(""); + expect(textEl.style.fontWeight).toBe("bold"); + }); +}); From 63a86e5a2c8d71d47c719d599e4b2510d1f6e4d4 Mon Sep 17 00:00:00 2001 From: Rophy Tsai Date: Fri, 21 Aug 2026 22:52:14 +0000 Subject: [PATCH 02/12] fix: ensure libsodium is initialized before crypto operations Add shared initSodium()/requireSodium() pattern so all crypto functions fail fast if called before initialization. Call initSodium() at connection startup before the handshake. Fixes #9 --- flutter/web/js/src/connection.test.ts | 1 + flutter/web/js/src/connection.ts | 1 + flutter/web/js/src/globals.js | 31 ++++++++++------ flutter/web/js/src/globals.test.ts | 51 +++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/flutter/web/js/src/connection.test.ts b/flutter/web/js/src/connection.test.ts index abb35b95bba..0cb3d15b205 100644 --- a/flutter/web/js/src/connection.test.ts +++ b/flutter/web/js/src/connection.test.ts @@ -81,6 +81,7 @@ vi.mock("./globals", () => ({ draw: vi.fn(), pushEvent: vi.fn(), getPeers: vi.fn(() => ({})), + initSodium: vi.fn().mockResolvedValue(undefined), isDesktop: vi.fn(() => true), verify: vi.fn().mockResolvedValue(new Uint8Array(32)), genBoxKeyPair: vi.fn(() => [new Uint8Array(32), new Uint8Array(32)]), diff --git a/flutter/web/js/src/connection.ts b/flutter/web/js/src/connection.ts index 09e55e9f2e7..91e8122f088 100644 --- a/flutter/web/js/src/connection.ts +++ b/flutter/web/js/src/connection.ts @@ -59,6 +59,7 @@ export default class Connection { } async _start(id: string) { + await globals.initSodium(); if (!this._options) { this._options = globals.getPeers()[id] || {}; } diff --git a/flutter/web/js/src/globals.js b/flutter/web/js/src/globals.js index 36c24ed1d20..266a4949584 100644 --- a/flutter/web/js/src/globals.js +++ b/flutter/web/js/src/globals.js @@ -140,11 +140,21 @@ export function newConn() { } let sodium; -export async function verify(signed, pk) { - if (!sodium) { - await _sodium.ready; - sodium = _sodium; +let sodiumReady; +export async function initSodium() { + if (!sodiumReady) { + sodiumReady = _sodium.ready.then(() => { sodium = _sodium; }); } + await sodiumReady; +} + +function requireSodium() { + if (!sodium) throw new Error('libsodium not initialized — call initSodium() first'); + return sodium; +} + +export async function verify(signed, pk) { + await initSodium(); if (typeof pk == 'string') { pk = decodeBase64(pk); } @@ -152,23 +162,24 @@ export async function verify(signed, pk) { } export function decodeBase64(pk) { - return sodium.from_base64(pk, sodium.base64_variants.ORIGINAL); + return requireSodium().from_base64(pk, sodium.base64_variants.ORIGINAL); } export function genBoxKeyPair() { - const pair = sodium.crypto_box_keypair(); + const s = requireSodium(); + const pair = s.crypto_box_keypair(); const sk = pair.privateKey; const pk = pair.publicKey; return [sk, pk]; } export function genSecretKey() { - return sodium.crypto_secretbox_keygen(); + return requireSodium().crypto_secretbox_keygen(); } export function seal(unsigned, theirPk, ourSk) { const nonce = Uint8Array.from(Array(24).fill(0)); - return sodium.crypto_box_easy(unsigned, nonce, theirPk, ourSk); + return requireSodium().crypto_box_easy(unsigned, nonce, theirPk, ourSk); } function makeOnce(value) { @@ -184,11 +195,11 @@ function makeOnce(value) { }; export function encrypt(unsigned, nonce, key) { - return sodium.crypto_secretbox_easy(unsigned, makeOnce(nonce), key); + return requireSodium().crypto_secretbox_easy(unsigned, makeOnce(nonce), key); } export function decrypt(signed, nonce, key) { - return sodium.crypto_secretbox_open_easy(signed, makeOnce(nonce), key); + return requireSodium().crypto_secretbox_open_easy(signed, makeOnce(nonce), key); } window.setByName = (name, value) => { diff --git a/flutter/web/js/src/globals.test.ts b/flutter/web/js/src/globals.test.ts index d7fce9633d7..d43adc1101f 100644 --- a/flutter/web/js/src/globals.test.ts +++ b/flutter/web/js/src/globals.test.ts @@ -77,6 +77,7 @@ import { isDesktop, msgbox, pushEvent, setConn, getConn, close, newConn, verify, genBoxKeyPair, genSecretKey, seal, encrypt, decrypt, getPeers, copyToClipboard, draw, sendOffCanvas, initAudio, playAudio, + initSodium, } from "./globals"; describe("isDesktop", () => { @@ -532,6 +533,56 @@ describe("initAudio / playAudio", () => { }); }); +describe("sodium initialization", () => { + it("initSodium resolves without error", async () => { + await expect(initSodium()).resolves.toBeUndefined(); + }); + + it("initSodium is idempotent", async () => { + await initSodium(); + await initSodium(); + }); + + it("crypto functions work after initSodium", async () => { + await initSodium(); + expect(() => genBoxKeyPair()).not.toThrow(); + expect(() => genSecretKey()).not.toThrow(); + }); + + it("source: all crypto functions use requireSodium() not raw _sodium", async () => { + const fs = await import("fs"); + const path = await import("path"); + const source = fs.readFileSync(path.resolve(__dirname, "globals.js"), "utf-8"); + + const cryptoSection = source.slice(source.indexOf("function requireSodium()")); + const cryptoFunctions = cryptoSection.match(/export (?:async )?function \w+/g) || []; + expect(cryptoFunctions.length).toBeGreaterThan(0); + + const lines = cryptoSection.split("\n"); + for (const line of lines) { + if (line.includes("import _sodium") || line.includes("_sodium.ready")) continue; + if (line.match(/\b_sodium\b/) && !line.includes("{ sodium = _sodium; }")) { + throw new Error(`Direct _sodium usage found: ${line.trim()}`); + } + } + }); + + it("source: connection.ts calls initSodium() before crypto operations", async () => { + const fs = await import("fs"); + const path = await import("path"); + const source = fs.readFileSync(path.resolve(__dirname, "connection.ts"), "utf-8"); + + const startMethod = source.slice(source.indexOf("async _start(")); + const initLine = startMethod.indexOf("initSodium()"); + expect(initLine).toBeGreaterThan(-1); + + const verifyLine = startMethod.indexOf("verify("); + if (verifyLine > -1) { + expect(initLine).toBeLessThan(verifyLine); + } + }); +}); + describe("window.init", () => { it("runs init sequence", async () => { await (window as any).init(); From ee9970e749b2f7511e505fbd24a7c81477d7b575 Mon Sep 17 00:00:00 2001 From: Rophy Tsai Date: Fri, 21 Aug 2026 23:40:11 +0000 Subject: [PATCH 03/12] fix: don't treat default PunchHoleResponse failure (0) as ID_NOT_EXIST Protobuf default enum value 0 maps to ID_NOT_EXIST, causing a false error on valid responses. Skip falsy failure values, add return after failure switch, and add a default case for unknown failure codes. Fixes #2 --- flutter/web/js/src/connection.test.ts | 10 ++++++++-- flutter/web/js/src/connection.ts | 11 ++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/flutter/web/js/src/connection.test.ts b/flutter/web/js/src/connection.test.ts index 0cb3d15b205..c273a9e1a07 100644 --- a/flutter/web/js/src/connection.test.ts +++ b/flutter/web/js/src/connection.test.ts @@ -667,10 +667,10 @@ describe("Connection", () => { expect(globals.msgbox).toHaveBeenCalledWith("error", "Error", "Server busy"); }); - it("handles ID_NOT_EXIST failure", async () => { + it("does not treat default failure value (0) as an error", async () => { nextWsResponse = { punch_hole_response: { failure: 0 } }; await (conn as any)._start("test-peer"); - expect(globals.msgbox).toHaveBeenCalledWith("error", "Error", "ID does not exist"); + expect(globals.msgbox).not.toHaveBeenCalledWith("error", expect.anything(), expect.anything()); }); it("handles OFFLINE failure", async () => { @@ -691,6 +691,12 @@ describe("Connection", () => { expect(globals.msgbox).toHaveBeenCalledWith("error", "Error", "Key overuse"); }); + it("handles unrecognized nonzero failure with generic message", async () => { + nextWsResponse = { punch_hole_response: { failure: 99 } }; + await (conn as any)._start("test-peer"); + expect(globals.msgbox).toHaveBeenCalledWith("error", "Error", "Connection failed"); + }); + it("handles relay_response with no version", async () => { nextWsResponse = { relay_response: { version: 0, pk: new Uint8Array(32), uuid: "test-uuid" } }; await (conn as any)._start("test-peer"); diff --git a/flutter/web/js/src/connection.ts b/flutter/web/js/src/connection.ts index 91e8122f088..e8bf58d6d9a 100644 --- a/flutter/web/js/src/connection.ts +++ b/flutter/web/js/src/connection.ts @@ -109,11 +109,8 @@ export default class Connection { this.msgbox("error", "Error", phr?.other_failure); return; } - if (phr.failure != rendezvous.PunchHoleResponse_Failure.UNRECOGNIZED) { - switch (phr?.failure) { - case rendezvous.PunchHoleResponse_Failure.ID_NOT_EXIST: - this.msgbox("error", "Error", "ID does not exist"); - break; + if (phr.failure) { + switch (phr.failure) { case rendezvous.PunchHoleResponse_Failure.OFFLINE: this.msgbox("error", "Error", "Remote desktop is offline"); break; @@ -123,7 +120,11 @@ export default class Connection { case rendezvous.PunchHoleResponse_Failure.LICENSE_OVERUSE: this.msgbox("error", "Error", "Key overuse"); break; + default: + this.msgbox("error", "Error", "Connection failed"); + break; } + return; } } else if (rr) { if (!rr.version) { From 51badca3aeb4903d66800b3628497e5b0c461fb3 Mon Sep 17 00:00:00 2001 From: Rophy Tsai Date: Fri, 21 Aug 2026 23:44:03 +0000 Subject: [PATCH 04/12] fix: handle VP9 decoder readiness and lifecycle in web client Guard handleVideoFrame when decoder isn't loaded yet (ack frames to prevent peer stall). Track decoder generation to discard stale async callbacks after close() or display switch. Validate OGVLoader before use and propagate codec-load failures. Fixes #4 --- flutter/web/js/src/codec.js | 44 ++++++++++++++++----------- flutter/web/js/src/codec.test.ts | 23 ++++++++++++++ flutter/web/js/src/connection.test.ts | 24 +++++++++++++++ flutter/web/js/src/connection.ts | 19 +++++++++++- 4 files changed, 92 insertions(+), 18 deletions(-) diff --git a/flutter/web/js/src/codec.js b/flutter/web/js/src/codec.js index 27c9565ec70..0d44345716b 100644 --- a/flutter/web/js/src/codec.js +++ b/flutter/web/js/src/codec.js @@ -21,23 +21,33 @@ */ import { simd } from "wasm-feature-detect"; -export async function loadVp9(callback) { - // Multithreading is used only if `options.threading` is true. - // This requires browser support for the new `SharedArrayBuffer` and `Atomics` APIs, - // currently available in Firefox and Chrome with experimental flags enabled. - // 所有主流浏览器均默认于2018年1月5日禁用SharedArrayBuffer +export async function loadVp9(callback, onError) { + if (!window.OGVLoader) { + const err = new Error("OGVLoader not available"); + if (onError) onError(err); + else console.error(err); + return; + } const isSIMD = await simd(); console.log('isSIMD: ' + isSIMD); - window.OGVLoader.loadClass( - isSIMD ? "OGVDecoderVideoVP9SIMDW" : "OGVDecoderVideoVP9W", - (videoCodecClass) => { - window.videoCodecClass = videoCodecClass; - videoCodecClass({ videoFormat: {} }).then((decoder) => { - decoder.init(() => { - callback(decoder); - }) - }) - }, - { worker: true, threading: true } - ); + try { + window.OGVLoader.loadClass( + isSIMD ? "OGVDecoderVideoVP9SIMDW" : "OGVDecoderVideoVP9W", + (videoCodecClass) => { + window.videoCodecClass = videoCodecClass; + videoCodecClass({ videoFormat: {} }).then((decoder) => { + decoder.init(() => { + callback(decoder); + }) + }).catch((err) => { + if (onError) onError(err); + else console.error("VP9 decoder init failed:", err); + }); + }, + { worker: true, threading: true } + ); + } catch (err) { + if (onError) onError(err); + else console.error("VP9 load failed:", err); + } } \ No newline at end of file diff --git a/flutter/web/js/src/codec.test.ts b/flutter/web/js/src/codec.test.ts index 8d8604846e8..e9674d5b982 100644 --- a/flutter/web/js/src/codec.test.ts +++ b/flutter/web/js/src/codec.test.ts @@ -54,4 +54,27 @@ describe("loadVp9", () => { await loadVp9(vi.fn()); expect((window as any).videoCodecClass).toBeDefined(); }); + + it("calls onError when OGVLoader is missing", async () => { + delete (window as any).OGVLoader; + const callback = vi.fn(); + const onError = vi.fn(); + await loadVp9(callback, onError); + expect(callback).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + + it("calls onError when decoder init rejects", async () => { + vi.mocked(simd).mockResolvedValue(false); + const initError = new Error("init failed"); + mockLoadClass.mockImplementation((cls: string, cb: Function) => { + cb(vi.fn().mockRejectedValue(initError)); + }); + const callback = vi.fn(); + const onError = vi.fn(); + await loadVp9(callback, onError); + await new Promise(r => setTimeout(r, 0)); + expect(callback).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith(initError); + }); }); diff --git a/flutter/web/js/src/connection.test.ts b/flutter/web/js/src/connection.test.ts index c273a9e1a07..e32e4cf7f59 100644 --- a/flutter/web/js/src/connection.test.ts +++ b/flutter/web/js/src/connection.test.ts @@ -658,6 +658,30 @@ describe("Connection", () => { expect(mockWs.sendMessage).toHaveBeenCalled(); }); + + it("acks frames without crashing when decoder is not ready", () => { + (conn as any)._videoDecoder = undefined; + (conn as any)._firstFrame = true; + (conn as any)._ws = mockWs; + + conn.handleVideoFrame({ + vp9s: { frames: [{ data: new Uint8Array([1]) }] }, + } as any); + + expect(mockWs.sendMessage).toHaveBeenCalled(); + }); + }); + + describe("decoder lifecycle", () => { + it("close() clears decoder and increments generation", () => { + const mockDecoder = { close: vi.fn() }; + (conn as any)._videoDecoder = mockDecoder; + const genBefore = (conn as any)._decoderGeneration; + conn.close(); + expect(mockDecoder.close).toHaveBeenCalled(); + expect((conn as any)._videoDecoder).toBeUndefined(); + expect((conn as any)._decoderGeneration).toBeGreaterThan(genBefore); + }); }); describe("_start", () => { diff --git a/flutter/web/js/src/connection.ts b/flutter/web/js/src/connection.ts index e8bf58d6d9a..18b82c95744 100644 --- a/flutter/web/js/src/connection.ts +++ b/flutter/web/js/src/connection.ts @@ -32,6 +32,7 @@ export default class Connection { _peerInfo: message.PeerInfo | undefined; _firstFrame: Boolean | undefined; _videoDecoder: any; + _decoderGeneration: number; _password: Uint8Array | undefined; _options: any; _videoTestSpeed: number[]; @@ -43,6 +44,7 @@ export default class Connection { this._msgs = []; this._id = ""; this._videoTestSpeed = [0, 0]; + this._decoderGeneration = 0; //this._cursors = {}; } @@ -331,6 +333,8 @@ export default class Connection { clearInterval(this._interval); this._ws?.close(); this._videoDecoder?.close(); + this._videoDecoder = undefined; + this._decoderGeneration++; } refresh() { @@ -426,6 +430,10 @@ export default class Connection { } if (vf.vp9s) { const dec = this._videoDecoder; + if (!dec) { + this.sendVideoReceived(); + return; + } var tm = new Date().getTime(); var i = 0; const n = vf.vp9s?.frames.length; @@ -716,10 +724,19 @@ export default class Connection { loadVideoDecoder() { this._videoDecoder?.close(); + this._videoDecoder = undefined; + const gen = ++this._decoderGeneration; loadVp9((decoder: any) => { + if (gen !== this._decoderGeneration) { + decoder.close(); + return; + } this._videoDecoder = decoder; console.log("vp9 loaded"); - console.log(decoder); + }, (err: any) => { + if (gen !== this._decoderGeneration) return; + this.msgbox("error", "Error", "Failed to load video decoder"); + console.error("VP9 load failed:", err); }); } } From 0bf5bacf9e47a1236ebb1d639f22ddd12c8d0668 Mon Sep 17 00:00:00 2001 From: Rophy Tsai Date: Fri, 21 Aug 2026 23:46:18 +0000 Subject: [PATCH 05/12] fix: clean up PCMPlayer during audio reinitialization Destroy existing PCMPlayer (closes AudioContext) before creating a new one. Guard opus worker callback against uninitialized player. Fixes #5 --- flutter/web/js/src/globals.js | 3 ++- flutter/web/js/src/globals.test.ts | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/flutter/web/js/src/globals.js b/flutter/web/js/src/globals.js index 266a4949584..5d427facc82 100644 --- a/flutter/web/js/src/globals.js +++ b/flutter/web/js/src/globals.js @@ -385,6 +385,7 @@ let opusWorker = new Worker("./libopus.js"); let pcmPlayer; export function initAudio(channels, sampleRate) { + if (pcmPlayer) pcmPlayer.destroy(); pcmPlayer = newAudioPlayer(channels, sampleRate); opusWorker.postMessage({ channels, sampleRate }); } @@ -400,7 +401,7 @@ window.init = async () => { } } opusWorker.onmessage = (e) => { - pcmPlayer.feed(e.data); + if (pcmPlayer) pcmPlayer.feed(e.data); } await loadConfig(); loadVp9(() => { }); diff --git a/flutter/web/js/src/globals.test.ts b/flutter/web/js/src/globals.test.ts index d43adc1101f..d519351ce44 100644 --- a/flutter/web/js/src/globals.test.ts +++ b/flutter/web/js/src/globals.test.ts @@ -68,6 +68,7 @@ vi.mock("./common", () => ({ vi.mock("pcm-player", () => { class MockPCMPlayer { feed = vi.fn(); + destroy = vi.fn(); constructor(_opts: any) {} } return { default: MockPCMPlayer }; @@ -527,6 +528,11 @@ describe("initAudio / playAudio", () => { initAudio(2, 48000); }); + it("destroys previous player on reinit", () => { + initAudio(2, 48000); + initAudio(2, 44100); + }); + it("sends audio packet to opus worker", () => { const packet = new Uint8Array([1, 2, 3]); playAudio(packet); From a6d9bad629d44c1378be6dd3141312a875f8b064 Mon Sep 17 00:00:00 2001 From: Rophy Tsai Date: Fri, 21 Aug 2026 23:48:48 +0000 Subject: [PATCH 06/12] fix: rename window.confirm override and guard getConn() in password handler Rename to window.submitPassword to avoid clobbering the native browser confirm() API. Guard getConn() result before calling login() to prevent crash after connection cancellation. Fixes #7 --- flutter/web/js/src/ui.js | 9 +++++---- flutter/web/js/src/ui.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/flutter/web/js/src/ui.js b/flutter/web/js/src/ui.js index e95ca5d32af..b07d2e6360c 100644 --- a/flutter/web/js/src/ui.js +++ b/flutter/web/js/src/ui.js @@ -14,7 +14,7 @@ if (app) { @@ -106,7 +106,7 @@ if (app) { } window.submitPassword = () => { - const password = document.querySelector('input#password').value; + const password = document.querySelector('#password-input').value; const conn = globals.getConn(); if (password && conn) { document.querySelector('div#password').style.display = 'none'; From fee278a8132b0ae8699f1c1d1a8051bf70249fb7 Mon Sep 17 00:00:00 2001 From: Rophy Tsai Date: Fri, 21 Aug 2026 23:52:30 +0000 Subject: [PATCH 08/12] refactor: replace polling in Websock.next() with event-driven delivery Store a pending resolver/rejecter when the buffer is empty instead of polling with recursive setTimeout(..., 1). Messages resolve the pending promise directly; close, error, and timeout reject it. Removes ~1000 wakeups/second during idle waits. Fixes #8 --- flutter/web/js/src/websock.test.ts | 63 +++++++++++++++++++++++++++ flutter/web/js/src/websock.ts | 68 ++++++++++++++++++------------ 2 files changed, 105 insertions(+), 26 deletions(-) diff --git a/flutter/web/js/src/websock.test.ts b/flutter/web/js/src/websock.test.ts index 422651fcac6..87873d982b9 100644 --- a/flutter/web/js/src/websock.test.ts +++ b/flutter/web/js/src/websock.test.ts @@ -212,4 +212,67 @@ describe("Websock", () => { const result = ws.parseRendezvous(new Uint8Array([1])); expect(result).toHaveProperty("rendezvous", true); }); + + it("next resolves immediately when message arrives after call", async () => { + const ws = new Websock("ws://test:1234", true); + const openPromise = ws.open(1000); + mockWsInstances[0].simulateOpen(); + await openPromise; + + const nextPromise = ws.next(1000); + mockWsInstances[0].simulateMessage(new Uint8Array([5, 6]).buffer); + const msg = await nextPromise; + expect(msg).toHaveProperty("rendezvous", true); + }); + + it("next does not use polling (no recursive setTimeout)", async () => { + const fs = await import("fs"); + const path = await import("path"); + const source = fs.readFileSync( + path.resolve(__dirname, "websock.ts"), + "utf-8" + ); + const nextMethod = source.slice( + source.indexOf("async next("), + source.indexOf("_settlePending(") + ); + expect(nextMethod).not.toMatch(/setTimeout\(\s*\(\)\s*=>\s*func/); + }); + + it("next rejects when connection closes while waiting", async () => { + const ws = new Websock("ws://test:1234", true); + const openPromise = ws.open(1000); + mockWsInstances[0].simulateOpen(); + await openPromise; + + const nextPromise = ws.next(5000); + ws.close(); + await expect(nextPromise).rejects.toBe("Connection closed"); + }); + + it("next rejects when remote closes while waiting", async () => { + const ws = new Websock("ws://test:1234", true); + const openPromise = ws.open(1000); + mockWsInstances[0].simulateOpen(); + await openPromise; + + const nextPromise = ws.next(5000); + mockWsInstances[0].simulateClose(1006); + await expect(nextPromise).rejects.toBe("Reset by the peer"); + }); + + it("buffers messages when no next() is pending", async () => { + const ws = new Websock("ws://test:1234", true); + const openPromise = ws.open(1000); + mockWsInstances[0].simulateOpen(); + await openPromise; + + mockWsInstances[0].simulateMessage(new Uint8Array([1]).buffer); + mockWsInstances[0].simulateMessage(new Uint8Array([2]).buffer); + + const msg1 = await ws.next(1000); + const msg2 = await ws.next(1000); + expect(msg1).toHaveProperty("rendezvous", true); + expect(msg2).toHaveProperty("rendezvous", true); + }); }); diff --git a/flutter/web/js/src/websock.ts b/flutter/web/js/src/websock.ts index 6f05e6f6bd1..280cce86c95 100644 --- a/flutter/web/js/src/websock.ts +++ b/flutter/web/js/src/websock.ts @@ -13,6 +13,9 @@ export default class Websock { _secretKey: [Uint8Array, number, number] | undefined; _uri: string; _isRendezvous: boolean; + _pendingResolve: ((value: rendezvous.RendezvousMessage | message.Message) => void) | undefined; + _pendingReject: ((reason: any) => void) | undefined; + _pendingTimer: any; constructor(uri: string, isRendezvous: boolean = true) { this._eventHandlers = { @@ -105,6 +108,7 @@ export default class Websock { this._status = e; console.error("WebSock.onclose: "); console.error(e); + this._settlePending(undefined, "Reset by the peer"); this._eventHandlers.close(e); reject("Reset by the peer"); }; @@ -116,6 +120,7 @@ export default class Websock { this._status = e; console.error("WebSock.onerror: ") console.error(e); + this._settlePending(undefined, e); this._eventHandlers.error(e); }; }); @@ -124,33 +129,41 @@ export default class Websock { async next( timeout = 12000 ): Promise { - const func = ( - resolve: (value: rendezvous.RendezvousMessage | message.Message) => void, - reject: (reason: any) => void, - tm0: number - ) => { - if (this._buf.length) { - resolve(this._buf[0]); - this._buf.splice(0, 1); - } else { - if (this._status != "open") { - reject(this._status); - return; - } - if (new Date().getTime() > tm0 + timeout) { - reject("Timeout"); - } else { - setTimeout(() => func(resolve, reject, tm0), 1); - } - } - }; + if (this._buf.length) { + return this._buf.shift()!; + } + if (this._status != "open") { + throw this._status; + } return new Promise((resolve, reject) => { - func(resolve, reject, new Date().getTime()); + this._pendingResolve = resolve; + this._pendingReject = reject; + this._pendingTimer = setTimeout(() => { + this._settlePending(undefined, "Timeout"); + }, timeout); }); } + _settlePending( + value?: rendezvous.RendezvousMessage | message.Message, + reason?: any + ) { + const resolve = this._pendingResolve; + const reject = this._pendingReject; + clearTimeout(this._pendingTimer); + this._pendingResolve = undefined; + this._pendingReject = undefined; + this._pendingTimer = undefined; + if (value !== undefined && resolve) { + resolve(value); + } else if (reason !== undefined && reject) { + reject(reason); + } + } + close() { this._status = ""; + this._settlePending(undefined, "Connection closed"); if (this._websocket) { if ( this._websocket.readyState === WebSocket.OPEN || @@ -172,11 +185,14 @@ export default class Websock { k[2] += 1; bytes = globals.decrypt(bytes, k[2], k[0]); } - this._buf.push( - this._isRendezvous - ? this.parseRendezvous(bytes) - : this.parseMessage(bytes) - ); + const msg = this._isRendezvous + ? this.parseRendezvous(bytes) + : this.parseMessage(bytes); + if (this._pendingResolve) { + this._settlePending(msg); + } else { + this._buf.push(msg); + } } this._eventHandlers.message(e.data); } From 0ccf02269e03666dceef70f232fca102150ecf1d Mon Sep 17 00:00:00 2001 From: Rophy Tsai Date: Sat, 22 Aug 2026 00:04:56 +0000 Subject: [PATCH 09/12] feat: support path-based WebSocket URIs with auto scheme selection Config values starting with "/" are resolved against the page origin, using wss:// on HTTPS and ws:// on HTTP. Defaults to /hbbs and /hbbr for zero-config same-origin deployments behind a reverse proxy. Full URIs (ws://host:port) still work for split-domain setups. Fixes #10 --- deploy/docker/webclient/Dockerfile | 13 +++--- deploy/docker/webclient/README.md | 63 +++++++++++++++++++++--------- flutter/web/js/src/url.test.ts | 55 +++++++++++++++++++++----- flutter/web/js/src/url.ts | 18 ++++++--- 4 files changed, 108 insertions(+), 41 deletions(-) diff --git a/deploy/docker/webclient/Dockerfile b/deploy/docker/webclient/Dockerfile index b055662e316..c941961582b 100644 --- a/deploy/docker/webclient/Dockerfile +++ b/deploy/docker/webclient/Dockerfile @@ -53,16 +53,13 @@ COPY <<'EOF' /docker-entrypoint.d/90-rustdesk-config.sh #!/bin/sh set -e CONFIG=/usr/share/nginx/html/config.json -if [ -n "$RUSTDESK_HOST" ]; then - cat > "$CONFIG" < "$CONFIG" < { + it("resolves path to wss:// on HTTPS page", () => { + Object.defineProperty(globalThis, "location", { + value: { protocol: "https:", host: "rustdesk.corp.com" }, + writable: true, + }); + expect(resolveUri("/hbbs")).toBe("wss://rustdesk.corp.com/hbbs"); + expect(resolveUri("/hbbr")).toBe("wss://rustdesk.corp.com/hbbr"); + }); + + it("resolves path to ws:// on HTTP page", () => { + (globalThis as any).location = { protocol: "http:", host: "localhost:8080" }; + expect(resolveUri("/hbbs")).toBe("ws://localhost:8080/hbbs"); + }); + + it("returns full URI as-is", () => { + expect(resolveUri("wss://example.com/hbbs")).toBe("wss://example.com/hbbs"); + expect(resolveUri("ws://127.0.0.1:21118")).toBe("ws://127.0.0.1:21118"); + }); + + it("returns host:port as-is", () => { + expect(resolveUri("myserver.com:21118")).toBe("myserver.com:21118"); + }); +}); describe("getDefaultUri", () => { beforeEach(() => { - setConfig("", "", ""); + setConfig("/hbbs", "/hbbr", ""); + (globalThis as any).location = { protocol: "https:", host: "rustdesk.corp.com" }; + }); + + it("defaults resolve to same-origin wss paths", () => { + expect(getDefaultUri()).toBe("wss://rustdesk.corp.com/hbbs"); + expect(getDefaultUri(true)).toBe("wss://rustdesk.corp.com/hbbr"); }); it("returns full wss:// host URL without modification", () => { @@ -22,14 +53,9 @@ describe("getDefaultUri", () => { expect(getDefaultUri(true)).toBe("ws://127.0.0.1:12022/hbbr"); }); - it("returns host:port as-is", () => { - setConfig("myserver.com:21116", "", ""); - expect(getDefaultUri()).toBe("myserver.com:21116"); - }); - it("falls back to HOST when RELAY_HOST is empty", () => { - setConfig("wss://rustdesk.example.com/hbbs", "", ""); - expect(getDefaultUri(true)).toBe("wss://rustdesk.example.com/hbbs"); + setConfig("/hbbs", "", ""); + expect(getDefaultUri(true)).toBe("wss://rustdesk.corp.com/hbbs"); }); it("returns relay when relay is set", () => { @@ -91,4 +117,15 @@ describe("loadConfig", () => { expect(getRelayHost()).toBe(""); expect(getConfigKey()).toBe(""); }); + + it("loads path-based config", async () => { + (globalThis as any).location = { protocol: "https:", host: "myapp.com" }; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ host: "/hbbs", relay: "/hbbr", key: "k1" }), + }); + await loadConfig(); + expect(getDefaultUri()).toBe("wss://myapp.com/hbbs"); + expect(getDefaultUri(true)).toBe("wss://myapp.com/hbbr"); + }); }); diff --git a/flutter/web/js/src/url.ts b/flutter/web/js/src/url.ts index fdbeeff7b91..693ab49f71d 100644 --- a/flutter/web/js/src/url.ts +++ b/flutter/web/js/src/url.ts @@ -1,5 +1,5 @@ -let HOST = ""; -let RELAY_HOST = ""; +let HOST = "/hbbs"; +let RELAY_HOST = "/hbbr"; let CONFIG_KEY = ""; export function setConfig(host: string, relay: string, key: string) { @@ -20,11 +20,17 @@ export function getConfigKey(): string { return CONFIG_KEY; } -export function getDefaultUri(isRelay: Boolean = false): string { - if (isRelay) { - return RELAY_HOST || HOST; +export function resolveUri(value: string): string { + if (value.startsWith("/")) { + const scheme = location.protocol === "https:" ? "wss" : "ws"; + return scheme + "://" + location.host + value; } - return HOST; + return value; +} + +export function getDefaultUri(isRelay: Boolean = false): string { + const raw = isRelay ? (RELAY_HOST || HOST) : HOST; + return resolveUri(raw); } export async function loadConfig(): Promise { From 583afceb288bb4eb340fd1903ad4ee080cb3bddf Mon Sep 17 00:00:00 2001 From: Rophy Tsai Date: Sat, 22 Aug 2026 04:31:08 +0000 Subject: [PATCH 10/12] fix: address CodeRabbit review findings - Move simd() await inside try block in codec.js - Replace single pending resolve/reject with FIFO queue in websock.ts - Assert destroy() called on previous PCMPlayer in reinit test - Add proxy_read_timeout 3600s to nginx WebSocket example - Document wss:// split-domain deployment in README --- deploy/docker/webclient/README.md | 10 ++++++ flutter/web/js/src/codec.js | 4 +-- flutter/web/js/src/globals.test.ts | 8 ++++- flutter/web/js/src/websock.ts | 54 ++++++++++++++++++------------ 4 files changed, 52 insertions(+), 24 deletions(-) diff --git a/deploy/docker/webclient/README.md b/deploy/docker/webclient/README.md index 187373020f5..4fec37f3d32 100644 --- a/deploy/docker/webclient/README.md +++ b/deploy/docker/webclient/README.md @@ -17,11 +17,19 @@ The web client defaults to same-origin WebSocket paths `/hbbs` and `/hbbr`, with For split-domain deployments (hbbs/hbbr on a different host): ```bash +# Plain WebSocket (development) docker run -d -p 8080:80 \ -e RUSTDESK_HOST=ws://hbbs.example.com:21118 \ -e RUSTDESK_RELAY=ws://hbbr.example.com:21119 \ -e RUSTDESK_KEY=your-public-key \ rophy/rustdesk-webclient + +# Secure WebSocket via TLS-terminating proxy +docker run -d -p 8080:80 \ + -e RUSTDESK_HOST=wss://hbbs.example.com/ws \ + -e RUSTDESK_RELAY=wss://hbbr.example.com/ws \ + -e RUSTDESK_KEY=your-public-key \ + rophy/rustdesk-webclient ``` Then open http://localhost:8080 in a browser. @@ -54,6 +62,7 @@ location /hbbs { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; + proxy_read_timeout 3600s; } location /hbbr { @@ -61,6 +70,7 @@ location /hbbr { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; + proxy_read_timeout 3600s; } ``` diff --git a/flutter/web/js/src/codec.js b/flutter/web/js/src/codec.js index 0d44345716b..3b39626b243 100644 --- a/flutter/web/js/src/codec.js +++ b/flutter/web/js/src/codec.js @@ -28,9 +28,9 @@ export async function loadVp9(callback, onError) { else console.error(err); return; } - const isSIMD = await simd(); - console.log('isSIMD: ' + isSIMD); try { + const isSIMD = await simd(); + console.log('isSIMD: ' + isSIMD); window.OGVLoader.loadClass( isSIMD ? "OGVDecoderVideoVP9SIMDW" : "OGVDecoderVideoVP9W", (videoCodecClass) => { diff --git a/flutter/web/js/src/globals.test.ts b/flutter/web/js/src/globals.test.ts index d519351ce44..c2bb76a5ee2 100644 --- a/flutter/web/js/src/globals.test.ts +++ b/flutter/web/js/src/globals.test.ts @@ -65,11 +65,14 @@ vi.mock("./common", () => ({ translate: vi.fn((locale: string, text: string) => text), })); +const mockPCMPlayerInstances: Array<{ feed: ReturnType; destroy: ReturnType }> = []; vi.mock("pcm-player", () => { class MockPCMPlayer { feed = vi.fn(); destroy = vi.fn(); - constructor(_opts: any) {} + constructor(_opts: any) { + mockPCMPlayerInstances.push(this); + } } return { default: MockPCMPlayer }; }); @@ -529,8 +532,11 @@ describe("initAudio / playAudio", () => { }); it("destroys previous player on reinit", () => { + const before = mockPCMPlayerInstances.length; initAudio(2, 48000); + const firstPlayer = mockPCMPlayerInstances[before]; initAudio(2, 44100); + expect(firstPlayer.destroy).toHaveBeenCalled(); }); it("sends audio packet to opus worker", () => { diff --git a/flutter/web/js/src/websock.ts b/flutter/web/js/src/websock.ts index 280cce86c95..c3e6583d131 100644 --- a/flutter/web/js/src/websock.ts +++ b/flutter/web/js/src/websock.ts @@ -13,9 +13,11 @@ export default class Websock { _secretKey: [Uint8Array, number, number] | undefined; _uri: string; _isRendezvous: boolean; - _pendingResolve: ((value: rendezvous.RendezvousMessage | message.Message) => void) | undefined; - _pendingReject: ((reason: any) => void) | undefined; - _pendingTimer: any; + _pendingQueue: Array<{ + resolve: (value: rendezvous.RendezvousMessage | message.Message) => void; + reject: (reason: any) => void; + timer: any; + }>; constructor(uri: string, isRendezvous: boolean = true) { this._eventHandlers = { @@ -27,6 +29,7 @@ export default class Websock { this._uri = uri; this._status = ""; this._buf = []; + this._pendingQueue = []; this._websocket = new WebSocket(uri); this._websocket.onmessage = this._recv_message.bind(this); this._websocket.binaryType = "arraybuffer"; @@ -108,7 +111,7 @@ export default class Websock { this._status = e; console.error("WebSock.onclose: "); console.error(e); - this._settlePending(undefined, "Reset by the peer"); + this._rejectAllPending("Reset by the peer"); this._eventHandlers.close(e); reject("Reset by the peer"); }; @@ -120,7 +123,7 @@ export default class Websock { this._status = e; console.error("WebSock.onerror: ") console.error(e); - this._settlePending(undefined, e); + this._rejectAllPending(e); this._eventHandlers.error(e); }; }); @@ -136,11 +139,15 @@ export default class Websock { throw this._status; } return new Promise((resolve, reject) => { - this._pendingResolve = resolve; - this._pendingReject = reject; - this._pendingTimer = setTimeout(() => { - this._settlePending(undefined, "Timeout"); + const entry = { resolve, reject, timer: undefined as any }; + entry.timer = setTimeout(() => { + const idx = this._pendingQueue.indexOf(entry); + if (idx !== -1) { + this._pendingQueue.splice(idx, 1); + reject("Timeout"); + } }, timeout); + this._pendingQueue.push(entry); }); } @@ -148,22 +155,27 @@ export default class Websock { value?: rendezvous.RendezvousMessage | message.Message, reason?: any ) { - const resolve = this._pendingResolve; - const reject = this._pendingReject; - clearTimeout(this._pendingTimer); - this._pendingResolve = undefined; - this._pendingReject = undefined; - this._pendingTimer = undefined; - if (value !== undefined && resolve) { - resolve(value); - } else if (reason !== undefined && reject) { - reject(reason); + const entry = this._pendingQueue.shift(); + if (!entry) return; + clearTimeout(entry.timer); + if (value !== undefined) { + entry.resolve(value); + } else if (reason !== undefined) { + entry.reject(reason); + } + } + + _rejectAllPending(reason: any) { + while (this._pendingQueue.length) { + const entry = this._pendingQueue.shift()!; + clearTimeout(entry.timer); + entry.reject(reason); } } close() { this._status = ""; - this._settlePending(undefined, "Connection closed"); + this._rejectAllPending("Connection closed"); if (this._websocket) { if ( this._websocket.readyState === WebSocket.OPEN || @@ -188,7 +200,7 @@ export default class Websock { const msg = this._isRendezvous ? this.parseRendezvous(bytes) : this.parseMessage(bytes); - if (this._pendingResolve) { + if (this._pendingQueue.length) { this._settlePending(msg); } else { this._buf.push(msg); From 02cb3c1ad1f339fbd477330a6a8cdec97e26298d Mon Sep 17 00:00:00 2001 From: Rophy Tsai Date: Sat, 22 Aug 2026 05:19:46 +0000 Subject: [PATCH 11/12] fix: use consistent /hbbs /hbbr paths in wss:// README example --- deploy/docker/webclient/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/docker/webclient/README.md b/deploy/docker/webclient/README.md index 4fec37f3d32..7a14b157b73 100644 --- a/deploy/docker/webclient/README.md +++ b/deploy/docker/webclient/README.md @@ -26,8 +26,8 @@ docker run -d -p 8080:80 \ # Secure WebSocket via TLS-terminating proxy docker run -d -p 8080:80 \ - -e RUSTDESK_HOST=wss://hbbs.example.com/ws \ - -e RUSTDESK_RELAY=wss://hbbr.example.com/ws \ + -e RUSTDESK_HOST=wss://hbbs.example.com/hbbs \ + -e RUSTDESK_RELAY=wss://hbbr.example.com/hbbr \ -e RUSTDESK_KEY=your-public-key \ rophy/rustdesk-webclient ``` From a8ce231120cfcb8b7032a29eaa2dde4425b71205 Mon Sep 17 00:00:00 2001 From: Rophy Tsai Date: Sat, 22 Aug 2026 06:41:48 +0000 Subject: [PATCH 12/12] test: add behavioral ui tests and websock FIFO concurrency tests - Test msgbox callback: XSS prevention, error styling, password flow - Test submitPassword guards empty input and calls conn.login - Test concurrent next() calls resolve in FIFO order - Test independent timeout for concurrent next() calls --- flutter/web/js/src/ui.test.ts | 83 ++++++++++++++++++++++++++++++ flutter/web/js/src/websock.test.ts | 34 ++++++++++++ 2 files changed, 117 insertions(+) diff --git a/flutter/web/js/src/ui.test.ts b/flutter/web/js/src/ui.test.ts index 6b7f007d9ed..ab55b8be533 100644 --- a/flutter/web/js/src/ui.test.ts +++ b/flutter/web/js/src/ui.test.ts @@ -99,6 +99,89 @@ describe("ui.js XSS prevention", () => { }); }); +describe("ui.js behavioral tests", () => { + let mockConn: any; + let mockGlobals: any; + + beforeEach(async () => { + document.body.innerHTML = '
'; + + (globalThis as any).YUVCanvas = { + attach: vi.fn(() => ({ drawFrame: vi.fn() })), + }; + (window as any).init = vi.fn(); + + vi.resetModules(); + + mockConn = { + setMsgbox: vi.fn(), + setDraw: vi.fn(), + start: vi.fn(), + login: vi.fn(), + }; + mockGlobals = { + newConn: vi.fn(() => mockConn), + getConn: vi.fn(() => mockConn), + close: vi.fn(), + draw: vi.fn(), + }; + + vi.doMock("./style.css", () => ({})); + vi.doMock("./connection", () => ({})); + vi.doMock("./globals", () => mockGlobals); + + await import("./ui.js"); + + (window as any).connect(); + }); + + it("msgbox input-password shows password div, hides status", () => { + const msgbox = mockConn.setMsgbox.mock.calls[0][0]; + msgbox("input-password", "Password", "Enter password"); + + expect((document.querySelector("div#password") as HTMLElement).style.display).toBe("block"); + expect((document.querySelector("div#status") as HTMLElement).style.display).toBe("none"); + }); + + it("msgbox error shows red status text via textContent", () => { + const msgbox = mockConn.setMsgbox.mock.calls[0][0]; + const malicious = ''; + msgbox("error", "Error", malicious); + + const textEl = document.querySelector("div#text") as HTMLElement; + expect(textEl.textContent).toBe(malicious); + expect(textEl.innerHTML).not.toContain(" { + const msgbox = mockConn.setMsgbox.mock.calls[0][0]; + msgbox("connecting", "Status", "Connecting..."); + + const textEl = document.querySelector("div#text") as HTMLElement; + expect(textEl.textContent).toBe("Connecting..."); + expect(textEl.style.color).toBe(""); + }); + + it("submitPassword calls conn.login with password value", () => { + const input = document.querySelector("#password-input") as HTMLInputElement; + input.value = "secret123"; + + (window as any).submitPassword(); + + expect(mockConn.login).toHaveBeenCalledWith("secret123"); + }); + + it("submitPassword does nothing when password is empty", () => { + const input = document.querySelector("#password-input") as HTMLInputElement; + input.value = ""; + + (window as any).submitPassword(); + + expect(mockConn.login).not.toHaveBeenCalled(); + }); +}); + describe("ui.js password confirmation", () => { it("does not override native window.confirm", async () => { const fs = await import("fs"); diff --git a/flutter/web/js/src/websock.test.ts b/flutter/web/js/src/websock.test.ts index 87873d982b9..54558fa32f7 100644 --- a/flutter/web/js/src/websock.test.ts +++ b/flutter/web/js/src/websock.test.ts @@ -261,6 +261,40 @@ describe("Websock", () => { await expect(nextPromise).rejects.toBe("Reset by the peer"); }); + it("concurrent next() calls resolve in FIFO order", async () => { + const ws = new Websock("ws://test:1234", true); + const openPromise = ws.open(1000); + mockWsInstances[0].simulateOpen(); + await openPromise; + + const p1 = ws.next(5000); + const p2 = ws.next(5000); + + mockWsInstances[0].simulateMessage(new Uint8Array([10]).buffer); + mockWsInstances[0].simulateMessage(new Uint8Array([20]).buffer); + + const msg1 = await p1; + const msg2 = await p2; + expect((msg1 as any).data).toEqual(new Uint8Array([10])); + expect((msg2 as any).data).toEqual(new Uint8Array([20])); + }); + + it("concurrent next() calls reject independently on timeout", async () => { + const ws = new Websock("ws://test:1234", true); + const openPromise = ws.open(1000); + mockWsInstances[0].simulateOpen(); + await openPromise; + + const p1 = ws.next(50); + const p2 = ws.next(5000); + + await expect(p1).rejects.toBe("Timeout"); + + mockWsInstances[0].simulateMessage(new Uint8Array([30]).buffer); + const msg2 = await p2; + expect((msg2 as any).data).toEqual(new Uint8Array([30])); + }); + it("buffers messages when no next() is pending", async () => { const ws = new Websock("ws://test:1234", true); const openPromise = ws.open(1000);