diff --git a/js/qmux/src/scheduler.test.ts b/js/qmux/src/scheduler.test.ts index e3013341456..11c3ac8e2c5 100644 --- a/js/qmux/src/scheduler.test.ts +++ b/js/qmux/src/scheduler.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { yieldNow } from "@moq/clock"; -import { SendScheduler, type SendSink } from "./scheduler.ts"; +import { BacklogSink, SendScheduler, type SendSink } from "./scheduler.ts"; /** A sink that starts backpressured; `release()` admits exactly one write. */ class GatedSink implements SendSink { @@ -54,6 +54,10 @@ class GatedSink implements SendSink { wantsMore(): boolean { return this.#admits > 0; } + + async flush(): Promise {} + + readonly closed = new Promise(() => {}); } /** Yield to the event loop `n` times to let queued work run. */ @@ -312,3 +316,79 @@ describe("SendScheduler", () => { expect(activity).toBe(2); }); }); + +/** A writable that counts writes like the native `WebSocketStream`, completing each only on `complete()`. */ +function heldWritable() { + const written: number[] = []; + const held: { resolve: () => void; reject: (err: Error) => void }[] = []; + const writable = new WritableStream({ + write: (chunk) => + new Promise((resolve, reject) => { + written.push(chunk.byteLength); + held.push({ resolve, reject }); + }), + }); + return { writable, written, held }; +} + +describe("BacklogSink", () => { + test("only bytes past the mark are backpressure, not a write in flight", async () => { + const { writable, held } = heldWritable(); + const sink = new BacklogSink(writable, 100); + + await sink.write(new Uint8Array(60)); + expect(sink.wantsMore()).toBe(true); + // Handed over at once, queued in the writable behind the first. + const second = sink.write(new Uint8Array(60)); + expect(sink.wantsMore()).toBe(false); + + let accepted = false; + void second.then(() => { + accepted = true; + }); + await tick(3); + expect(accepted).toBe(false); + held.shift()?.resolve(); + await until(() => accepted); + expect(sink.wantsMore()).toBe(true); + }); + + test("raising the mark admits a parked write", async () => { + const { writable } = heldWritable(); + const sink = new BacklogSink(writable, 10); + let accepted = false; + void sink.write(new Uint8Array(20)).then(() => { + accepted = true; + }); + await tick(3); + expect(accepted).toBe(false); + sink.setHighWaterMark(20); + await until(() => accepted); + }); + + test("a failed write fails the next write, ready, and flush", async () => { + const { writable, held } = heldWritable(); + const sink = new BacklogSink(writable, 100); + await sink.write(new Uint8Array(1)); + const flushed = sink.flush(); + held.shift()?.reject(new Error("socket gone")); + await expect(flushed).rejects.toThrow("socket gone"); + expect(sink.wantsMore()).toBe(false); + await expect(sink.ready()).rejects.toThrow("socket gone"); + await expect(sink.write(new Uint8Array(1))).rejects.toThrow("socket gone"); + }); + + test("close resolves only once the close frame's write completes", async () => { + const { writable, written, held } = heldWritable(); + const sched = new SendScheduler(new BacklogSink(writable, 100)); + let done = false; + void sched.close(new Error("closed"), frame(9)).then(() => { + done = true; + }); + await until(() => written.length === 1); + await tick(3); + expect(done).toBe(false); + held.shift()?.resolve(); + await until(() => done); + }); +}); diff --git a/js/qmux/src/scheduler.ts b/js/qmux/src/scheduler.ts index e83873c61db..ec5b5c8e2e6 100644 --- a/js/qmux/src/scheduler.ts +++ b/js/qmux/src/scheduler.ts @@ -7,9 +7,9 @@ * priorities. Prioritization only bites under backpressure; when the sink has * room every frame is written as soon as it's queued. * - * The backpressure source is abstracted behind {@link SendSink} so we can use - * `WebSocketStream`'s real (event-driven) backpressure where available and fall - * back to polling `WebSocket.bufferedAmount` everywhere else. + * The backpressure source is abstracted behind {@link SendSink}: the ponyfill + * reports its `bufferedAmount` backlog itself, while {@link BacklogSink} counts + * the backlog of any other writable, such as the native `WebSocketStream`. */ /** A write sink over the underlying socket, exposing backpressure. */ @@ -26,12 +26,17 @@ export interface SendSink { * instant the transport starts queuing, rather than letting them pile up * behind a stream/control backlog and arrive stale. */ wantsMore(): boolean; + /** Resolve once every accepted write has reached the socket. Rejects if a + * write failed. */ + flush(): Promise; + /** Settles when the socket's writable closes; rejects once it fails, even + * after a write it already accepted. */ + readonly closed: Promise; } -/** Backpressure-aware sink over a `WebSocketStream` writable. The writable's - * `writer.ready` is the backpressure signal - native `WebSocketStream` ties it - * to the real send buffer, while the `WebSocketStream` ponyfill drives - * it from `bufferedAmount` against a (resizable) high-water mark. */ +/** Sink over a writable whose `desiredSize` already tracks a byte backlog, as + * the `WebSocketStream` ponyfill's does from `bufferedAmount` against its + * (resizable) high-water mark. Each write resolves once the socket holds it. */ export class WritableStreamSink implements SendSink { #writer: WritableStreamDefaultWriter; @@ -39,6 +44,10 @@ export class WritableStreamSink implements SendSink { this.#writer = writable.getWriter(); } + get closed(): Promise { + return this.#writer.closed; + } + ready(): Promise { return this.#writer.ready; } @@ -51,11 +60,85 @@ export class WritableStreamSink implements SendSink { wantsMore(): boolean { // desiredSize > 0 means below the high-water mark (room to write); 0 or // negative means backpressured; null means the stream errored/closed. - // Native WebSocketStream ties this to the real send buffer; the ponyfill - // derives it from bufferedAmount, so both report true backpressure. const desired = this.#writer.desiredSize; return desired !== null && desired > 0; } + + flush(): Promise { + // A resolved write is already in the socket, which flushes it before closing. + return Promise.resolve(); + } +} + +/** Sink over a writable whose own backpressure counts writes rather than bytes, + * such as the native `WebSocketStream`: its `desiredSize` reaches 0 while any + * one write is in flight, however idle the socket. This hands each write over + * at once and counts the bytes whose write has yet to complete as the send + * backlog, held under a resizable high-water mark. */ +export class BacklogSink implements SendSink { + #writer: WritableStreamDefaultWriter; + #highWaterMark: number; + #pending = 0; + #error?: Error; + // Settles whenever the backlog shrinks or a write fails, then is replaced. + #changed = Promise.withResolvers(); + + constructor(writable: WritableStream, highWaterMark: number) { + this.#writer = writable.getWriter(); + this.#highWaterMark = highWaterMark; + } + + get closed(): Promise { + return this.#writer.closed; + } + + /** Resize the high-water mark (bytes), waking a write parked behind the old one. */ + setHighWaterMark(bytes: number): void { + this.#highWaterMark = bytes; + this.#notify(); + } + + async ready(): Promise { + for (;;) { + if (this.#error) throw this.#error; + if (this.#pending <= this.#highWaterMark) return; + await this.#changed.promise; + } + } + + write(bytes: Uint8Array): Promise { + if (this.#error) return Promise.reject(this.#error); + const size = bytes.byteLength; + this.#pending += size; + this.#writer.write(bytes).then( + () => { + this.#pending -= size; + this.#notify(); + }, + (err) => { + this.#error ??= err instanceof Error ? err : new Error(String(err)); + this.#notify(); + }, + ); + return this.ready(); + } + + wantsMore(): boolean { + return !this.#error && this.#pending <= this.#highWaterMark; + } + + async flush(): Promise { + for (;;) { + if (this.#error) throw this.#error; + if (this.#pending === 0) return; + await this.#changed.promise; + } + } + + #notify(): void { + this.#changed.resolve(); + this.#changed = Promise.withResolvers(); + } } /** One stream's single in-flight frame, awaiting its turn on the wire. */ @@ -89,6 +172,7 @@ export class SendScheduler { #onActivity: () => void; #onFail: (err: Error) => void; #closed?: Error; + #failed = false; // Control frames preempt all stream data, FIFO among themselves. Lossless and // uncapped, so whatever the peer can trigger here must be bounded by design: @@ -126,6 +210,12 @@ export class SendScheduler { this.#onFail = options?.onFail ?? (() => {}); // Start the writer loop. It never rejects - failures are captured by #fail. void this.#run(options?.first); + // A write the sink already accepted can fail while the loop is parked. + sink.closed.catch((err) => { + if (this.#closed) return; + this.#fail(err instanceof Error ? err : new Error(String(err))); + this.#signal(); + }); } /** Queue a pre-encoded control frame. Preempts all stream data. */ @@ -168,7 +258,7 @@ export class SendScheduler { this.#sendOrders.set(streamId, order); } - /** Queue a stream-data frame. Resolves once the bytes hit the socket; + /** Queue a stream-data frame. Resolves once the sink accepts the bytes; * rejects if the session closes or the stream is dropped first. */ enqueueStream(streamId: bigint, bytes: Uint8Array): Promise { if (this.#closed) return Promise.reject(this.#closed); @@ -201,8 +291,9 @@ export class SendScheduler { /** Close the scheduler: drop everything still queued and reject pending * stream frames, then send `final` (the session's close frame) next. - * Resolves once the loop has nothing left to write: `final` was handed to - * the sink, or the sink failed. A write already in flight finishes first. */ + * Resolves once the loop has nothing left to write: every accepted write, + * `final` included, reached the socket, or the sink failed. A write already + * in flight finishes first. */ close(err: Error, final?: Uint8Array): Promise { if (this.#closed) return this.#done.promise; this.#closed = err; @@ -259,7 +350,7 @@ export class SendScheduler { this.#datagrams.length === 0 && this.#ready.size === 0 ) { - if (this.#closed) return; + if (this.#closed) break; await new Promise((resolve) => { this.#wake = resolve; }); @@ -293,7 +384,7 @@ export class SendScheduler { continue; } - if (this.#closed) return; // closed with only stream frames left (already rejected) + if (this.#closed) break; // closed with only stream frames left (already rejected) if (this.#ready.size === 0) continue; const id = this.#pickStream(); @@ -310,6 +401,8 @@ export class SendScheduler { this.#onActivity(); waiter.resolve(); } + // Closed: the close frame counts as sent only once the socket holds it. + await this.#sink.flush(); } catch (err) { this.#fail(err instanceof Error ? err : new Error(String(err))); } finally { @@ -318,6 +411,8 @@ export class SendScheduler { } #fail(err: Error): void { + if (this.#failed) return; + this.#failed = true; this.#closed ??= err; for (const waiter of this.#ready.values()) waiter.reject(err); this.#ready.clear(); diff --git a/js/qmux/src/session.test.ts b/js/qmux/src/session.test.ts index ce48cfa02f7..5d49e553a19 100644 --- a/js/qmux/src/session.test.ts +++ b/js/qmux/src/session.test.ts @@ -264,8 +264,11 @@ function connect(config?: Config): { session: Session; peer: FakePeer } { (globalThis as { WebSocketStream?: unknown }).WebSocketStream = FakePeer; // Idle timer off so a stray interval can't interfere with the test. // Bare version ALPNs are offered by default (requireProtocol defaults to false). + // A one-byte send buffer makes one write stuck in the socket a backlog, so + // frames behind it stay queued in the session. const session = new Session("https://example/test", { config: { idleTimeout: null, ...config }, + sendBufferSize: 1, }); const peer = FakePeer.last as FakePeer; return { session, peer }; @@ -273,6 +276,13 @@ function connect(config?: Config): { session: Session; peer: FakePeer } { const ORIGINAL_WSS = (globalThis as { WebSocketStream?: unknown }).WebSocketStream; +test("a non-finite sendBufferSize throws instead of parking every write", () => { + (globalThis as { WebSocketStream?: unknown }).WebSocketStream = FakePeer; + for (const sendBufferSize of [Number.NaN, Number.POSITIVE_INFINITY]) { + expect(() => new Session("https://example/test", { sendBufferSize })).toThrow(RangeError); + } +}); + describe("Session integration (scripted peer)", () => { afterEach(() => { if (ORIGINAL_WSS === undefined) { diff --git a/js/qmux/src/session.ts b/js/qmux/src/session.ts index 42b5f5aab9d..a732017de73 100644 --- a/js/qmux/src/session.ts +++ b/js/qmux/src/session.ts @@ -17,7 +17,7 @@ import * as Frame from "./frame.ts"; import { DEFAULT_TRANSPORT_PARAMS, usesRecords } from "./frame.ts"; import { RecvStream } from "./recv.ts"; import { RecvOpen } from "./recv-open.ts"; -import { DEFAULT_SEND_ORDER, SendScheduler, type SendSink, WritableStreamSink } from "./scheduler.ts"; +import { BacklogSink, DEFAULT_SEND_ORDER, SendScheduler, type SendSink, WritableStreamSink } from "./scheduler.ts"; import * as Stream from "./stream.ts"; import { VarInt } from "./varint.ts"; import { @@ -433,9 +433,8 @@ export interface SessionOptions extends WebTransportOptions { config?: Config; /** Initial send-buffer high-water mark in bytes, used for write - * backpressure. Only takes effect when falling back to the - * `WebSocketStream` ponyfill (no native `WebSocketStream`); the - * native API sizes its own send buffer. Defaults to 64 KiB. + * backpressure: past it, writes wait and datagrams are dropped. + * Defaults to 64 KiB. * * For best throughput *and* prioritization, set this to roughly the * bandwidth-delay product (RTT × estimated throughput) and adjust it at @@ -443,6 +442,12 @@ export interface SessionOptions extends WebTransportOptions { sendBufferSize?: number; } +// At least one byte, so a lone frame can always be written; non-finite sizes are a caller bug. +function sendBufferSize(bytes: number): number { + if (!Number.isFinite(bytes)) throw new RangeError(`invalid sendBufferSize: ${bytes}`); + return Math.max(1, Math.floor(bytes)); +} + /** Options for accepting a QMux Session on an already-negotiated socket. * * The handshake is done by the time [[Session.accept]] is called, so the @@ -461,7 +466,7 @@ export interface AcceptOptions extends Pick` asks for the oldest group first, so a voice reply's opening arrives first, and audio's head hold shrinks to the floor - [Push-to-talk's press-to-audible latency fits a budget on the defaults](/quest/m1/push-to-talk-latency.md) - press to audible on the browser defaults, excluding device output, stays under a kixtop-measured budget that `just test push-to-talk` enforces - [Socket endpoints split into servers and clients](/quest/m1/socket-roles.md) - servers announce `.server/` and clients `.client/`, so plain grants keep a client from posing as a server or reading another client diff --git a/quest/m1/websocket-stream-datagrams.md b/quest/m1/websocket-stream-datagrams.md deleted file mode 100644 index 75b408af974..00000000000 --- a/quest/m1/websocket-stream-datagrams.md +++ /dev/null @@ -1,45 +0,0 @@ -# [S] Native WebSocketStream keeps datagrams flowing - -## Goal - -On Chromium's native `WebSocketStream`, a QMux session over WebSocket sheds a -datagram only when the socket has a real send backlog, not whenever any write -is in flight. A headless-Chromium test writes datagrams back to back on an idle -socket and proves every one arrives, and qmux is fixed if they don't. - -## Plan - -Planned 2026-11-07 (maintainer, /quest-plan of the follow-ups from the /quest-complete run) as the follow-up of -[moq-dev/moq#8054](https://github.com/moq-dev/moq/pull/8054). Decision prompts: - -- ✅ Plan it, goal as stated -- Plan it, different goal -- Drop it - -Milestone: - -- ✅ m1 -- m0 -- m4 - -8054 found that the `WebSocketStream` ponyfill reported backpressure during -every write, because its writable used the default one-chunk high-water mark. -`WritableStreamSink.wantsMore()` in `js/qmux/src/scheduler.ts` sheds a datagram -as soon as `writer.desiredSize` reaches 0, so two datagrams written back to -back lost one. 8054 fixed only the ponyfill. When the platform has a native -`WebSocketStream` (Chromium), qmux uses it instead, and its writable may report -the same in-flight `desiredSize` of 0. - -- Measure first: drive a QMux session over native `WebSocketStream` in - headless Chromium, as the ponyfill's case in 8054's `test/datagram-tracks` - does over Node, and record whether `desiredSize` drops to 0 while a write is - in flight with nothing buffered. -- If it does, fix the native path in qmux so that only a real socket backlog - sheds datagrams, and keep the test as the regression. If it doesn't, keep the - test and finish. - -Public API: none expected. Wire: none. - -## Related - -- [Datagram tracks](https://github.com/moq-dev/moq/pull/8054) - the PR whose ponyfill fix this extends to the native API diff --git a/test/interop/clients/js/qmux-datagrams.browser.ts b/test/interop/clients/js/qmux-datagrams.browser.ts new file mode 100644 index 00000000000..1ebca6814c7 --- /dev/null +++ b/test/interop/clients/js/qmux-datagrams.browser.ts @@ -0,0 +1,73 @@ +/** A QMux session over Chromium's native `WebSocketStream` sends every datagram on an idle socket. */ +import { expect, test } from "bun:test"; +import { createServer } from "vite"; +import Session, { selectSubprotocol } from "../../../../js/qmux/src/index.ts"; +import { BunSocketStream } from "../../../qmux/bun-socket.ts"; +import { launch } from "./harness"; + +const PROTOCOL = "qmux-datagrams"; +const COUNT = 100; + +test("native WebSocketStream sends every datagram written back to back", async () => { + let accepted = Promise.withResolvers(); + const socket = Bun.serve<{ stream: BunSocketStream }>({ + port: 0, + hostname: "127.0.0.1", + fetch(req, server) { + const protocol = selectSubprotocol(req.headers.get("sec-websocket-protocol"), { + protocols: [PROTOCOL], + versions: { [PROTOCOL]: "qmux-01" }, + requireProtocol: true, + }); + if (!protocol) return new Response("no supported protocol", { status: 400 }); + const stream = new BunSocketStream(protocol); + accepted.resolve(Session.accept(stream, { protocol })); + if (server.upgrade(req, { data: { stream }, headers: { "sec-websocket-protocol": protocol } })) return; + return new Response("upgrade failed", { status: 500 }); + }, + websocket: { + open: (ws) => ws.data.stream.attach(ws), + message: (ws, message) => ws.data.stream.push(message), + close: (ws, code, reason) => ws.data.stream.finish(code, reason), + }, + }); + const server = await createServer({ + configFile: false, + root: `${import.meta.dir}/qmux-datagrams`, + server: { host: "127.0.0.1", port: 0 }, + }); + await server.listen(); + const browser = await launch(); + try { + const page = await browser.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.goto(server.resolvedUrls?.local[0] ?? ""); + + // Each write awaited, as WebTransport callers do, then all at once. + for (const burst of [false, true]) { + accepted = Promise.withResolvers(); + const sent = page.evaluate( + async ([url, protocol, count, burst]) => + (await import(`${location.origin}/main.ts`)).run(url, protocol, count, burst), + [`ws://127.0.0.1:${socket.port}/`, PROTOCOL, COUNT, burst] as const, + ); + const session = await accepted.promise; + const end = await session.incomingUnidirectionalStreams.getReader().read(); + await end.value?.getReader().read(); + await sent; + + // The socket is ordered, so every datagram the browser kept arrived ahead of the + // stream that marks the end, and a clean close leaves them readable. + session.close(); + const received: number[] = []; + for await (const datagram of session.datagrams.readable) received.push(...datagram); + expect({ burst, received }).toEqual({ burst, received: Array.from({ length: COUNT }, (_, i) => i) }); + } + expect(errors).toEqual([]); + } finally { + await browser.close(); + await server.close(); + socket.stop(true); + } +}, 30_000); diff --git a/test/interop/clients/js/qmux-datagrams/index.html b/test/interop/clients/js/qmux-datagrams/index.html new file mode 100644 index 00000000000..4ea2f746444 --- /dev/null +++ b/test/interop/clients/js/qmux-datagrams/index.html @@ -0,0 +1,3 @@ + + +QMux datagrams diff --git a/test/interop/clients/js/qmux-datagrams/main.ts b/test/interop/clients/js/qmux-datagrams/main.ts new file mode 100644 index 00000000000..4aeb963c25f --- /dev/null +++ b/test/interop/clients/js/qmux-datagrams/main.ts @@ -0,0 +1,20 @@ +/** Datagrams from a QMux session over the browser's native `WebSocketStream`. @module */ +import Session from "../../../../../js/qmux/src/index.ts"; + +/** Dial `url`, send `count` one-byte datagrams back to back, then a stream that marks the end. */ +export async function run(url: string, protocol: string, count: number, burst: boolean): Promise { + if (typeof (globalThis as { WebSocketStream?: unknown }).WebSocketStream !== "function") + throw new Error("no native WebSocketStream"); + const session = new Session(url, { + protocols: [protocol], + versions: { [protocol]: "qmux-01" }, + requireProtocol: true, + }); + await session.ready; + const datagrams = session.datagrams.writable.getWriter(); + if (burst) await Promise.all(Array.from({ length: count }, (_, i) => datagrams.write(new Uint8Array([i])))); + else for (let i = 0; i < count; i++) await datagrams.write(new Uint8Array([i])); + const end = (await session.createUnidirectionalStream()).getWriter(); + await end.write(new Uint8Array([0])); + await end.close(); +} diff --git a/test/interop/clients/js/tsconfig.json b/test/interop/clients/js/tsconfig.json index 538a3849867..c6522d99fc3 100644 --- a/test/interop/clients/js/tsconfig.json +++ b/test/interop/clients/js/tsconfig.json @@ -6,6 +6,8 @@ "include": [ "net-cleanups.browser.ts", "net-cleanups", + "qmux-datagrams.browser.ts", + "qmux-datagrams", "close.ts", "encoded-preview.browser.ts", "encoded-preview", diff --git a/test/justfile b/test/justfile index 455b20ce878..e397d0de860 100644 --- a/test/justfile +++ b/test/justfile @@ -24,6 +24,7 @@ harness_browser_tests := ''' ./net-cleanups.browser.ts ./open-gop.browser.ts ./publish-ui.browser.ts + ./qmux-datagrams.browser.ts ./signals-dom.browser.ts ./source-selection.browser.ts ./tone.test.ts diff --git a/test/qmux/bun-socket.ts b/test/qmux/bun-socket.ts new file mode 100644 index 00000000000..48b9e20d2a8 --- /dev/null +++ b/test/qmux/bun-socket.ts @@ -0,0 +1,68 @@ +/** Accept a QMux session from `Bun.serve`. @module */ +import type { WebSocketStreamLike } from "@moq/qmux"; +import type { ServerWebSocket } from "bun"; + +type OpenEvent = Awaited; +type CloseEvent = Awaited; +type CloseInfo = NonNullable[0]>; + +/** Bun's `ServerWebSocket` is handler-based rather than a `WebSocket`, so it + * can't be adopted directly. Presenting it as a `WebSocketStreamLike` is the + * escape hatch every non-standard host has, and what makes `Session.accept` + * usable from `Bun.serve` at all. */ +export class BunSocketStream implements WebSocketStreamLike { + readonly url = ""; + readonly protocol: string; + readonly opened: Promise; + readonly closed: Promise; + #opened = Promise.withResolvers(); + #closed = Promise.withResolvers(); + #incoming!: ReadableStreamDefaultController; + #socket?: ServerWebSocket<{ stream: BunSocketStream }>; + + readonly #readable = new ReadableStream({ + start: (controller) => { + this.#incoming = controller; + }, + }); + + constructor(protocol: string) { + this.protocol = protocol; + this.opened = this.#opened.promise; + this.closed = this.#closed.promise; + this.opened.catch(() => {}); + } + + /** The upgrade completed: hand the session its streams. */ + attach(socket: ServerWebSocket<{ stream: BunSocketStream }>): void { + this.#socket = socket; + const writable = new WritableStream({ + write: (chunk) => { + socket.send(chunk); + }, + close: () => socket.close(), + abort: () => socket.close(), + }); + this.#opened.resolve({ readable: this.#readable, writable, extensions: "", protocol: this.protocol }); + } + + /** A message arrived on the socket. */ + push(data: string | Buffer): void { + // Bun hands back a Buffer; copy to a plain Uint8Array view of just this + // message rather than passing the pooled buffer through. + this.#incoming.enqueue(typeof data === "string" ? data : new Uint8Array(data)); + } + + /** The socket closed. */ + finish(closeCode: number, reason: string): void { + try { + this.#incoming.close(); + } catch {} + this.#opened.reject(new Error("socket closed before opening")); + this.#closed.resolve({ closeCode, reason }); + } + + close(info: CloseInfo = {}): void { + this.#socket?.close(info.closeCode, info.reason); + } +} diff --git a/test/qmux/interop.test.ts b/test/qmux/interop.test.ts index d7b831b7148..4175d7fa61d 100644 --- a/test/qmux/interop.test.ts +++ b/test/qmux/interop.test.ts @@ -2,12 +2,8 @@ // The Rust side is the in-tree qmux crate's interop examples, built by `just test qmux`. import { expect, test } from "bun:test"; import { join } from "node:path"; -import Session, { selectSubprotocol, type Version, type WebSocketStreamLike } from "@moq/qmux"; -import type { ServerWebSocket } from "bun"; - -type OpenEvent = Awaited; -type CloseEvent = Awaited; -type CloseInfo = NonNullable[0]>; +import Session, { selectSubprotocol, type Version } from "@moq/qmux"; +import { BunSocketStream } from "./bun-socket.ts"; const BIN = process.env.QMUX_INTEROP_BIN; if (!BIN) throw new Error("QMUX_INTEROP_BIN is unset; run `just test qmux`"); @@ -178,65 +174,6 @@ test("A TypeScript client interoperates with the Rust WebSocket server across su } }, 120_000); -/** Bun's `ServerWebSocket` is handler-based rather than a `WebSocket`, so it - * can't be adopted directly. Presenting it as a `WebSocketStreamLike` is the - * escape hatch every non-standard host has, and what makes `Session.accept` - * usable from `Bun.serve` at all. */ -class BunSocketStream implements WebSocketStreamLike { - readonly url = ""; - readonly protocol: string; - readonly opened: Promise; - readonly closed: Promise; - #opened = Promise.withResolvers(); - #closed = Promise.withResolvers(); - #incoming!: ReadableStreamDefaultController; - #socket?: ServerWebSocket<{ stream: BunSocketStream }>; - - readonly #readable = new ReadableStream({ - start: (controller) => { - this.#incoming = controller; - }, - }); - - constructor(protocol: string) { - this.protocol = protocol; - this.opened = this.#opened.promise; - this.closed = this.#closed.promise; - this.opened.catch(() => {}); - } - - /** The upgrade completed: hand the session its streams. */ - attach(socket: ServerWebSocket<{ stream: BunSocketStream }>): void { - this.#socket = socket; - const writable = new WritableStream({ - write: (chunk) => { - socket.send(chunk); - }, - close: () => socket.close(), - abort: () => socket.close(), - }); - this.#opened.resolve({ readable: this.#readable, writable, extensions: "", protocol: this.protocol }); - } - - push(data: string | Buffer): void { - // Bun hands back a Buffer; copy to a plain Uint8Array view of just this - // message rather than passing the pooled buffer through. - this.#incoming.enqueue(typeof data === "string" ? data : new Uint8Array(data)); - } - - finish(closeCode: number, reason: string): void { - try { - this.#incoming.close(); - } catch {} - this.#opened.reject(new Error("socket closed before opening")); - this.#closed.resolve({ closeCode, reason }); - } - - close(info: CloseInfo = {}): void { - this.#socket?.close(info.closeCode, info.reason); - } -} - /** The mirror of `runClientVersion`: the TypeScript side accepts and the Rust * client dials, so `Session.accept` owns the server half of the stream-id space. */ async function runServerVersion(version: Version): Promise { diff --git a/test/qmux/tsconfig.json b/test/qmux/tsconfig.json index d29cb3b1fcf..556aa532eba 100644 --- a/test/qmux/tsconfig.json +++ b/test/qmux/tsconfig.json @@ -1,5 +1,5 @@ { "extends": "../../js/tsconfig.json", "compilerOptions": { "types": ["bun"], "noEmit": true }, - "include": ["interop.test.ts"] + "include": ["interop.test.ts", "bun-socket.ts"] }