diff --git a/.nx/version-plans/version-plan-1792067880723.md b/.nx/version-plans/version-plan-1792067880723.md new file mode 100644 index 00000000..bbe7b5e1 --- /dev/null +++ b/.nx/version-plans/version-plan-1792067880723.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +The connection between the Harness CLI and the app now sends screenshot data in flow-controlled chunks on its own channel, with test events and heartbeats sent ahead of it. The CLI and the app runtime must use the same Harness version. diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 5ff347a0..e502c8a1 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -33,6 +33,7 @@ "pngjs": "^7.0.0", "ssim.js": "^3.5.0", "tslib": "^2.3.0", + "web-streams-polyfill": "^4.3.0", "ws": "^8.18.2" }, "devDependencies": { diff --git a/packages/bridge/src/__tests__/client.test.ts b/packages/bridge/src/__tests__/client.test.ts index 2f34eef7..22f5cec7 100644 --- a/packages/bridge/src/__tests__/client.test.ts +++ b/packages/bridge/src/__tests__/client.test.ts @@ -1,85 +1,57 @@ import { describe, expect, it, vi } from 'vitest'; +import { receiveBinary } from '../binary.js'; import { connectToHarness } from '../client.js'; -import type { - BridgeTransport, - BridgeTransportCloseEvent, - BridgeTransportMessage, - BridgeTransportState, -} from '../transport.js'; - -const createMockTransport = (initialState: BridgeTransportState = 'connecting') => { - let state = initialState; - const openListeners = new Set<() => void>(); - const messageListeners = new Set<(message: BridgeTransportMessage) => void>(); - const closeListeners = new Set<(event: BridgeTransportCloseEvent) => void>(); - const errorListeners = new Set<(error: Error) => void>(); - const sent: BridgeTransportMessage[] = []; - - const transport: BridgeTransport = { - get state() { - return state; - }, - send: (message) => { - sent.push(message); - }, - close: (code = 1000, reason = '') => { - state = 'closed'; - for (const listener of closeListeners) { - listener({ code, reason }); - } - }, - onOpen: (listener) => { - openListeners.add(listener); - return () => openListeners.delete(listener); - }, - onMessage: (listener) => { - messageListeners.add(listener); - return () => messageListeners.delete(listener); - }, - onClose: (listener) => { - closeListeners.add(listener); - return () => closeListeners.delete(listener); - }, - onError: (listener) => { - errorListeners.add(listener); - return () => errorListeners.delete(listener); - }, - }; +import { createLink, globalClock, type Logger } from '../link/index.js'; +import { createConnectionPair } from '../link/__tests__/ports.js'; +import type { BridgeMessage } from '../protocol.js'; - return { - transport, - sent, - open: () => { - state = 'open'; - for (const listener of openListeners) { - listener(); - } - }, - receive: (message: BridgeTransportMessage) => { - for (const listener of messageListeners) { - listener(message); - } - }, - fail: (error: Error) => { - for (const listener of errorListeners) { - listener(error); +const silentLogger: Logger = { + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined, + child: () => silentLogger, +}; + +const setup = async () => { + const [appEnd, cliEnd] = createConnectionPair(); + const cli = createLink(cliEnd, { + end: 'gateway', + clock: globalClock, + logger: silentLogger, + }); + const received: BridgeMessage[] = []; + const waiters: Array<() => void> = []; + cli.onData((chunk) => { + received.push(JSON.parse(chunk) as BridgeMessage); + for (const waiter of waiters.splice(0)) { + waiter(); + } + }); + const nextMessage = async (type: BridgeMessage['type']) => { + for (;;) { + const message = received.find((candidate) => candidate.type === type); + if (message) { + received.splice(received.indexOf(message), 1); + return message; } - }, + await new Promise((resolve) => waiters.push(resolve)); + } }; + + const handle = await connectToHarness( + 'ws://unused', + { runTests: vi.fn(), resetEnvironment: vi.fn() }, + { transport: appEnd }, + ); + + return { cli, handle, nextMessage }; }; -describe('connectToHarness transport injection', () => { +describe('connectToHarness', () => { it('uses the injected transport instead of constructing a WebSocket', async () => { - const mockTransport = createMockTransport(); - const handlePromise = connectToHarness( - 'ws://unused', - { runTests: vi.fn(), resetEnvironment: vi.fn() }, - { transport: mockTransport.transport }, - ); - - mockTransport.open(); + const { handle, nextMessage } = await setup(); - const handle = await handlePromise; handle.reportReady({ platform: 'ios', manufacturer: 'Apple', @@ -87,13 +59,74 @@ describe('connectToHarness transport injection', () => { osVersion: '18.0', }); - expect(mockTransport.sent).toHaveLength(1); - expect(mockTransport.sent[0]).toBeTypeOf('string'); - expect(JSON.parse(mockTransport.sent[0] as string)).toMatchObject({ + expect(await nextMessage('ready')).toMatchObject({ type: 'ready', device: { model: 'Injected' }, }); handle.disconnect(); }); + + it('answers pings with pongs', async () => { + const { cli, handle, nextMessage } = await setup(); + + await cli.write(JSON.stringify({ type: 'ping', id: 7 })); + + expect(await nextMessage('pong')).toEqual({ type: 'pong', id: 7 }); + + handle.disconnect(); + }); + + it('sends screenshot bytes over a link channel, not inside the RPC message', async () => { + const { cli, handle, nextMessage } = await setup(); + const data = new Uint8Array(3 * 1024 * 1024).map((_, index) => index % 251); + + const transfer = handle.transferScreenshot(data, { width: 10, height: 20 }); + const invoke = await nextMessage('invoke'); + + if (invoke.type !== 'invoke') { + throw new Error('expected an invoke'); + } + + const [reference, metadata] = invoke.args as [ + { channelId: string; size: number }, + unknown, + ]; + expect(invoke.method).toBe('device.screenshot.receive'); + expect(metadata).toEqual({ width: 10, height: 20 }); + expect(reference.size).toBe(data.length); + + const bytes = await receiveBinary(cli, reference.channelId); + expect(Buffer.from(bytes).equals(Buffer.from(data))).toBe(true); + + await cli.write( + JSON.stringify({ + type: 'return', + id: invoke.id, + ok: true, + value: { path: '/tmp/shot.png' }, + }), + ); + + await expect(transfer).resolves.toEqual({ path: '/tmp/shot.png' }); + + handle.disconnect(); + }); + + it('rejects pending calls once the link closes', async () => { + const { cli, handle, nextMessage } = await setup(); + + const call = handle.matchImageSnapshot( + { path: '/tmp/shot.png' }, + '/tests/a.test.ts', + { name: 'a' }, + 'ios', + ); + await nextMessage('invoke'); + await cli.close(); + + await expect(call).rejects.toThrow('Harness connection closed'); + + handle.disconnect(); + }); }); diff --git a/packages/bridge/src/__tests__/rpc-peer.test.ts b/packages/bridge/src/__tests__/rpc-peer.test.ts index 544fac0d..0040880a 100644 --- a/packages/bridge/src/__tests__/rpc-peer.test.ts +++ b/packages/bridge/src/__tests__/rpc-peer.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createRpcPeer } from '../rpc-peer.js'; import type { BridgeEvents } from '../shared.js'; -import type { RpcTransport } from '../transport.js'; +import type { RpcTransport } from '../rpc-peer.js'; type LocalMethods = { add: (left: number, right: number) => Promise; @@ -12,7 +12,6 @@ const createMockTransport = (): RpcTransport & { messages: string[] } => { const messages: string[] = []; return { - state: 'open', messages, send: (message) => { messages.push(message); @@ -27,7 +26,6 @@ describe('rpc-peer', () => { const client = createRpcPeer, { add: LocalMethods['add'] }, BridgeEvents>({ localMethods: {}, transport: { - state: 'open', send: (message) => { if (!serverHandleMessage) { throw new Error('Server peer not initialized'); @@ -46,7 +44,6 @@ describe('rpc-peer', () => { }, }, transport: { - state: 'open', send: (message) => { void client.handleMessage(message); }, @@ -77,7 +74,6 @@ describe('rpc-peer', () => { }, }, transport: { - state: 'open', send: (message) => { void client.handleMessage(message); }, @@ -96,7 +92,6 @@ describe('rpc-peer', () => { const peer = createRpcPeer, Record, BridgeEvents>({ localMethods: {}, transport: { - state: 'open', send: (message) => { response = message; }, diff --git a/packages/bridge/src/binary-transfer.ts b/packages/bridge/src/binary-transfer.ts deleted file mode 100644 index aa5076bb..00000000 --- a/packages/bridge/src/binary-transfer.ts +++ /dev/null @@ -1,79 +0,0 @@ -export const HEADER_SIZE = 8; - -export function createBinaryFrame( - transferId: number, - data: Uint8Array -): Uint8Array { - const frame = new Uint8Array(HEADER_SIZE + data.length); - const view = new DataView(frame.buffer); - - // Transfer ID (4 bytes, Big Endian) - view.setUint32(0, transferId, false); - - // Reserved (4 bytes) - set to 0 - view.setUint32(4, 0, false); - - // Copy data - frame.set(data, HEADER_SIZE); - - return frame; -} - -export function parseBinaryFrame(frame: Uint8Array): { - transferId: number; - data: Uint8Array; -} { - const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength); - const transferId = view.getUint32(0, false); - const data = frame.subarray(HEADER_SIZE); - - return { transferId, data }; -} - -export class BinaryStore { - private store = new Map(); - private timeouts = new Map>(); - // 5 minutes timeout for binary data - private readonly TIMEOUT_MS = 5 * 60 * 1000; - - add(transferId: number, data: Uint8Array): void { - this.store.set(transferId, data); - const timeout = setTimeout(() => { - this.store.delete(transferId); - this.timeouts.delete(transferId); - }, this.TIMEOUT_MS); - this.timeouts.set(transferId, timeout); - } - - get(transferId: number): Uint8Array | undefined { - return this.store.get(transferId); - } - - delete(transferId: number): boolean { - const timeout = this.timeouts.get(transferId); - if (timeout) { - clearTimeout(timeout); - this.timeouts.delete(transferId); - } - return this.store.delete(transferId); - } - - dispose(): void { - for (const timeout of this.timeouts.values()) { - clearTimeout(timeout); - } - this.timeouts.clear(); - this.store.clear(); - } -} - -let nextTransferId = 1; -export function generateTransferId(): number { - // Use a rolling counter, but ensure it doesn't overflow 32-bit integer just in case - // though JS numbers are doubles, we are writing to Uint32. - const id = nextTransferId++; - if (nextTransferId > 0xffffffff) { - nextTransferId = 1; - } - return id; -} diff --git a/packages/bridge/src/binary.ts b/packages/bridge/src/binary.ts new file mode 100644 index 00000000..2e3993f3 --- /dev/null +++ b/packages/bridge/src/binary.ts @@ -0,0 +1,74 @@ +import type { LinkChannel, LinkConnection } from './link/index.js'; + +export type OutgoingBinary = { + channelId: string; + /** Settles once every byte is handed to the link, or rejects if the channel fails. */ + written: Promise; + abort: (reason?: string) => void; +}; + +/** + * Opens a link channel and starts writing `data` into it. The channel id travels in an + * RPC message; the peer reads the bytes with `receiveBinary`. The link sends the channel's + * OPEN before any later message, so the peer always knows the id by the time it asks. + */ +export const sendBinary = async ( + link: LinkConnection, + data: Uint8Array, +): Promise => { + const channel = await link.openChannel({ + signal: new AbortController().signal, + }); + + const written = (async () => { + const writer = channel.writable.getWriter(); + await writer.write(data); + await writer.close(); + })(); + + return { + channelId: channel.id, + written, + abort: (reason) => channel.abort(reason), + }; +}; + +const readAll = async (channel: LinkChannel): Promise => { + const reader = channel.readable.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + + for (;;) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + chunks.push(value); + size += value.length; + } + + const bytes = new Uint8Array(size); + let offset = 0; + + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + + return bytes; +}; + +/** Accepts the channel the peer opened with `sendBinary` and reads it to the end. */ +export const receiveBinary = async ( + link: LinkConnection, + channelId: string, +): Promise => { + const channel = link.acceptChannel(channelId); + + // Nothing flows back to the sender; ending this direction lets the channel free its slot. + void channel.writable.close().catch(() => undefined); + + return readAll(channel); +}; diff --git a/packages/bridge/src/client.ts b/packages/bridge/src/client.ts index 02d019a4..09092161 100644 --- a/packages/bridge/src/client.ts +++ b/packages/bridge/src/client.ts @@ -1,11 +1,15 @@ -import { createBinaryFrame, generateTransferId } from './binary-transfer.js'; +import { sendBinary } from './binary.js'; +import { + connectStandardWebSocket, + createLink, + globalClock, + type LinkConnection, + type Logger, + type StandardWebSocket, + type UplinkTransportConnection, +} from './link/index.js'; import { serializeBridgeMessage } from './protocol.js'; import { createRpcPeer } from './rpc-peer.js'; -import { - createRpcTransport, - type BridgeTransport, -} from './transport.js'; -import { createWebSocketClientTransport } from './websocket-client-transport.js'; import type { BridgeClientFunctions, BridgeServerFunctions, @@ -39,184 +43,182 @@ export type HarnessHandle = { }; export type ConnectToHarnessOptions = { - transport?: BridgeTransport; + /** An already open link transport, used instead of dialing `url`. */ + transport?: UplinkTransportConnection; + /** Creates the WebSocket to dial `url` with. Defaults to the global `WebSocket`. */ + createSocket?: (url: string) => StandardWebSocket; }; -export { createWebSocketClientTransport }; +export type { UplinkTransportConnection }; const noop = (): void => undefined; -export const connectToHarness = ( +const createConsoleLogger = (): Logger => { + const logger: Logger = { + debug: noop, + info: noop, + warn: (message) => console.warn(`[harness] ${message}`), + error: (message) => console.error(`[harness] ${message}`), + child: () => logger, + }; + + return logger; +}; + +// React Native has no `crypto.randomUUID`, so channel ids come from a counter. Ids only +// need to be unique among this connection's channels. +const createChannelIdGenerator = (): (() => string) => { + let nextId = 1; + return () => `app-${nextId++}`; +}; + +const dial = async ( + url: string, + options: ConnectToHarnessOptions, +): Promise => { + if (options.transport) { + return options.transport; + } + + try { + return await connectStandardWebSocket({ + url, + createSocket: options.createSocket, + }); + } catch (error) { + const detail = + error instanceof Error && error.message ? `: ${error.message}` : ''; + throw new Error(`Failed to connect to Harness at ${url}${detail}`); + } +}; + +export const connectToHarness = async ( url: string, callbacks: HarnessCallbacks, options: ConnectToHarnessOptions = {}, -): Promise => - new Promise((resolve, reject) => { - const transport = options.transport ?? createWebSocketClientTransport(url); - let settled = false; - let peerClosed = false; - let offOpen: () => void = noop; - let offError: () => void = noop; - let offClose: () => void = noop; - - const cleanup = () => { - offOpen(); - offError(); - offClose(); - }; - - const fail = (message: string) => { - if (settled) { - return; - } +): Promise => { + const transport = await dial(url, options); + const link: LinkConnection = createLink(transport, { + end: 'worker', + clock: globalClock, + logger: createConsoleLogger(), + generateId: createChannelIdGenerator(), + }); + + const rpc = createRpcPeer< + BridgeClientFunctions, + BridgeServerFunctions, + BridgeEvents + >({ + localMethods: callbacks, + transport: { + send: (message) => { + void link.write(message); + }, + }, + }); + + let peerClosed = false; + let offMessage: () => void = noop; + let offClose: () => void = noop; + let offError: () => void = noop; - settled = true; - cleanup(); - reject(new Error(message)); - }; + const getLinkClosedError = () => { + return new Error('Harness bridge link is closed'); + }; - const handleOpen = () => { - if (settled) { + const closePeer = (reason: Error) => { + if (peerClosed) { + return; + } + + peerClosed = true; + offMessage(); + offClose(); + offError(); + rpc.close(reason); + }; + + const handleMessage = async (data: string) => { + const controlMessage = await rpc.handleMessage(data); + + if (!controlMessage) { + return; + } + + if (controlMessage.type === 'ping') { + void link.write( + serializeBridgeMessage({ type: 'pong', id: controlMessage.id }), + ); + } + }; + + offMessage = link.onData((message) => { + void handleMessage(message).catch((error) => { + closePeer( + error instanceof Error + ? error + : new Error('Received invalid Harness bridge message'), + ); + void link.close(); + }); + }); + + offError = link.onError((error) => { + closePeer(error); + }); + + offClose = link.onClose(() => { + closePeer(new Error('Harness connection closed')); + }); + + return { + reportReady: (device) => { + if (link.closed) { + closePeer(getLinkClosedError()); return; } - settled = true; - cleanup(); - - const getTransportNotOpenError = () => { - return new Error('Harness bridge transport is not open'); - }; - - const rpc = createRpcPeer< - BridgeClientFunctions, - BridgeServerFunctions, - BridgeEvents - >({ - localMethods: callbacks, - transport: createRpcTransport(transport), - }); - - let offMessage: () => void = noop; - let offRuntimeClose: () => void = noop; - let offRuntimeError: () => void = noop; - - const closePeer = (reason: Error) => { - if (peerClosed) { - return; - } - - peerClosed = true; - offMessage(); - offRuntimeClose(); - offRuntimeError(); - rpc.close(reason); - }; - - const handleMessage = async (data: string) => { - const controlMessage = await rpc.handleMessage(data); - - if (!controlMessage) { - return; - } - - if (controlMessage.type === 'ping') { - transport.send( - serializeBridgeMessage({ type: 'pong', id: controlMessage.id }), - ); - } - }; - - offMessage = transport.onMessage((message) => { - if (typeof message !== 'string') { - return; - } - - void handleMessage(message).catch((error) => { - closePeer( - error instanceof Error - ? error - : new Error('Received invalid Harness bridge message'), - ); - - if (transport.state === 'open') { - transport.close(1002, 'Invalid message'); - } - }); - }); - - offRuntimeClose = transport.onClose((event) => { - closePeer( - new Error( - `Harness connection closed (code ${event.code}${ - event.reason ? `, reason: ${event.reason}` : '' - })`, - ), - ); - }); - - offRuntimeError = transport.onError((error) => { - closePeer(error); - }); - - resolve({ - reportReady: (device) => { - try { - if (transport.state !== 'open') { - throw getTransportNotOpenError(); - } - - transport.send(serializeBridgeMessage({ type: 'ready', device })); - } catch (error) { - closePeer( - error instanceof Error ? error : getTransportNotOpenError(), - ); - } - }, - emitEvent: (event) => { - rpc.sendEvent(event); - }, - transferScreenshot: async (data, metadata) => { - const transferId = generateTransferId(); - transport.send(createBinaryFrame(transferId, data)); - return rpc.invoke( + void link.write(serializeBridgeMessage({ type: 'ready', device })); + }, + emitEvent: (event) => { + rpc.sendEvent(event); + }, + transferScreenshot: async (data, metadata) => { + const binary = await sendBinary(link, data); + + try { + const [file] = await Promise.all([ + rpc.invoke( 'device.screenshot.receive', - { type: 'binary', transferId, size: data.length, mimeType: 'image/png' }, + { + type: 'binary', + channelId: binary.channelId, + size: data.length, + mimeType: 'image/png', + }, metadata, - ); - }, - matchImageSnapshot: (screenshot, testPath, options, runner) => - rpc.invoke( - 'test.matchImageSnapshot', - screenshot, - testPath, - options, - runner, ), - disconnect: () => { - closePeer(new Error('Harness connection closed by client')); - transport.close(); - }, - }); - }; - - const handleError = (error: Error) => { - const detail = error.message ? `: ${error.message}` : ''; - fail(`Failed to connect to Harness at ${url}${detail}`); - }; - - const handleClose = (event: { code: number; reason: string }) => { - fail( - `Harness connection at ${url} closed before becoming ready (code ${event.code}${ - event.reason ? `, reason: ${event.reason}` : '' - })`, - ); - }; + binary.written, + ]); - offOpen = transport.onOpen(handleOpen); - offError = transport.onError(handleError); - offClose = transport.onClose(handleClose); - - if (transport.state === 'open') { - handleOpen(); - } - }); + return file; + } catch (error) { + binary.abort(); + throw error; + } + }, + matchImageSnapshot: (screenshot, testPath, options, runner) => + rpc.invoke( + 'test.matchImageSnapshot', + screenshot, + testPath, + options, + runner, + ), + disconnect: () => { + closePeer(new Error('Harness connection closed by client')); + void link.close(); + }, + }; +}; diff --git a/packages/bridge/src/index.ts b/packages/bridge/src/index.ts index 2c2ba41b..8180878f 100644 --- a/packages/bridge/src/index.ts +++ b/packages/bridge/src/index.ts @@ -1,3 +1 @@ export * from './shared.js'; -export * from './binary-transfer.js'; -export * from './transport.js'; diff --git a/packages/bridge/src/link/README.md b/packages/bridge/src/link/README.md new file mode 100644 index 00000000..e8fdf9f5 --- /dev/null +++ b/packages/bridge/src/link/README.md @@ -0,0 +1,43 @@ +# Link + +A copy of the link from [simlock](https://github.com/callstackincubator/simlock) +(`src/link/`, commit `b730ef37536b51ebdb37d076e7592954c32d0904`), plus the +standard `WebSocket` transport from simlock's browser link check. + +The link wraps one WebSocket and carries two kinds of traffic: + +- **Text messages** (`write` / `onData`): the bridge's JSON RPC, events, and + heartbeat ping/pong. They go in the priority lane, so they are never stuck + behind a large transfer. +- **Byte channels** (`openChannel` / `acceptChannel`): raw bytes as + `ReadableStream` / `WritableStream`, in 64 KiB frames with per-channel credit. + The bridge uses them for screenshots (`../binary.ts`). + +See simlock's `src/link/AGENTS.md` for the wire format, limits and rules. + +## Harness changes + +Keep the files close to upstream so they are easy to sync. Changes are marked +with `harness:` comments: + +- `ports.ts` holds the port types the link imports from simlock's `src/ports`, + plus `globalClock`. +- `streams.ts` uses the global WHATWG streams when present and the + `web-streams-polyfill` ponyfill otherwise (React Native). It never installs + globals. +- `envelope.ts` encodes channel ids as ASCII instead of using + `TextEncoder`/`TextDecoder`. +- `standard-websocket.ts` treats a missing `bufferedAmount` as `0`, since React + Native's `WebSocket` never sets it. +- `__tests__/` holds upstream's link tests. The in-memory pair and `FakeClock` + come from simlock's ports, and the WebSocket pair uses `ws` with + `StandardWebSocketConnection` on both ends. + +## React Native notes + +- React Native's `WebSocket` base64-encodes binary frames on the way to the + native module. The protocol is binary, but stock React Native still pays for + that encoding inside the socket. +- With no `bufferedAmount`, the link cannot see the socket's send buffer, so + only per-channel credit (4 MiB) limits how much is in flight. +- There is no `crypto.randomUUID`, so the client passes `generateId`. diff --git a/packages/bridge/src/link/__tests__/link-channels.test.ts b/packages/bridge/src/link/__tests__/link-channels.test.ts new file mode 100644 index 00000000..d4854c9e --- /dev/null +++ b/packages/bridge/src/link/__tests__/link-channels.test.ts @@ -0,0 +1,786 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { createLink, type LinkChannel } from "../index.js"; +import { + drain, + endChannel, + errorOfRead, + errorOfWritable, + KiB, + linkPair, + MiB, + outcome, + pattern, + patternHash, + pump, + readAll, + RecordingLogger, + settle, + signal, + TappedTransport, + TYPE, + writeAndClose, + writeUntilBlocked, + type LinkPair, +} from "./testing.js"; +import { startWebSocketPair } from "./test-websocket.js"; +import { FakeClock } from "./ports.js"; + +async function openPair(pair: LinkPair): Promise<{ g: LinkChannel; w: LinkChannel }> { + const g = await pair.gateway.openChannel({ signal: signal() }); + await settle(); + return { g, w: pair.worker.acceptChannel(g.id) }; +} + +async function readAtLeast(reader: ReadableStreamDefaultReader, bytes: number) { + let total = 0; + while (total < bytes) { + const { done, value } = await reader.read(); + if (done) break; + total += value.length; + } + return total; +} + +async function fillWindow(writable: WritableStream) { + const writer = writable.getWriter(); + for (let i = 0; i < 64; i += 1) await writer.write(pattern(i * 64 * KiB, 64 * KiB)); + return writer; +} + +describe("link byte channels: transfer", () => { + const cleanups: Array<() => Promise> = []; + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); + }); + + async function realLinks() { + const sockets = await startWebSocketPair(); + cleanups.push(() => sockets.close()); + const clock = new FakeClock(0); + const worker = createLink(sockets.worker, { + clock, + end: "worker", + logger: new RecordingLogger(), + }); + const gateway = createLink(sockets.gateway, { + clock, + end: "gateway", + logger: new RecordingLogger(), + }); + return { gateway, worker }; + } + + it("20 MiB written to a channel arrives byte for byte at the accepted channel, each way at once, over the real WebSocket", async () => { + const { gateway, worker } = await realLinks(); + const g = await gateway.openChannel({ signal: signal() }); + await settle(); + const w = worker.acceptChannel(g.id); + const total = 20 * MiB; + + const [, atWorker, , atGateway] = await Promise.all([ + pump(g.writable, total), + drain(w.readable), + pump(w.writable, total), + drain(g.readable), + ]); + + const expected = { hash: patternHash(total), length: total }; + expect(atWorker).toEqual(expected); + expect(atGateway).toEqual(expected); + }, 60_000); + + it("over the real WebSocket, a 20 MiB channel transfer puts at most 20.2 MiB on the socket, in binary messages", async () => { + const sockets = await startWebSocketPair(); + cleanups.push(() => sockets.close()); + const tapped = new TappedTransport(sockets.gateway); + const clock = new FakeClock(0); + const worker = createLink(sockets.worker, { + clock, + end: "worker", + logger: new RecordingLogger(), + }); + const gateway = createLink(tapped, { clock, end: "gateway", logger: new RecordingLogger() }); + const g = await gateway.openChannel({ signal: signal() }); + await settle(); + const w = worker.acceptChannel(g.id); + + await Promise.all([pump(g.writable, 20 * MiB), drain(w.readable)]); + + const onSocket = tapped.sent.reduce( + (sum, entry) => sum + (entry.kind === "binary" ? entry.size : Number.NaN), + 0, + ); + expect(onSocket).toBeGreaterThanOrEqual(20 * MiB); + expect(onSocket).toBeLessThanOrEqual(20.2 * MiB); + }, 60_000); + + it("a text message written right after openChannel resolves never reaches the peer before the channel's OPEN, even with the transport held", async () => { + const pair = linkPair(); + pair.gatewayEnd.hold(); + const failures: unknown[] = []; + const seen: string[] = []; + // eslint-disable-next-line prefer-const -- read by the onData listener before it is assigned + let opened: LinkChannel | undefined; + pair.worker.onData((chunk) => { + try { + pair.worker.acceptChannel(opened?.id ?? "unopened"); + seen.push(chunk); + } catch (error: unknown) { + failures.push(error); + } + }); + opened = await pair.gateway.openChannel({ signal: signal() }); + + const write = pair.gateway.write("after-open\n"); + pair.gatewayEnd.release(); + await write; + await settle(); + + expect(failures).toEqual([]); + expect(seen).toEqual(["after-open\n"]); + const order = pair.gatewayTap.sent.map((entry) => + entry.kind === "text" ? "text" : String(entry.envelope.type), + ); + expect(order.indexOf(String(TYPE.OPEN))).toBeLessThan(order.indexOf("text")); + }); + + it("bytes written right after openChannel, before the peer accepts, arrive once it accepts", async () => { + const pair = linkPair(); + const g = await pair.gateway.openChannel({ signal: signal() }); + await writeAndClose(g, pattern(0, 100 * KiB)); + await settle(); + + const w = pair.worker.acceptChannel(g.id); + + await expect(readAll(w.readable)).resolves.toEqual(pattern(0, 100 * KiB)); + }); + + it("with credit left but the bulk lane held, the writable takes the chunks that fit in credit and then at most 64 KiB more before its writer waits", async () => { + const pair = linkPair(); + const { g } = await openPair(pair); + pair.gatewayEnd.hold(); + const credit = g.credit; + expect(credit).toBe(4 * MiB); + + const accepted = await writeUntilBlocked(g.writable.getWriter()); + + expect(accepted).toBeGreaterThanOrEqual(credit); + expect(accepted).toBeLessThanOrEqual(credit + 64 * KiB); + }); + + it("a writer whose peer never accepts stops after 4 MiB; 30 s after the open its streams error with not-accepted, and the peer's acceptChannel for that id then throws", async () => { + const pair = linkPair(); + const g = await pair.gateway.openChannel({ signal: signal() }); + await settle(); + const writer = g.writable.getWriter(); + + const accepted = await writeUntilBlocked(writer); + expect(accepted).toBeGreaterThanOrEqual(4 * MiB); + expect(accepted).toBeLessThanOrEqual(4 * MiB + 64 * KiB); + pair.clock.advance(30_000); + await settle(); + + await expect(writer.closed).rejects.toMatchObject({ reason: "not-accepted" }); + await expect(errorOfRead(g.readable)).resolves.toMatchObject({ reason: "not-accepted" }); + expect(() => pair.worker.acceptChannel(g.id)).toThrow(); + }); + + it("a reader that stops reading holds its writer after 4 MiB unread, and reading again lets it go on", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + const writer = g.writable.getWriter(); + + const first = await writeUntilBlocked(writer); + expect(first).toBeGreaterThanOrEqual(4 * MiB); + expect(first).toBeLessThanOrEqual(4 * MiB + 64 * KiB); + expect(await readAtLeast(w.readable.getReader(), MiB)).toBeGreaterThanOrEqual(MiB); + await settle(); + const more = await writeUntilBlocked(writer); + + expect(more).toBeGreaterThan(0); + }); + + it("a single 20 MiB write goes through in parts as credit arrives, and resolves once all of it is taken", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + const writer = g.writable.getWriter(); + let resolved = false; + + const write = writer.write(pattern(0, 20 * MiB)).then(() => { + resolved = true; + }); + await settle(); + expect(resolved).toBe(false); + const received = drain(w.readable); + expect((await outcome(write)).state).toBe("resolved"); + await writer.close(); + + const arrived = await outcome(received); + expect(arrived).toEqual({ + state: "resolved", + value: { hash: patternHash(20 * MiB), length: 20 * MiB }, + }); + }); + + it("one channel's stalled reader does not slow another channel on the same link", async () => { + const pair = linkPair(); + const stalled = await openPair(pair); + const live = await openPair(pair); + await writeUntilBlocked(stalled.g.writable.getWriter()); + + const outcome = await Promise.race([ + Promise.all([pump(live.g.writable, 2 * MiB), drain(live.w.readable)]).then( + ([, received]) => received, + ), + new Promise((resolve) => + setTimeout(() => resolve("stuck behind the stalled channel"), 10_000), + ), + ]); + + expect(outcome).toEqual({ hash: patternHash(2 * MiB), length: 2 * MiB }); + }); +}); + +describe("link byte channels: credit and stalls", () => { + it("a write that waits 60 s with no credit arriving errors both sides' streams with stalled", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + const writer = g.writable.getWriter(); + await writeUntilBlocked(writer); + + pair.clock.advance(60_000); + await settle(); + + const stalled = { state: "resolved", value: { reason: "stalled" } }; + expect(await outcome(writer.closed)).toMatchObject({ + state: "rejected", + error: { reason: "stalled" }, + }); + expect(await outcome(errorOfRead(g.readable))).toMatchObject(stalled); + expect(await outcome(errorOfRead(w.readable))).toMatchObject(stalled); + expect(await outcome(errorOfWritable(w.writable))).toMatchObject(stalled); + }); + + it("a reader that reads 64 KiB every 50 s keeps a 10 MiB transfer going without a reset", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + const total = 10 * MiB; + const sending = pump(g.writable, total); + const reader = w.readable.getReader(); + let received = 0; + + while (received < total) { + const { done, value } = await reader.read(); + if (done) break; + received += value.length; + pair.clock.advance(50_000); + await settle(); + } + await sending; + + expect(received).toBe(total); + expect(pair.gatewayLog.problems()).toEqual([]); + expect(pair.workerLog.problems()).toEqual([]); + }, 60_000); + + it("credit counts down as the writer writes and up as the peer reads", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + expect(g.credit).toBe(4 * MiB); + const writer = g.writable.getWriter(); + + for (let i = 0; i < 16; i += 1) await writer.write(pattern(i * 64 * KiB, 64 * KiB)); + expect(g.credit).toBe(3 * MiB); + await readAtLeast(w.readable.getReader(), MiB); + await settle(); + + expect(g.credit).toBeGreaterThan(3 * MiB); + expect(g.credit).toBeLessThanOrEqual(4 * MiB); + }); + + it("whenCredit() resolves when credit arrives", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + await fillWindow(g.writable); + expect(g.credit).toBe(0); + let resolved = false; + const waiting = g.whenCredit().then(() => { + resolved = true; + }); + await settle(); + expect(resolved).toBe(false); + + await readAtLeast(w.readable.getReader(), 64 * KiB); + await settle(); + + expect(resolved).toBe(true); + await waiting; + expect(g.credit).toBeGreaterThan(0); + }); + + it("whenCredit() rejects when the channel fails", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + await fillWindow(g.writable); + const waiting = outcome(g.whenCredit()); + + w.abort(); + + expect(await waiting).toMatchObject({ state: "rejected", error: { reason: "aborted" } }); + }); + + it("whenCredit() rejects when the peer stops that direction", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + await fillWindow(g.writable); + const waiting = outcome(g.whenCredit()); + + await w.readable.cancel(); + + expect(await waiting).toMatchObject({ state: "rejected", error: { reason: "stopped" } }); + }); + + it("whenCredit() rejects when this side closes its own writable", async () => { + const pair = linkPair(); + const { g } = await openPair(pair); + const writer = await fillWindow(g.writable); + const waiting = outcome(g.whenCredit()); + + await writer.close(); + + const ended = await waiting; + expect(ended.state).toBe("rejected"); + expect((ended as { error: unknown }).error).toBeInstanceOf(Error); + }); + + it("a whenCredit() pending 60 s, with the peer holding every byte and granting nothing, errors the channel stalled", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + await fillWindow(g.writable); + const waiting = outcome(g.whenCredit()); + + pair.clock.advance(60_000); + + expect(await waiting).toMatchObject({ state: "rejected", error: { reason: "stalled" } }); + expect(await outcome(errorOfRead(w.readable))).toMatchObject({ + state: "resolved", + value: { reason: "stalled" }, + }); + }); + + it("with the transport held for 90 s and a write waiting behind it, the channel is not reset", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + const writer = g.writable.getWriter(); + pair.gatewayEnd.hold(); + const write = writer.write(pattern(0, 8 * MiB)); + await settle(); + + pair.clock.advance(90_000); + await settle(); + pair.gatewayEnd.release(); + const received = drain(w.readable); + expect((await outcome(write)).state).toBe("resolved"); + await writer.close(); + + expect(await outcome(received)).toEqual({ + state: "resolved", + value: { hash: patternHash(8 * MiB), length: 8 * MiB }, + }); + expect(pair.gatewayLog.problems()).toEqual([]); + }); + + it("a writer that writes no more than credit, against a reader that reads nothing for 10 minutes, is never reset", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + const writer = await fillWindow(g.writable); + + pair.clock.advance(600_000); + await settle(); + await writer.close(); + + await expect(drain(w.readable)).resolves.toEqual({ + hash: patternHash(4 * MiB), + length: 4 * MiB, + }); + }); + + it("a channel whose peer sends nothing for 10 minutes, with no write waiting, stays open", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + + pair.clock.advance(600_000); + await settle(); + await writeAndClose(g, pattern(0, 10)); + + await expect(readAll(w.readable)).resolves.toEqual(pattern(0, 10)); + }); +}); + +describe("link byte channels: ending", () => { + it("closing a writable ends the peer's readable", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + + await writeAndClose(g, pattern(0, 1000)); + + await expect(readAll(w.readable)).resolves.toEqual(pattern(0, 1000)); + }); + + it("after one side closes its writable, the other side's bytes still travel back to the side that closed", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + + await g.writable.getWriter().close(); + await settle(); + await writeAndClose(w, pattern(0, 700)); + + expect(await outcome(readAll(g.readable))).toEqual({ + state: "resolved", + value: pattern(0, 700), + }); + }); + + it("cancelling a readable errors the peer's writable with stopped, and the other direction still carries bytes to its end", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + const writer = g.writable.getWriter(); + + await w.readable.cancel(); + await settle(); + + await expect(writer.closed).rejects.toMatchObject({ reason: "stopped" }); + await writeAndClose(w, pattern(0, 500)); + await expect(readAll(g.readable)).resolves.toEqual(pattern(0, 500)); + }); + + it("DATA still in flight when a readable is cancelled is dropped without a reset, and the peer's acknowledging END arrives", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + const writer = g.writable.getWriter(); + pair.gatewayEnd.hold(); + await writer.write(pattern(0, 10 * KiB)); + await settle(); + const before = pair.gatewayTap.binaries(TYPE.DATA).length; + expect(before).toBe(1); + + await w.readable.cancel(); + await settle(); + pair.gatewayEnd.release(); + await settle(); + + expect(pair.workerTap.binaries(TYPE.RESET)).toEqual([]); + expect(pair.gatewayTap.binaries(TYPE.RESET)).toEqual([]); + expect(pair.workerLog.problems()).toEqual([]); + const types = pair.gatewayTap.binaries().map((entry) => entry.type); + expect(types.indexOf(TYPE.END)).toBeGreaterThan(types.indexOf(TYPE.DATA)); + }); + + it("a STOP and an END for the same direction crossing on the wire end that direction on both sides with no reset and no second END", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + pair.gatewayEnd.hold(); + pair.workerEnd.hold(); + const gWriter = g.writable.getWriter(); + + const closing = gWriter.close(); + void w.readable.cancel(); + await settle(); + pair.gatewayEnd.release(); + pair.workerEnd.release(); + await closing; + await settle(); + await writeAndClose(w, pattern(0, 10)); + await readAll(g.readable); + await settle(); + + expect(pair.gatewayTap.binaries(TYPE.END)).toHaveLength(1); + expect(pair.gatewayTap.binaries(TYPE.RESET)).toEqual([]); + expect(pair.workerTap.binaries(TYPE.RESET)).toEqual([]); + expect(pair.gatewayLog.problems()).toEqual([]); + expect(pair.workerLog.problems()).toEqual([]); + }); + + it("cancelling a readable after its END arrived sends nothing", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + await writeAndClose(g, pattern(0, 10)); + await settle(); + + await w.readable.cancel(); + await settle(); + + expect(pair.workerTap.binaries(TYPE.STOP)).toEqual([]); + expect(pair.gatewayLog.problems()).toEqual([]); + }); + + it("grants credit once per 64 KiB read, or when the reader has read everything it holds", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + const writer = g.writable.getWriter(); + for (let i = 0; i < 100; i += 1) await writer.write(pattern(i * KiB, KiB)); + await settle(); + + const reader = w.readable.getReader(); + let read = 0; + while (read < 100 * KiB) read += (await reader.read()).value?.length ?? 0; + await settle(); + + const grants = pair.workerTap + .binaries(TYPE.CREDIT) + .map((e) => new DataView(e.payload.buffer, e.payload.byteOffset).getUint32(0)); + expect(grants).toEqual([64 * KiB, 36 * KiB]); + }); + + it("abort(\"why\") on one side errors both streams on both sides with aborted; the aborting side's error message is why and the peer's carries no reason string", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + + g.abort("why"); + await settle(); + + const own = [await errorOfRead(g.readable), await errorOfWritable(g.writable)]; + const peer = [await errorOfRead(w.readable), await errorOfWritable(w.writable)]; + for (const error of own) { + expect(error).toMatchObject({ message: "why", reason: "aborted" }); + } + for (const error of peer) { + expect(error).toMatchObject({ reason: "aborted" }); + expect((error as Error).message).not.toContain("why"); + } + }); +}); + +describe("link byte channels: ids and numbers", () => { + it("acceptChannel throws for an id never opened", () => { + const pair = linkPair(); + + expect(() => pair.worker.acceptChannel("never-opened")).toThrow(); + }); + + it("acceptChannel throws for an id already accepted", async () => { + const pair = linkPair(); + const { g } = await openPair(pair); + + expect(() => pair.worker.acceptChannel(g.id)).toThrow(); + }); + + it("acceptChannel throws for an id already ended", async () => { + const pair = linkPair(); + const { g, w } = await openPair(pair); + await endChannel(g, w); + await settle(); + + expect(() => pair.worker.acceptChannel(g.id)).toThrow(); + }); + + it("channel ids match [A-Za-z0-9-]{1,32}, checked over 200 channels from each end", async () => { + const pair = linkPair(); + const ids: string[] = []; + + for (let i = 0; i < 200; i += 1) { + const g = await pair.gateway.openChannel({ signal: signal() }); + const w = await pair.worker.openChannel({ signal: signal() }); + ids.push(g.id, w.id); + await settle(); + const gAccepted = pair.worker.acceptChannel(g.id); + const wAccepted = pair.gateway.acceptChannel(w.id); + await Promise.all([endChannel(g, gAccepted), endChannel(w, wAccepted)]); + } + + expect(ids).toHaveLength(400); + for (const id of ids) expect(id).toMatch(/^[A-Za-z0-9-]{1,32}$/); + }, 60_000); + + it("50 channels opened at once from each end never collide", async () => { + const pair = linkPair(); + + const [fromGateway, fromWorker] = await Promise.all([ + Promise.all(Array.from({ length: 50 }, () => pair.gateway.openChannel({ signal: signal() }))), + Promise.all(Array.from({ length: 50 }, () => pair.worker.openChannel({ signal: signal() }))), + ]); + await settle(); + + const reads: Array> = []; + fromGateway.forEach((channel, index) => { + const accepted = pair.worker.acceptChannel(channel.id); + void writeAndClose(channel, new TextEncoder().encode(`g-${String(index)}`)); + reads.push( + readAll(accepted.readable).then((bytes) => { + expect(new TextDecoder().decode(bytes)).toBe(`g-${String(index)}`); + }), + ); + }); + fromWorker.forEach((channel, index) => { + const accepted = pair.gateway.acceptChannel(channel.id); + void writeAndClose(channel, new TextEncoder().encode(`w-${String(index)}`)); + reads.push( + readAll(accepted.readable).then((bytes) => { + expect(new TextDecoder().decode(bytes)).toBe(`w-${String(index)}`); + }), + ); + }); + await Promise.all(reads); + + const numbers = (tap: LinkPair["gatewayTap"]) => tap.binaries(TYPE.OPEN).map((e) => e.number); + expect(new Set(numbers(pair.gatewayTap)).size).toBe(50); + expect(new Set(numbers(pair.workerTap)).size).toBe(50); + expect(numbers(pair.gatewayTap).every((n) => n % 2 === 0)).toBe(true); + expect(numbers(pair.workerTap).every((n) => n % 2 === 1)).toBe(true); + }); + + it("200 channels opened and ended one after another each get a new number", async () => { + const pair = linkPair(); + + for (let i = 0; i < 200; i += 1) { + const { g, w } = await openPair(pair); + await writeAndClose(g, new TextEncoder().encode(`n-${String(i)}`)); + expect(new TextDecoder().decode(await readAll(w.readable))).toBe(`n-${String(i)}`); + await Promise.all([w.writable.getWriter().close(), drain(g.readable)]); + } + + const numbers = pair.gatewayTap.binaries(TYPE.OPEN).map((entry) => entry.number); + expect(numbers).toHaveLength(200); + expect(new Set(numbers).size).toBe(200); + }, 60_000); + + it("with this side's numbers exhausted, the next openChannel rejects and the link logs it", async () => { + const pair = linkPair({ gatewayFirstNumber: 0xfffffffe }); + const outcomes: string[] = []; + + for (let i = 0; i < 3; i += 1) { + outcomes.push( + await pair.gateway.openChannel({ signal: signal() }).then( + () => "opened", + () => "rejected", + ), + ); + } + + expect(outcomes.slice(1)).toEqual(["rejected", "rejected"]); + expect(pair.gatewayLog.problems().length).toBeGreaterThan(0); + }); +}); + +describe("link byte channels: the 64 channel limit", () => { + async function open64(pair: LinkPair) { + const opened: LinkChannel[] = []; + for (let i = 0; i < 64; i += 1) { + opened.push(await pair.gateway.openChannel({ signal: signal() })); + } + await settle(); + return opened.map((g) => ({ g, w: pair.worker.acceptChannel(g.id) })); + } + + it("the 65th openChannel waits, and resolves once one ends", async () => { + const pair = linkPair(); + const open = await open64(pair); + let state = "pending"; + const next = pair.gateway.openChannel({ signal: signal() }).then( + () => (state = "opened"), + () => (state = "rejected"), + ); + await settle(); + expect(state).toBe("pending"); + + await endChannel(open[0]!.g, open[0]!.w); + await next; + + expect(state).toBe("opened"); + }); + + it("an abort after this side's END was written, with the peer's END still in flight, frees the slot once that END arrives", async () => { + const pair = linkPair(); + const open = await open64(pair); + const { g, w } = open[0]!; + pair.workerEnd.hold(); + await g.writable.getWriter().close(); + await w.writable.getWriter().close(); + await settle(); + + g.abort(); + await settle(); + pair.workerEnd.release(); + const next = pair.gateway.openChannel({ signal: signal() }); + + expect((await outcome(next)).state).toBe("resolved"); + }); + + it("the 65th openChannel rejects with the signal's reason when its signal aborts first", async () => { + const pair = linkPair(); + await open64(pair); + const controller = new AbortController(); + const reason = new Error("caller gave up"); + const next = pair.gateway.openChannel({ signal: controller.signal }); + await settle(); + + controller.abort(reason); + + await expect(next).rejects.toBe(reason); + }); + + it("the 65th openChannel rejects with closed when the connection closes", async () => { + const pair = linkPair(); + await open64(pair); + const next = expect(pair.gateway.openChannel({ signal: signal() })).rejects.toMatchObject({ + reason: "closed", + }); + await settle(); + + await pair.gateway.close(); + + await next; + }); + + it("three waiters get slots in the order they asked", async () => { + const pair = linkPair(); + const open = await open64(pair); + const order: number[] = []; + [1, 2, 3].forEach((n) => { + void pair.gateway.openChannel({ signal: signal() }).then(() => order.push(n)); + }); + await settle(); + expect(order).toEqual([]); + + await endChannel(open[0]!.g, open[0]!.w); + await settle(); + expect(order).toEqual([1]); + await endChannel(open[1]!.g, open[1]!.w); + await settle(); + expect(order).toEqual([1, 2]); + await endChannel(open[2]!.g, open[2]!.w); + await settle(); + + expect(order).toEqual([1, 2, 3]); + }); + + it("with 64 channels open, ending one and opening another at once never makes the peer see 65, even with the transport held", async () => { + const pair = linkPair(); + const open = await open64(pair); + pair.gatewayEnd.hold(); + // why: a held transport only refuses bulk messages once 256 KiB sit unsent, so fill it + // from another channel; this channel's END then waits behind it and is not yet written. + void open[1]!.g.writable + .getWriter() + .write(pattern(0, MiB)) + .catch(() => undefined); + await settle(); + + let state = "pending"; + const next = pair.gateway.openChannel({ signal: signal() }).then((opened) => { + state = "opened"; + return opened; + }); + const ending = endChannel(open[0]!.g, open[0]!.w); + await settle(); + expect(state).toBe("pending"); + pair.gatewayEnd.release(); + await ending; + const channel = await next; + await settle(); + + expect(pair.workerLog.problems()).toEqual([]); + expect(() => pair.worker.acceptChannel(channel.id)).not.toThrow(); + }); +}); diff --git a/packages/bridge/src/link/__tests__/link-lanes.test.ts b/packages/bridge/src/link/__tests__/link-lanes.test.ts new file mode 100644 index 00000000..651f50c7 --- /dev/null +++ b/packages/bridge/src/link/__tests__/link-lanes.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; + +import { KiB, linkPair, MiB, pattern, settle, signal, TYPE, type LinkPair } from "./testing.js"; + +async function openPair(pair: LinkPair) { + const g = await pair.gateway.openChannel({ signal: signal() }); + await settle(); + return { g, w: pair.worker.acceptChannel(g.id) }; +} + +function dataBytes(entries: LinkPair["gatewayTap"]["sent"]): number { + return entries.reduce( + (sum, entry) => + entry.kind === "binary" && entry.envelope.type === TYPE.DATA + ? sum + entry.envelope.payload.length + : sum, + 0, + ); +} + +describe("link lanes", () => { + it("with the transport held and 1 MiB of channel bytes queued, a text message written next is sent before all but at most 256 KiB plus one 64 KiB message of them", async () => { + const pair = linkPair(); + const { g } = await openPair(pair); + pair.gatewayEnd.hold(); + await g.writable.getWriter().write(pattern(0, MiB)); + await settle(); + + void pair.gateway.write("urgent\n"); + await settle(); + + const sent = pair.gatewayTap.sent; + const at = sent.findIndex((entry) => entry.kind === "text" && entry.text === "urgent\n"); + expect(at).toBeGreaterThanOrEqual(0); + expect(dataBytes(sent.slice(0, at))).toBeLessThanOrEqual(256 * KiB + 64 * KiB); + pair.gatewayEnd.release(); + await settle(); + expect(dataBytes(pair.gatewayTap.sent.slice(at))).toBeGreaterThanOrEqual( + MiB - (256 * KiB + 64 * KiB), + ); + }); + + it("with text messages written without pause and channel bytes queued, at least one channel message goes out per 64 KiB of text", async () => { + const pair = linkPair(); + const { g } = await openPair(pair); + pair.gatewayTap.setUnsent(300 * KiB); + await g.writable.getWriter().write(pattern(0, MiB)); + await settle(); + expect(dataBytes(pair.gatewayTap.sent)).toBe(0); + const text = "x".repeat(8 * KiB); + + pair.gatewayTap.setUnsent(0); + pair.gatewayTap.fireSent(); + for (let i = 0; i < 100; i += 1) void pair.gateway.write(text); + await settle(); + + const sent = pair.gatewayTap.sent; + const lastText = sent.map((entry) => entry.kind).lastIndexOf("text"); + expect(dataBytes(sent.slice(0, lastText)) / (64 * KiB)).toBeGreaterThanOrEqual(12); + let run = 0; + let longest = 0; + for (const entry of sent) { + if (entry.kind === "text") run += entry.text.length; + else if (entry.envelope.type === TYPE.DATA) run = 0; + longest = Math.max(longest, run); + } + expect(longest).toBeLessThanOrEqual(64 * KiB + 8 * KiB); + }); + + it("two channels with bytes queued take turns, one message each", async () => { + const pair = linkPair(); + const a = await openPair(pair); + const b = await openPair(pair); + pair.gatewayTap.setUnsent(300 * KiB); + await Promise.all([ + a.g.writable.getWriter().write(pattern(0, 512 * KiB)), + b.g.writable.getWriter().write(pattern(0, 512 * KiB)), + ]); + await settle(); + expect(pair.gatewayTap.binaries(TYPE.DATA)).toEqual([]); + + pair.gatewayTap.setUnsent(0); + pair.gatewayTap.fireSent(); + await settle(); + + const numbers = pair.gatewayTap.binaries(TYPE.DATA).map((envelope) => envelope.number); + expect(numbers).toHaveLength(16); + expect(new Set(numbers).size).toBe(2); + for (let i = 1; i < numbers.length; i += 1) expect(numbers[i]).not.toBe(numbers[i - 1]); + }); +}); diff --git a/packages/bridge/src/link/__tests__/link-messages.test.ts b/packages/bridge/src/link/__tests__/link-messages.test.ts new file mode 100644 index 00000000..68770d49 --- /dev/null +++ b/packages/bridge/src/link/__tests__/link-messages.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createLink } from "../index.js"; +import { + errorOfRead, + errorOfWritable, + linkPair, + settle, + signal, + RecordingLogger, +} from "./testing.js"; +import { startWebSocketPair } from "./test-websocket.js"; +import { FakeClock } from "./ports.js"; + +const MESSAGES = ['{"id":1,"type":"hello"}\n', "second é — ünïcode\n", "third\n"]; + +async function expectInOrder( + writer: { write(contents: string): Promise }, + onData: (listener: (chunk: string) => void) => unknown, +): Promise { + const received: string[] = []; + onData((chunk) => received.push(chunk)); + for (const message of MESSAGES) await writer.write(message); + await vi.waitFor(() => expect(received).toEqual(MESSAGES)); +} + +describe("link text messages", () => { + const cleanups: Array<() => Promise> = []; + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())); + }); + + it("text messages written on one link arrive on the other unchanged and in order, over the in-memory pair", async () => { + const { gateway, worker } = linkPair(); + + await expectInOrder(gateway, (listener) => worker.onData(listener)); + }); + + it("text messages written on one link arrive on the other unchanged and in order, over the real WebSocket", async () => { + const sockets = await startWebSocketPair(); + cleanups.push(() => sockets.close()); + const clock = new FakeClock(0); + const worker = createLink(sockets.worker, { + clock, + end: "worker", + logger: new RecordingLogger(), + }); + const gateway = createLink(sockets.gateway, { + clock, + end: "gateway", + logger: new RecordingLogger(), + }); + + await expectInOrder(gateway, (listener) => worker.onData(listener)); + }); + + it("text that arrives before the first onData is delivered to it", async () => { + const { gateway, worker } = linkPair(); + await gateway.write("early-1\n"); + await gateway.write("early-2\n"); + await settle(); + + const received: string[] = []; + worker.onData((chunk) => received.push(chunk)); + + expect(received).toEqual(["early-1\n", "early-2\n"]); + }); + + it("an onClose attached after the close fires at once", async () => { + const { gateway, worker } = linkPair(); + await worker.close(); + await settle(); + + const closed = vi.fn(); + gateway.onClose(closed); + + expect(closed).toHaveBeenCalledTimes(1); + }); + + it("a pair's fail(error) reaches onError and closes that end", async () => { + const { gatewayEnd, gateway } = linkPair(); + const errors: Error[] = []; + gateway.onError((error) => errors.push(error)); + + gatewayEnd.fail(new Error("socket reset")); + await settle(); + + expect(errors.map((error) => error.message)).toEqual(["socket reset"]); + expect(gatewayEnd.closed).toBe(true); + }); + + it("closing the transport fails every open channel with closed", async () => { + const { gateway, gatewayEnd, worker } = linkPair(); + const opened = await gateway.openChannel({ signal: signal() }); + const accepted = worker.acceptChannel(opened.id); + + await gatewayEnd.close(); + await settle(); + + await expect(errorOfRead(opened.readable)).resolves.toMatchObject({ reason: "closed" }); + await expect(errorOfWritable(opened.writable)).resolves.toMatchObject({ reason: "closed" }); + await expect(errorOfRead(accepted.readable)).resolves.toMatchObject({ reason: "closed" }); + await expect(errorOfWritable(accepted.writable)).resolves.toMatchObject({ reason: "closed" }); + }); + + it("closing the link closes the transport", async () => { + const { gateway, gatewayEnd, workerEnd } = linkPair(); + + await gateway.close(); + + expect(gatewayEnd.closed).toBe(true); + expect(workerEnd.closed).toBe(true); + }); + + it("write resolves when the transport's write resolves", async () => { + const { gateway, gatewayEnd, worker } = linkPair(); + const received: string[] = []; + worker.onData((chunk) => received.push(chunk)); + gatewayEnd.hold(); + let resolved = false; + + const write = gateway.write("held\n").then(() => { + resolved = true; + }); + await settle(); + expect(resolved).toBe(false); + expect(received).toEqual([]); + + gatewayEnd.release(); + await write; + + expect(received).toEqual(["held\n"]); + }); + + it("a text message queued at close resolves unsent, also when the pair was held", async () => { + const { gateway, gatewayEnd, worker } = linkPair(); + const received: string[] = []; + worker.onData((chunk) => received.push(chunk)); + await gateway.write("before\n"); + await settle(); + expect(received).toEqual(["before\n"]); + gatewayEnd.hold(); + + const write = gateway.write("queued\n"); + await settle(); + await gateway.close(); + await write; + await settle(); + + expect(received).toEqual(["before\n"]); + }); + + it("a text message written after the connection closed resolves without being sent", async () => { + const { gateway, worker } = linkPair(); + const received: string[] = []; + worker.onData((chunk) => received.push(chunk)); + await gateway.write("before\n"); + await settle(); + expect(received).toEqual(["before\n"]); + await gateway.close(); + + await gateway.write("late\n"); + await settle(); + + expect(received).toEqual(["before\n"]); + }); +}); diff --git a/packages/bridge/src/link/__tests__/link-portable.test.ts b/packages/bridge/src/link/__tests__/link-portable.test.ts new file mode 100644 index 00000000..a73e6218 --- /dev/null +++ b/packages/bridge/src/link/__tests__/link-portable.test.ts @@ -0,0 +1,134 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { utf8Length } from "../envelope.js"; +import { createLink } from "../index.js"; +import { linkPair, pattern, RecordingLogger, settle, signal, writeAndClose } from "./testing.js"; +import { createConnectionPair, FakeClock } from "./ports.js"; + +async function collect(readable: ReadableStream): Promise { + const reader = readable.getReader(); + const bytes: number[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) return bytes; + for (const byte of value) bytes.push(byte); + } +} + +describe("link: nothing in it needs Node", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("utf8Length counts every string the way Buffer.byteLength does, unpaired surrogates included", () => { + const samples = [ + "", + "ascii", + "é — ünïcode", + "日本語", + "emoji 😀 pair", + "lone high \ud83d end", + "lone low \ude00 end", + "\ud83d", + "\ude00", + "\ud83d😀", + '{"id":1,"type":"hello"}\n', + ]; + + for (const sample of samples) expect(utf8Length(sample)).toBe(Buffer.byteLength(sample)); + }); + + // harness: stubbing the global Buffer also breaks vitest's own worker messages, so the + // link's sources are checked for it instead. + it("no link source uses Buffer, a Node module, or a package other than the streams ponyfill", () => { + const linkDir = join(dirname(fileURLToPath(import.meta.url)), ".."); + const sources = readdirSync(linkDir).filter((name) => name.endsWith(".ts")); + const imports = /from\s+"([^"]+)"/g; + + expect(sources.length).toBeGreaterThan(0); + for (const name of sources) { + const code = readFileSync(join(linkDir, name), "utf8").replace(/\/\/.*$/gm, ""); + expect(code, name).not.toMatch(/\bBuffer\b/); + for (const [, specifier] of code.matchAll(imports)) { + expect( + specifier?.startsWith("./") || specifier === "web-streams-polyfill", + `${name} imports ${String(specifier)}`, + ).toBe(true); + } + } + }); + + it("a channel carries bytes both ways with no crypto.randomUUID in the runtime", async () => { + vi.stubGlobal("crypto", { + getRandomValues: (bytes: Uint8Array) => { + for (let i = 0; i < bytes.length; i += 1) bytes[i] = (i * 37 + 11) % 256; + return bytes; + }, + }); + const pair = linkPair(); + + const g = await pair.gateway.openChannel({ signal: signal() }); + await settle(); + const w = pair.worker.acceptChannel(g.id); + const sent = pattern(0, 300_000); + const received = collect(w.readable); + await writeAndClose(g, sent); + + expect(g.id).toMatch(/^[0-9a-f]{32}$/); + expect(await received).toEqual(Array.from(sent)); + }); + + it("with no crypto at all, openChannel fails naming generateId, and works once one is passed", async () => { + vi.stubGlobal("crypto", undefined); + const [workerEnd, gatewayEnd] = createConnectionPair(); + const clock = new FakeClock(0); + const without = createLink(gatewayEnd, { + clock, + end: "gateway", + logger: new RecordingLogger(), + }); + const withIds = createLink(workerEnd, { + clock, + end: "worker", + generateId: () => "chosen-id-1", + logger: new RecordingLogger(), + }); + + await expect(without.openChannel({ signal: signal() })).rejects.toThrow(/generateId/); + const channel = await withIds.openChannel({ signal: signal() }); + + expect(channel.id).toBe("chosen-id-1"); + }); + + it("an id from generateId that the peer would drop is refused when the channel is opened, not lost silently", async () => { + const [workerEnd] = createConnectionPair(); + const link = createLink(workerEnd, { + clock: new FakeClock(0), + end: "worker", + generateId: () => "not a valid id!", + logger: new RecordingLogger(), + }); + + await expect(link.openChannel({ signal: signal() })).rejects.toThrow(/\[A-Za-z0-9-\]/); + }); + + it("generateId is asked again when it repeats an id still in use, and the two channels differ", async () => { + const ids = ["same", "same", "other"]; + const [workerEnd] = createConnectionPair(); + const link = createLink(workerEnd, { + clock: new FakeClock(0), + end: "worker", + generateId: () => ids.shift() ?? "exhausted", + logger: new RecordingLogger(), + }); + + const first = await link.openChannel({ signal: signal() }); + const second = await link.openChannel({ signal: signal() }); + + expect([first.id, second.id]).toEqual(["same", "other"]); + }); +}); diff --git a/packages/bridge/src/link/__tests__/link-rules.test.ts b/packages/bridge/src/link/__tests__/link-rules.test.ts new file mode 100644 index 00000000..c2147ec1 --- /dev/null +++ b/packages/bridge/src/link/__tests__/link-rules.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; + +import { + creditEnvelope, + dataEnvelope, + envelope, + errorOfRead, + KiB, + linkWithRawPeer, + openEnvelope, + pattern, + RESET_CODE, + resetEnvelope, + settle, + TYPE, + type RawPeer, +} from "./testing.js"; + +async function acceptRaw(peer: RawPeer, number = 2, id = "raw-a") { + await peer.raw.writeBinary(openEnvelope(number, id)); + await settle(); + return peer.link.acceptChannel(id); +} + +async function expectTextFlows(peer: RawPeer): Promise { + await peer.link.write("ping\n"); + await peer.raw.write("pong\n"); + await settle(); + expect(peer.receivedText).toContain("ping\n"); + expect(peer.linkText).toContain("pong\n"); +} + +function resets(peer: RawPeer, number: number) { + return peer.received.filter((e) => e.type === TYPE.RESET && e.number === number); +} + +interface Break { + readonly name: string; + readonly run: (peer: RawPeer) => Promise; +} + +const channelBreaks: Break[] = [ + { + name: "DATA past the allowed bytes", + run: async (peer) => { + const channel = await acceptRaw(peer); + for (let i = 0; i < 64; i += 1) await peer.raw.writeBinary(dataEnvelope(2, 64 * KiB)); + await settle(); + expect(resets(peer, 2)).toEqual([]); + await peer.raw.writeBinary(dataEnvelope(2, 1)); + return channel; + }, + }, + { + name: "DATA over 64 KiB", + run: async (peer) => { + const channel = await acceptRaw(peer); + await peer.raw.writeBinary(dataEnvelope(2, 64 * KiB)); + await settle(); + expect(resets(peer, 2)).toEqual([]); + await peer.raw.writeBinary(dataEnvelope(2, 64 * KiB + 1)); + return channel; + }, + }, + { + name: "DATA after that direction ended", + run: async (peer) => { + const channel = await acceptRaw(peer); + await peer.raw.writeBinary(envelope(TYPE.END, 2)); + await settle(); + expect(resets(peer, 2)).toEqual([]); + await peer.raw.writeBinary(dataEnvelope(2, 10)); + return channel; + }, + }, + { + name: "END after that direction ended", + run: async (peer) => { + const channel = await acceptRaw(peer); + await peer.raw.writeBinary(envelope(TYPE.END, 2)); + await settle(); + expect(resets(peer, 2)).toEqual([]); + await peer.raw.writeBinary(envelope(TYPE.END, 2)); + return channel; + }, + }, + { + name: "a CREDIT of 0", + run: async (peer) => { + const channel = await acceptRaw(peer); + await channel.writable.getWriter().write(pattern(0, KiB)); + await peer.raw.writeBinary(creditEnvelope(2, KiB)); + await settle(); + expect(resets(peer, 2)).toEqual([]); + await peer.raw.writeBinary(creditEnvelope(2, 0)); + return channel; + }, + }, + { + name: "credit beyond 4 MiB outstanding", + run: async (peer) => { + const channel = await acceptRaw(peer); + await channel.writable.getWriter().write(pattern(0, 64 * KiB)); + await peer.raw.writeBinary(creditEnvelope(2, 64 * KiB)); + await settle(); + expect(resets(peer, 2)).toEqual([]); + await peer.raw.writeBinary(creditEnvelope(2, 1)); + return channel; + }, + }, +]; + +describe("link rule breaks that name a held channel", () => { + for (const rule of channelBreaks) { + it(`${rule.name} is dropped, resets the channel it names with invalid, is logged, and leaves text flowing`, async () => { + const peer = linkWithRawPeer(); + const channel = (await rule.run(peer)) as Awaited>; + await settle(); + + const named = resets(peer, 2); + expect(named).toHaveLength(1); + expect(named[0]?.payload[0]).toBe(RESET_CODE.invalid); + await expect(errorOfRead(channel.readable)).resolves.toMatchObject({ reason: "invalid" }); + expect(peer.log.problems().length).toBeGreaterThan(0); + await expectTextFlows(peer); + }); + } +}); + +interface Dropped { + readonly name: string; + readonly run: (peer: RawPeer) => Promise; + readonly neverAccepted?: string; +} + +const dropped: Dropped[] = [ + { + name: "a channel number the peer never opened", + run: async (peer) => { + await acceptRaw(peer); + await peer.raw.writeBinary(dataEnvelope(10, 5)); + }, + }, + { + name: "an OPEN reusing a live number", + neverAccepted: "raw-b", + run: async (peer) => { + await acceptRaw(peer); + await peer.raw.writeBinary(openEnvelope(2, "raw-b")); + }, + }, + { + name: "an OPEN with the wrong parity", + neverAccepted: "raw-c", + run: async (peer) => { + await peer.raw.writeBinary(openEnvelope(3, "raw-c")); + }, + }, + { + name: "a 65th OPEN from the peer", + neverAccepted: "raw-65", + run: async (peer) => { + for (let i = 1; i <= 64; i += 1) { + await peer.raw.writeBinary(openEnvelope(i * 2, `raw-${String(i)}`)); + } + await settle(); + expect(peer.log.problems()).toEqual([]); + expect(() => peer.link.acceptChannel("raw-64")).not.toThrow(); + await peer.raw.writeBinary(openEnvelope(130, "raw-65")); + }, + }, + { + name: "an unknown envelope type", + run: async (peer) => { + await acceptRaw(peer); + await peer.raw.writeBinary(envelope(99, 2)); + await settle(); + expect(peer.log.problems().join("\n")).toContain("an unknown envelope type"); + expect(resets(peer, 2)).toEqual([]); + }, + }, + { + name: "a message too short to read", + run: async (peer) => { + await peer.raw.writeBinary(new Uint8Array([TYPE.DATA, 0, 0])); + }, + }, +]; + +describe("link rule breaks that name no held channel", () => { + for (const rule of dropped) { + it(`${rule.name} is dropped and logged and leaves text flowing`, async () => { + const peer = linkWithRawPeer(); + await rule.run(peer); + await settle(); + + expect(peer.log.problems().length).toBeGreaterThan(0); + if (rule.neverAccepted !== undefined) { + expect(() => peer.link.acceptChannel(rule.neverAccepted as string)).toThrow(); + } + await expectTextFlows(peer); + }); + } +}); + +describe("link late traffic for an ended channel", () => { + it("a RESET for a channel this side does not hold is dropped and not answered", async () => { + const peer = linkWithRawPeer(); + await acceptRaw(peer); + await peer.raw.writeBinary(resetEnvelope(2, "aborted")); + await settle(); + const before = peer.received.length; + const problems = peer.log.problems().length; + + await peer.raw.writeBinary(resetEnvelope(2, "aborted")); + await settle(); + + expect(peer.received).toHaveLength(before); + expect(peer.log.problems()).toHaveLength(problems); + await expectTextFlows(peer); + }); + + it("a STOP that arrives after its channel ended on this side is dropped quietly: no log, no reset, no answer", async () => { + const peer = linkWithRawPeer(); + const channel = await acceptRaw(peer); + await channel.writable.getWriter().close(); + await settle(); + expect(peer.received.some((e) => e.type === TYPE.END && e.number === 2)).toBe(true); + await peer.raw.writeBinary(envelope(TYPE.END, 2)); + await settle(); + const before = peer.received.length; + const records = peer.log.records.length; + + await peer.raw.writeBinary(envelope(TYPE.STOP, 2)); + await settle(); + + expect(peer.received).toHaveLength(before); + expect(peer.log.records).toHaveLength(records); + }); +}); diff --git a/packages/bridge/src/link/__tests__/ports.ts b/packages/bridge/src/link/__tests__/ports.ts new file mode 100644 index 00000000..e7ba044e --- /dev/null +++ b/packages/bridge/src/link/__tests__/ports.ts @@ -0,0 +1,232 @@ +// Test doubles from simlock's `src/ports` that the link's tests use: `FakeClock` and the +// in-memory connection pair. +import { utf8Length } from "../envelope.js"; +import type { Clock, TimerHandle, UplinkTransportConnection } from "../ports.js"; + +export type { Logger, UplinkTransportConnection } from "../ports.js"; + +interface ScheduledTimer { + readonly deadline: number; + readonly id: number; + readonly callback: () => void; +} + +export class FakeClock implements Clock { + #now: number; + #nextTimerId = 0; + readonly #timers = new Map(); + + constructor(initialNow = 0) { + this.#now = initialNow; + } + + now(): number { + return this.#now; + } + + get pendingTimerCount(): number { + return this.#timers.size; + } + + setTimer(delayMs: number, callback: () => void): TimerHandle { + const id = this.#nextTimerId; + this.#nextTimerId += 1; + this.#timers.set(id, { + deadline: this.#now + Math.max(0, delayMs), + id, + callback, + }); + + return { id } as TimerHandle & { readonly id: number }; + } + + cancel(handle: TimerHandle): void { + const timer = handle as TimerHandle & { readonly id: number }; + this.#timers.delete(timer.id); + } + + advance(milliseconds: number): void { + const target = this.#now + milliseconds; + let nextTimer = this.#nextTimerDueBy(target); + + while (nextTimer !== undefined) { + this.#timers.delete(nextTimer.id); + this.#now = nextTimer.deadline; + nextTimer.callback(); + nextTimer = this.#nextTimerDueBy(target); + } + + this.#now = target; + } + + #nextTimerDueBy(target: number): ScheduledTimer | undefined { + let selected: ScheduledTimer | undefined; + + for (const timer of this.#timers.values()) { + if ( + timer.deadline <= target && + (selected === undefined || + timer.deadline < selected.deadline || + (timer.deadline === selected.deadline && timer.id < selected.id)) + ) { + selected = timer; + } + } + + return selected; + } +} + +export interface MemoryTransportConnection extends UplinkTransportConnection { + hold(): void; + release(): void; + fail(error: Error): void; +} + +function subscribeListener(listeners: Set, listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Two connections wired to each other: what one writes, the other's listeners receive; + * closing either closes both. `hold()` keeps writes as unsent until `release()`. + */ +export function createConnectionPair(): [MemoryTransportConnection, MemoryTransportConnection] { + const left = new PairedConnection(); + const right = new PairedConnection(); + left.pair(right); + right.pair(left); + return [left, right]; +} + +type PairedMessage = + | { readonly kind: "text"; readonly text: string } + | { readonly kind: "binary"; readonly bytes: Uint8Array }; + +class PairedConnection implements MemoryTransportConnection { + readonly #closeListeners = new Set<() => void>(); + readonly #dataListeners = new Set<(chunk: string) => void>(); + readonly #pending: string[] = []; + readonly #errorListeners = new Set<(error: Error) => void>(); + readonly #binaryListeners = new Set<(bytes: Uint8Array) => void>(); + readonly #sentListeners = new Set<() => void>(); + readonly #queue: Array<{ message: PairedMessage; resolve: () => void; size: number }> = []; + #unsent = 0; + #held = false; + #peer: PairedConnection | undefined; + #closed = false; + + get closed(): boolean { + return this.#closed; + } + + pair(peer: PairedConnection): void { + this.#peer = peer; + } + + onData(listener: (chunk: string) => void): () => void { + const unsubscribe = subscribeListener(this.#dataListeners, listener); + if (this.#pending.length > 0) { + const buffered = this.#pending.splice(0); + for (const chunk of buffered) listener(chunk); + } + return unsubscribe; + } + + onClose(listener: () => void): () => void { + if (this.#closed) { + listener(); + return () => undefined; + } + return subscribeListener(this.#closeListeners, listener); + } + + onError(listener: (error: Error) => void): () => void { + return subscribeListener(this.#errorListeners, listener); + } + + get unsentBytes(): number { + return this.#unsent; + } + + onBinary(listener: (bytes: Uint8Array) => void): () => void { + return subscribeListener(this.#binaryListeners, listener); + } + + onSent(listener: () => void): () => void { + return subscribeListener(this.#sentListeners, listener); + } + + hold(): void { + this.#held = true; + } + + release(): void { + this.#held = false; + while (this.#queue.length > 0) { + const entry = this.#queue.shift(); + if (entry === undefined) break; + this.#unsent -= entry.size; + this.#deliver(entry.message); + entry.resolve(); + } + this.#unsent = 0; + for (const listener of this.#sentListeners) listener(); + } + + fail(error: Error): void { + for (const listener of this.#errorListeners) listener(error); + void this.close(); + } + + async write(contents: string): Promise { + await this.#send({ kind: "text", text: contents }, utf8Length(contents)); + } + + async writeBinary(bytes: Uint8Array): Promise { + await this.#send({ kind: "binary", bytes }, bytes.length); + } + + async #send(message: PairedMessage, size: number): Promise { + if (this.#closed) return; + if (!this.#held) { + this.#deliver(message); + return; + } + this.#unsent += size; + await new Promise((resolve) => { + this.#queue.push({ message, resolve, size }); + }); + } + + #deliver(message: PairedMessage): void { + const peer = this.#peer; + if (this.#closed || peer === undefined || peer.#closed) return; + if (message.kind === "binary") { + for (const listener of peer.#binaryListeners) listener(message.bytes); + return; + } + if (peer.#dataListeners.size === 0) { + peer.#pending.push(message.text); + return; + } + for (const listener of peer.#dataListeners) listener(message.text); + } + + async close(): Promise { + this.#close(); + const peer = this.#peer; + if (peer !== undefined) peer.#close(); + } + + #close(): void { + if (this.#closed) return; + this.#closed = true; + this.#unsent = 0; + for (const entry of this.#queue.splice(0)) entry.resolve(); + for (const listener of this.#closeListeners) listener(); + } +} diff --git a/packages/bridge/src/link/__tests__/standard-websocket.test.ts b/packages/bridge/src/link/__tests__/standard-websocket.test.ts new file mode 100644 index 00000000..ad2e0e0a --- /dev/null +++ b/packages/bridge/src/link/__tests__/standard-websocket.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it, vi } from "vitest"; + +import { FakeClock } from "./ports.js"; +import { + connectStandardWebSocket, + StandardWebSocketConnection, + type StandardWebSocket, +} from "../standard-websocket.js"; + +type Listener = (event: unknown) => void; + +class FakeSocket implements StandardWebSocket { + binaryType = "blob"; + bufferedAmount = 0; + readyState = 1; + readonly sent: Array = []; + readonly #listeners = new Map>(); + retain = (size: number) => size; + + send(data: string | ArrayBufferView): void { + const bytes = + typeof data === "string" ? new TextEncoder().encode(data).length : data.byteLength; + this.sent.push(typeof data === "string" ? data : new Uint8Array(data.buffer)); + this.bufferedAmount += this.retain(bytes); + } + + close(): void { + this.readyState = 3; + this.emit("close", {}); + } + + addEventListener(type: string, listener: never): void { + const set = this.#listeners.get(type) ?? new Set(); + set.add(listener as Listener); + this.#listeners.set(type, set); + } + + removeEventListener(type: string, listener: never): void { + this.#listeners.get(type)?.delete(listener as Listener); + } + + emit(type: string, event: unknown): void { + for (const listener of this.#listeners.get(type) ?? []) listener(event); + } +} + +async function microtasks(): Promise { + for (let i = 0; i < 5; i += 1) await Promise.resolve(); +} + +function setup(pollMs = 4) { + const socket = new FakeSocket(); + const clock = new FakeClock(0); + const connection = new StandardWebSocketConnection(socket, { clock, pollMs }); + return { clock, connection, socket }; +} + +describe("standard WebSocket transport", () => { + it("asks the socket for ArrayBuffers, since a Blob cannot be read without reordering messages", () => { + const { socket } = setup(); + + expect(socket.binaryType).toBe("arraybuffer"); + }); + + it("text and binary messages reach their own listeners, binary as a Uint8Array over the same bytes", () => { + const { connection, socket } = setup(); + const text: string[] = []; + const binary: number[][] = []; + connection.onData((chunk) => text.push(chunk)); + connection.onBinary((bytes) => binary.push(Array.from(bytes))); + + socket.emit("message", { data: "hello é" }); + socket.emit("message", { data: new Uint8Array([1, 2, 3]).buffer }); + socket.emit("message", { data: new Uint8Array([9, 8, 7, 6]).subarray(1, 3) }); + + expect(text).toEqual(["hello é"]); + expect(binary).toEqual([ + [1, 2, 3], + [8, 7], + ]); + }); + + it("text that arrives before the first onData is delivered to it, in order", () => { + const { connection, socket } = setup(); + socket.emit("message", { data: "one" }); + socket.emit("message", { data: "two" }); + + const seen: string[] = []; + connection.onData((chunk) => seen.push(chunk)); + socket.emit("message", { data: "three" }); + + expect(seen).toEqual(["one", "two", "three"]); + }); + + it("a write whose bytes sit in the buffer stays pending until they leave, and reports them sent", async () => { + const { clock, connection, socket } = setup(); + const sent = vi.fn(); + connection.onSent(sent); + + let settled = false; + const written = connection.writeBinary(new Uint8Array(1000)).then(() => { + settled = true; + }); + await microtasks(); + + expect(connection.unsentBytes).toBe(1000); + expect(settled).toBe(false); + + socket.bufferedAmount = 0; + clock.advance(4); + await written; + + expect(settled).toBe(true); + expect(sent).toHaveBeenCalledTimes(1); + }); + + it("a socket that never sets bufferedAmount, as React Native's does not, still settles writes", async () => { + const socket = new FakeSocket(); + Object.defineProperty(socket, "bufferedAmount", { get: () => undefined, set: () => undefined }); + const clock = new FakeClock(0); + const connection = new StandardWebSocketConnection(socket, { clock }); + + await connection.writeBinary(new Uint8Array(1000)); + await connection.write("tiny"); + + expect(connection.unsentBytes).toBe(0); + expect(clock.pendingTimerCount).toBe(0); + }); + + it("a write the socket took whole settles without waiting for a timer", async () => { + const { clock, connection, socket } = setup(); + socket.retain = () => 0; + + await connection.write("tiny"); + + expect(clock.pendingTimerCount).toBe(0); + expect(socket.sent).toEqual(["tiny"]); + }); + + it("each write settles when its own bytes have left, not before, and onSent fires once per step the buffer shrinks", async () => { + const { clock, connection, socket } = setup(); + const sent = vi.fn(); + connection.onSent(sent); + const order: string[] = []; + const first = connection.writeBinary(new Uint8Array(300)).then(() => order.push("first")); + const second = connection.writeBinary(new Uint8Array(300)).then(() => order.push("second")); + const third = connection.writeBinary(new Uint8Array(300)).then(() => order.push("third")); + await microtasks(); + + socket.bufferedAmount = 500; + clock.advance(4); + await first; + expect(order).toEqual(["first"]); + + socket.bufferedAmount = 200; + clock.advance(4); + await second; + expect(order).toEqual(["first", "second"]); + + socket.bufferedAmount = 0; + clock.advance(4); + await third; + + expect(order).toEqual(["first", "second", "third"]); + expect(sent).toHaveBeenCalledTimes(3); + }); + + it("checks on a timer only while something is buffered", async () => { + const { clock, connection, socket } = setup(); + const written = connection.write("x".repeat(100)); + await microtasks(); + expect(clock.pendingTimerCount).toBe(1); + + socket.bufferedAmount = 0; + clock.advance(4); + await written; + + expect(clock.pendingTimerCount).toBe(0); + }); + + it("a message from the peer is taken as news that bytes are moving, and settles a drained write with no timer", async () => { + const { clock, connection, socket } = setup(); + connection.onBinary(() => undefined); + const written = connection.writeBinary(new Uint8Array(100)); + await microtasks(); + + socket.bufferedAmount = 0; + socket.emit("message", { data: new ArrayBuffer(5) }); + + await written; + expect(clock.pendingTimerCount).toBe(0); + }); + + it("closing settles every pending write, cancels the timer, and does not report sent for bytes that never left", async () => { + const { clock, connection } = setup(); + const sent = vi.fn(); + connection.onSent(sent); + const written = connection.writeBinary(new Uint8Array(100)); + await microtasks(); + + await connection.close(); + await written; + + expect(clock.pendingTimerCount).toBe(0); + expect(sent).not.toHaveBeenCalled(); + expect(connection.closed).toBe(true); + }); + + it("a write after the close sends nothing and settles", async () => { + const { connection, socket } = setup(); + await connection.close(); + + await connection.write("late"); + await connection.writeBinary(new Uint8Array(3)); + + expect(socket.sent).toEqual([]); + }); + + it("a close from the peer fires onClose once, and an onClose attached after fires at once", () => { + const { connection, socket } = setup(); + const closed = vi.fn(); + connection.onClose(closed); + + socket.emit("close", {}); + socket.emit("close", {}); + const late = vi.fn(); + connection.onClose(late); + + expect(closed).toHaveBeenCalledTimes(1); + expect(late).toHaveBeenCalledTimes(1); + }); + + it("an error event reaches onError as an Error and closes the connection", () => { + const { connection, socket } = setup(); + const errors: Error[] = []; + connection.onError((error) => errors.push(error)); + + socket.emit("error", {}); + socket.emit("error", { error: new Error("reset") }); + + expect(errors.map((error) => error.message)).toEqual(["WebSocket error", "reset"]); + expect(connection.closed).toBe(true); + }); + + it("connecting resolves on open with the connection already listening, and rejects if the socket closes first", async () => { + const opening = new FakeSocket(); + opening.readyState = 0; + const connecting = connectStandardWebSocket({ createSocket: () => opening, url: "ws://x" }); + opening.emit("message", { data: "spoke first" }); + opening.readyState = 1; + opening.emit("open", {}); + const connection = await connecting; + const seen: string[] = []; + connection.onData((chunk) => seen.push(chunk)); + + expect(seen).toEqual(["spoke first"]); + + const refused = new FakeSocket(); + refused.readyState = 0; + const failing = connectStandardWebSocket({ createSocket: () => refused, url: "ws://y" }); + refused.emit("close", {}); + + await expect(failing).rejects.toThrow(/could not open/); + }); +}); diff --git a/packages/bridge/src/link/__tests__/streams-ponyfill.test.ts b/packages/bridge/src/link/__tests__/streams-ponyfill.test.ts new file mode 100644 index 00000000..9a8aff9d --- /dev/null +++ b/packages/bridge/src/link/__tests__/streams-ponyfill.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createConnectionPair } from "./ports.js"; +import { pattern, RecordingLogger, settle, signal } from "./testing.js"; + +// React Native has no WHATWG streams: the link must fall back to the ponyfill. +describe("link: with no WHATWG streams in the runtime", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("a channel carries bytes both ways on ponyfilled streams", async () => { + const NativeReadableStream = globalThis.ReadableStream; + vi.stubGlobal("ReadableStream", undefined); + vi.stubGlobal("WritableStream", undefined); + vi.stubGlobal("ByteLengthQueuingStrategy", undefined); + vi.resetModules(); + const { createLink, globalClock } = await import("../index.js"); + const [workerEnd, gatewayEnd] = createConnectionPair(); + const options = { clock: globalClock, logger: new RecordingLogger() }; + const worker = createLink(workerEnd, { ...options, end: "worker", generateId: () => "w-1" }); + const gateway = createLink(gatewayEnd, { ...options, end: "gateway" }); + + const opened = await worker.openChannel({ signal: signal() }); + await settle(); + const accepted = gateway.acceptChannel(opened.id); + const data = pattern(0, 6 * 1024 * 1024); + + const read = async (readable: ReadableStream) => { + const reader = readable.getReader(); + let total = 0; + let intact = true; + for (;;) { + const { done, value } = await reader.read(); + if (done) return { intact, total }; + for (let i = 0; i < value.length; i += 1) { + if (value[i] !== (total + i) % 251) intact = false; + } + total += value.length; + } + }; + const write = async (writable: WritableStream) => { + const writer = writable.getWriter(); + await writer.write(data); + await writer.close(); + }; + + expect(accepted.readable).not.toBeInstanceOf(NativeReadableStream); + const [up, down] = await Promise.all([ + read(accepted.readable), + read(opened.readable), + write(opened.writable), + write(accepted.writable), + ]); + + expect(up).toEqual({ intact: true, total: data.length }); + expect(down).toEqual({ intact: true, total: data.length }); + }); +}); diff --git a/packages/bridge/src/link/__tests__/test-websocket.ts b/packages/bridge/src/link/__tests__/test-websocket.ts new file mode 100644 index 00000000..8c3a8096 --- /dev/null +++ b/packages/bridge/src/link/__tests__/test-websocket.ts @@ -0,0 +1,49 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { WebSocket, WebSocketServer } from "ws"; + +import type { UplinkTransportConnection } from "../ports.js"; +import { + connectStandardWebSocket, + StandardWebSocketConnection, + type StandardWebSocket, +} from "../standard-websocket.js"; + +// A real WebSocket pair over `ws`, both ends wrapped the way Harness wraps them. +export async function startWebSocketPair(): Promise<{ + worker: UplinkTransportConnection; + gateway: UplinkTransportConnection; + close(): Promise; +}> { + const sockets = new WebSocketServer({ noServer: true }); + let accepted: ((connection: UplinkTransportConnection) => void) | undefined; + const acceptedConnection = new Promise((resolve) => { + accepted = resolve; + }); + const server: Server = createServer((_request, response) => { + response.writeHead(404).end(); + }); + server.on("upgrade", (request, socket, head) => { + sockets.handleUpgrade(request, socket, head, (client) => { + accepted?.(new StandardWebSocketConnection(client as unknown as StandardWebSocket)); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as AddressInfo; + const worker = await connectStandardWebSocket({ + createSocket: (url) => new WebSocket(url) as unknown as StandardWebSocket, + url: `ws://127.0.0.1:${String(port)}`, + }); + const gateway = await acceptedConnection; + return { + close: async () => { + await worker.close(); + await gateway.close(); + await new Promise((resolve) => sockets.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + }, + gateway, + worker, + }; +} diff --git a/packages/bridge/src/link/__tests__/testing.ts b/packages/bridge/src/link/__tests__/testing.ts new file mode 100644 index 00000000..e28f2975 --- /dev/null +++ b/packages/bridge/src/link/__tests__/testing.ts @@ -0,0 +1,363 @@ +import { createHash } from "node:crypto"; + +import { + createConnectionPair, + FakeClock, + type Logger, + type MemoryTransportConnection, + type UplinkTransportConnection, +} from "./ports.js"; +import { createLink, type LinkChannel, type LinkConnection } from "../index.js"; + +export const KiB = 1024; +export const MiB = 1024 * KiB; + +export const TYPE = { OPEN: 1, DATA: 2, END: 3, STOP: 4, CREDIT: 5, RESET: 6 } as const; +export const RESET_CODE = { aborted: 1, stalled: 2, "not-accepted": 3, invalid: 4 } as const; + +export interface Envelope { + readonly type: number; + readonly number: number; + readonly payload: Uint8Array; +} + +export function envelope(type: number, number: number, payload = new Uint8Array(0)): Uint8Array { + const bytes = new Uint8Array(5 + payload.length); + bytes[0] = type; + new DataView(bytes.buffer).setUint32(1, number); + bytes.set(payload, 5); + return bytes; +} + +function parseEnvelope(bytes: Uint8Array): Envelope { + return { + type: bytes[0] ?? -1, + number: new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(1), + payload: bytes.slice(5), + }; +} + +export function openEnvelope(number: number, id: string): Uint8Array { + return envelope(TYPE.OPEN, number, new TextEncoder().encode(id)); +} + +export function dataEnvelope(number: number, size: number): Uint8Array { + return envelope(TYPE.DATA, number, new Uint8Array(size).fill(7)); +} + +export function creditEnvelope(number: number, amount: number): Uint8Array { + const payload = new Uint8Array(4); + new DataView(payload.buffer).setUint32(0, amount); + return envelope(TYPE.CREDIT, number, payload); +} + +export function resetEnvelope(number: number, reason: keyof typeof RESET_CODE): Uint8Array { + return envelope(TYPE.RESET, number, new Uint8Array([RESET_CODE[reason]])); +} + +export type Sent = + | { readonly kind: "text"; readonly text: string } + | { readonly kind: "binary"; readonly size: number; readonly envelope: Envelope }; + +export class TappedTransport implements UplinkTransportConnection { + readonly sent: Sent[] = []; + readonly #sentListeners = new Set<() => void>(); + #extraUnsent = 0; + + constructor(readonly inner: UplinkTransportConnection) { + inner.onSent(() => this.fireSent()); + } + + get closed(): boolean { + return this.inner.closed; + } + + get unsentBytes(): number { + return this.inner.unsentBytes + this.#extraUnsent; + } + + setUnsent(bytes: number): void { + this.#extraUnsent = bytes; + } + + fireSent(): void { + for (const listener of this.#sentListeners) listener(); + } + + onData(listener: (chunk: string) => void): () => void { + return this.inner.onData(listener); + } + + onClose(listener: () => void): () => void { + return this.inner.onClose(listener); + } + + onError(listener: (error: Error) => void): () => void { + return this.inner.onError(listener); + } + + onBinary(listener: (bytes: Uint8Array) => void): () => void { + return this.inner.onBinary(listener); + } + + onSent(listener: () => void): () => void { + this.#sentListeners.add(listener); + return () => this.#sentListeners.delete(listener); + } + + write(contents: string): Promise { + this.sent.push({ kind: "text", text: contents }); + return this.inner.write(contents); + } + + writeBinary(bytes: Uint8Array): Promise { + this.sent.push({ kind: "binary", size: bytes.length, envelope: parseEnvelope(bytes) }); + return this.inner.writeBinary(bytes); + } + + close(): Promise { + return this.inner.close(); + } + + binaries(type?: number): Envelope[] { + const out: Envelope[] = []; + for (const entry of this.sent) { + if (entry.kind === "binary" && (type === undefined || entry.envelope.type === type)) { + out.push(entry.envelope); + } + } + return out; + } +} + +export class RecordingLogger implements Logger { + readonly records: { readonly level: string; readonly message: string }[] = []; + debug(message: string): void { + this.records.push({ level: "debug", message }); + } + info(message: string): void { + this.records.push({ level: "info", message }); + } + warn(message: string): void { + this.records.push({ level: "warn", message }); + } + error(message: string): void { + this.records.push({ level: "error", message }); + } + child(): Logger { + return this; + } + problems(): string[] { + return this.records + .filter((record) => record.level === "warn" || record.level === "error") + .map((record) => record.message); + } +} + +export type Outcome = + | { readonly state: "resolved"; readonly value: T } + | { readonly state: "rejected"; readonly error: unknown } + | { readonly state: "pending" }; + +export async function outcome(promise: Promise, ms = 2000): Promise> { + let timer: NodeJS.Timeout | undefined; + const pending = new Promise>((resolve) => { + timer = setTimeout(() => resolve({ state: "pending" }), ms); + }); + try { + return await Promise.race([ + promise.then( + (value): Outcome => ({ state: "resolved", value }), + (error: unknown): Outcome => ({ state: "rejected", error }), + ), + pending, + ]); + } finally { + clearTimeout(timer); + } +} + +export async function settle(): Promise { + for (let i = 0; i < 5; i += 1) await new Promise((resolve) => setImmediate(resolve)); +} + +export interface LinkPair { + readonly clock: FakeClock; + readonly worker: LinkConnection; + readonly gateway: LinkConnection; + readonly workerTap: TappedTransport; + readonly gatewayTap: TappedTransport; + readonly workerEnd: MemoryTransportConnection; + readonly gatewayEnd: MemoryTransportConnection; + readonly workerLog: RecordingLogger; + readonly gatewayLog: RecordingLogger; +} + +export function linkPair(options: { gatewayFirstNumber?: number } = {}): LinkPair { + const clock = new FakeClock(0); + const [workerEnd, gatewayEnd] = createConnectionPair(); + const workerTap = new TappedTransport(workerEnd); + const gatewayTap = new TappedTransport(gatewayEnd); + const workerLog = new RecordingLogger(); + const gatewayLog = new RecordingLogger(); + const worker = createLink(workerTap, { clock, end: "worker", logger: workerLog }); + const gateway = createLink(gatewayTap, { + clock, + end: "gateway", + logger: gatewayLog, + ...(options.gatewayFirstNumber === undefined + ? {} + : { firstChannelNumber: options.gatewayFirstNumber }), + }); + return { + clock, + gateway, + gatewayEnd, + gatewayLog, + gatewayTap, + worker, + workerEnd, + workerLog, + workerTap, + }; +} + +export interface RawPeer { + readonly clock: FakeClock; + readonly link: LinkConnection; + readonly tap: TappedTransport; + readonly raw: MemoryTransportConnection; + readonly log: RecordingLogger; + readonly received: Envelope[]; + readonly receivedText: string[]; + readonly linkText: string[]; +} + +export function linkWithRawPeer(): RawPeer { + const clock = new FakeClock(0); + const [linkEnd, raw] = createConnectionPair(); + const tap = new TappedTransport(linkEnd); + const log = new RecordingLogger(); + const link = createLink(tap, { clock, end: "worker", logger: log }); + const received: Envelope[] = []; + const receivedText: string[] = []; + const linkText: string[] = []; + raw.onBinary((bytes) => received.push(parseEnvelope(bytes))); + raw.onData((chunk) => receivedText.push(chunk)); + link.onData((chunk) => linkText.push(chunk)); + return { clock, link, log, linkText, raw, received, receivedText, tap }; +} + +export function pattern(offset: number, size: number): Uint8Array { + const bytes = new Uint8Array(size); + for (let i = 0; i < size; i += 1) bytes[i] = (offset + i) % 251; + return bytes; +} + +export function patternHash(total: number): string { + const hash = createHash("sha256"); + for (let offset = 0; offset < total; offset += MiB) { + hash.update(pattern(offset, Math.min(MiB, total - offset))); + } + return hash.digest("hex"); +} + +export async function pump( + writable: WritableStream, + total: number, + chunk = 64 * KiB, +): Promise { + const writer = writable.getWriter(); + for (let offset = 0; offset < total; offset += chunk) { + await writer.write(pattern(offset, Math.min(chunk, total - offset))); + } + await writer.close(); +} + +export async function drain( + readable: ReadableStream, +): Promise<{ length: number; hash: string }> { + const reader = readable.getReader(); + const hash = createHash("sha256"); + let length = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + hash.update(value); + length += value.length; + } + return { hash: hash.digest("hex"), length }; +} + +export async function readAll(readable: ReadableStream): Promise { + const reader = readable.getReader(); + const parts: Uint8Array[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); + } + return new Uint8Array(Buffer.concat(parts)); +} + +export async function writeAndClose(channel: LinkChannel, bytes: Uint8Array): Promise { + const writer = channel.writable.getWriter(); + await writer.write(bytes); + await writer.close(); +} + +export async function writeUntilBlocked( + writer: WritableStreamDefaultWriter, + chunk = 64 * KiB, + limit = 400, +): Promise { + let accepted = 0; + for (let i = 0; i < limit; i += 1) { + const ready = await Promise.race([ + writer.ready.then( + () => true, + () => false, + ), + settle().then(() => false), + ]); + if (!ready) break; + writer.write(pattern(accepted, chunk)).catch(() => undefined); + accepted += chunk; + } + return accepted; +} + +export async function endChannel(a: LinkChannel, b: LinkChannel): Promise { + await Promise.all([ + a.writable.getWriter().close(), + b.writable.getWriter().close(), + drain(a.readable), + drain(b.readable), + ]); +} + +export async function errorOfRead(readable: ReadableStream): Promise { + const reader = readable.getReader(); + try { + for (;;) { + const { done } = await reader.read(); + if (done) throw new Error("the readable ended cleanly instead of erroring"); + } + } catch (error: unknown) { + return error; + } +} + +export async function errorOfWritable(writable: WritableStream): Promise { + const writer = writable.getWriter(); + try { + await writer.closed; + } catch (error: unknown) { + return error; + } + throw new Error("the writable closed cleanly instead of erroring"); +} + +export function signal(): AbortSignal { + return new AbortController().signal; +} diff --git a/packages/bridge/src/link/channel.ts b/packages/bridge/src/link/channel.ts new file mode 100644 index 00000000..3c59a7b1 --- /dev/null +++ b/packages/bridge/src/link/channel.ts @@ -0,0 +1,473 @@ +import type { Clock, Logger, TimerHandle } from "./ports.js"; +import { + CHANNEL_WINDOW, + ENVELOPE, + KIB, + MAX_DATA_BYTES, + encodeCredit, + encodeEnvelope, + encodeAscii, + encodeReset, + type ResetReason, +} from "./envelope.js"; +import type { BulkMessage, BulkSource, Scheduler } from "./scheduler.js"; +import { + LinkByteLengthQueuingStrategy, + LinkReadableStream, + LinkWritableStream, +} from "./streams.js"; + +export type LinkChannelErrorReason = + | "aborted" + | "invalid" + | "not-accepted" + | "stalled" + | "closed" + | "stopped"; + +export class LinkChannelError extends Error { + constructor( + readonly reason: LinkChannelErrorReason, + message?: string, + ) { + super(message ?? reason); + this.name = "LinkChannelError"; + } +} + +export interface LinkChannel { + readonly id: string; + readonly readable: ReadableStream; + readonly writable: WritableStream; + abort(reason?: string): void; + readonly credit: number; + whenCredit(): Promise; +} + +export interface ChannelHost { + readonly clock: Clock; + readonly logger: Logger; + readonly scheduler: Scheduler; + unsentBytes(): number; + channelDone(channel: Channel): void; +} + +const ACCEPT_WAIT_MS = 30_000; +const STALL_WAIT_MS = 60_000; +const WRITER_BUFFER = 64 * KIB; + +interface Deferred { + resolve(): void; + reject(error: Error): void; +} + +type QueueItem = { readonly bytes: Uint8Array; readonly payload: number; readonly end: boolean }; + +export class Channel implements BulkSource { + readonly number: number; + readonly id: string; + readonly ownedByUs: boolean; + accepted: boolean; + readonly view: LinkChannel; + readonly #host: ChannelHost; + + #failure: LinkChannelError | undefined; + #done = false; + + #openHanded: boolean; + #queue: QueueItem[] = []; + #queuedBytes = 0; + #allowance = CHANNEL_WINDOW; + #handed = 0; + #inFlight = 0; + #endQueued = false; + #endHanded = false; + #stoppedByPeer = false; + #pendingWrite: Deferred | undefined; + #creditWaiters: Deferred[] = []; + #stallTimer: TimerHandle | undefined; + #acceptTimer: TimerHandle | undefined; + + #received = 0; + #granted = 0; + #readTotal = 0; + #inQueue: Uint8Array[] = []; + #inEnded = false; + #stopSent = false; + #wantRead = false; + #readableController: ReadableStreamDefaultController | undefined; + #writableController: WritableStreamDefaultController | undefined; + + #resetQueued = false; + #resetHanded = false; + #resetReceived = false; + + constructor(host: ChannelHost, number: number, id: string, ownedByUs: boolean) { + this.#host = host; + this.number = number; + this.id = id; + this.ownedByUs = ownedByUs; + this.accepted = ownedByUs; + this.#openHanded = !ownedByUs; + this.view = this.#buildView(); + if (ownedByUs) { + host.scheduler.binary( + encodeEnvelope(ENVELOPE.OPEN, number, encodeAscii(id)), + () => { + this.#openHanded = true; + if (this.hasNext) host.scheduler.addBulk(this); + }, + ); + } else { + this.#acceptTimer = host.clock.setTimer(ACCEPT_WAIT_MS, () => { + this.#acceptTimer = undefined; + this.#fail(new LinkChannelError("not-accepted"), "not-accepted"); + }); + } + } + + get hasNext(): boolean { + return this.#queue.length > 0; + } + + get credit(): number { + if (this.#failure !== undefined || this.#endQueued || this.#stoppedByPeer) return 0; + return Math.max(0, this.#allowance - this.#handed - this.#queuedBytes); + } + + markAccepted(): void { + this.accepted = true; + if (this.#acceptTimer !== undefined) { + this.#host.clock.cancel(this.#acceptTimer); + this.#acceptTimer = undefined; + } + } + + takeNext(): BulkMessage | undefined { + const item = this.#queue.shift(); + if (item === undefined) return undefined; + return { + bytes: item.bytes, + onHanded: () => { + if (item.end) { + this.#endHanded = true; + this.#checkDone(); + return; + } + this.#queuedBytes -= item.payload; + this.#handed += item.payload; + this.#inFlight += 1; + this.#updateStall(); + }, + onWritten: () => { + if (item.end) return; + this.#inFlight -= 1; + this.#updateStall(); + }, + }; + } + + transportMoved(): void { + this.#updateStall(); + } + + onData(payload: Uint8Array): void { + if (this.#failure !== undefined) return; + if (this.#inEnded) return this.#invalid("DATA after the direction ended"); + if (this.#stopSent) return; + if (payload.length > MAX_DATA_BYTES) return this.#invalid("DATA over 64 KiB"); + if (this.#received + payload.length > CHANNEL_WINDOW + this.#granted) { + return this.#invalid("DATA past the allowed bytes"); + } + if (payload.length === 0) return; + this.#received += payload.length; + this.#inQueue.push(payload); + this.#deliver(); + } + + onEnd(): void { + if (this.#failure !== undefined) { + // why: the peer's END still ends its direction after this side failed; the slot frees once both are done. + this.#inEnded = true; + this.#checkDone(); + return; + } + if (this.#inEnded) return this.#invalid("END after the direction ended"); + this.#inEnded = true; + this.#deliver(); + this.#checkDone(); + } + + onStop(): void { + if (this.#failure !== undefined) return; + if (this.#stoppedByPeer) return this.#invalid("a second STOP for one direction"); + this.#dropQueuedData(); + if (this.#endQueued) return; + this.#stoppedByPeer = true; + this.#queueEnd(); + this.#failOutgoing(new LinkChannelError("stopped")); + } + + onCredit(amount: number | undefined): void { + if (this.#failure !== undefined) return; + if (amount === undefined || amount < 1) return this.#invalid("a CREDIT of 0"); + const taken = this.#handed + this.#queuedBytes; + if (this.#allowance + amount > taken + CHANNEL_WINDOW) { + return this.#invalid("credit beyond 4 MiB outstanding"); + } + this.#allowance += amount; + this.#wakeWriters(); + } + + onReset(reason: ResetReason | undefined): void { + this.#resetReceived = true; + if (this.#failure === undefined) { + this.#fail( + new LinkChannelError(reason ?? "aborted"), + this.#resetQueued ? undefined : "aborted", + ); + } + this.#checkDone(); + } + + closeWith(error: LinkChannelError): void { + this.#resetQueued = true; + this.#done = true; + this.#fail(error, undefined); + } + + abort(reason?: string): void { + if (this.#failure !== undefined) return; + this.#fail(new LinkChannelError("aborted", reason), "aborted"); + } + + #invalid(rule: string): void { + this.#host.logger.warn(`link: channel ${this.id} broke a rule: ${rule}`); + this.#fail(new LinkChannelError("invalid", rule), "invalid"); + } + + #fail(error: LinkChannelError, send: ResetReason | undefined): void { + if (this.#failure !== undefined) return; + this.#failure = error; + this.#clearTimers(); + this.#host.scheduler.removeBulk(this); + this.#queue = []; + this.#queuedBytes = 0; + this.#inQueue = []; + this.#wantRead = false; + this.#failOutgoing(error); + try { + this.#readableController?.error(error); + } catch { + // why: the readable may already be closed or cancelled, and a stream cannot be errored twice. + } + if (send !== undefined && !this.#resetQueued) { + this.#resetQueued = true; + this.#host.scheduler.binary(encodeReset(this.number, send), () => { + this.#resetHanded = true; + this.#checkDone(); + }); + } + this.#checkDone(); + } + + #failOutgoing(error: LinkChannelError): void { + this.#clearStall(); + const pending = this.#pendingWrite; + this.#pendingWrite = undefined; + pending?.reject(error); + for (const waiter of this.#creditWaiters.splice(0)) waiter.reject(error); + try { + this.#writableController?.error(error); + } catch { + // why: the writable may already be closed or errored. + } + } + + #checkDone(): void { + if (this.#done) return; + const ours = this.#resetHanded || this.#endHanded; + const theirs = this.#resetReceived || this.#inEnded; + if (!ours || !theirs) return; + this.#done = true; + this.#clearTimers(); + this.#host.scheduler.removeBulk(this); + this.#host.channelDone(this); + } + + #clearTimers(): void { + this.#clearStall(); + if (this.#acceptTimer !== undefined) { + this.#host.clock.cancel(this.#acceptTimer); + this.#acceptTimer = undefined; + } + } + + #dropQueuedData(): void { + this.#queue = this.#queue.filter((item) => item.end); + this.#queuedBytes = 0; + if (this.#queue.length === 0) this.#host.scheduler.removeBulk(this); + } + + #queueEnd(): void { + this.#endQueued = true; + this.#queue.push({ bytes: encodeEnvelope(ENVELOPE.END, this.number), end: true, payload: 0 }); + if (this.#openHanded) this.#host.scheduler.addBulk(this); + } + + #take(chunk: Uint8Array): void { + for (let offset = 0; offset < chunk.length; offset += MAX_DATA_BYTES) { + const piece = chunk.subarray(offset, Math.min(chunk.length, offset + MAX_DATA_BYTES)); + this.#queue.push({ + bytes: encodeEnvelope(ENVELOPE.DATA, this.number, piece), + end: false, + payload: piece.length, + }); + this.#queuedBytes += piece.length; + } + if (this.#openHanded) this.#host.scheduler.addBulk(this); + } + + #wakeWriters(): void { + const pending = this.#pendingWrite; + this.#pendingWrite = undefined; + pending?.resolve(); + for (const waiter of this.#creditWaiters.splice(0)) waiter.resolve(); + this.#updateStall(); + } + + #updateStall(): void { + const waiting = this.#pendingWrite !== undefined || this.#creditWaiters.length > 0; + const peerHoldsEverything = + this.#queuedBytes === 0 && (this.#host.unsentBytes() === 0 || this.#inFlight === 0); + if (!waiting || !peerHoldsEverything || this.#failure !== undefined) { + this.#clearStall(); + return; + } + if (this.#stallTimer !== undefined) return; + this.#stallTimer = this.#host.clock.setTimer(STALL_WAIT_MS, () => { + this.#stallTimer = undefined; + this.#fail(new LinkChannelError("stalled"), "stalled"); + }); + } + + #clearStall(): void { + if (this.#stallTimer === undefined) return; + this.#host.clock.cancel(this.#stallTimer); + this.#stallTimer = undefined; + } + + #deliver(): void { + const controller = this.#readableController; + if (controller === undefined || !this.#wantRead || this.#stopSent) return; + const chunk = this.#inQueue.shift(); + if (chunk !== undefined) { + this.#wantRead = false; + this.#readTotal += chunk.length; + controller.enqueue(chunk); + this.#grant(); + return; + } + if (this.#inEnded) { + this.#wantRead = false; + controller.close(); + } + } + + #grant(): void { + const unread = this.#readTotal - this.#granted; + if (unread <= 0 || this.#inEnded) return; + if (unread < 64 * KIB && this.#inQueue.length > 0) return; + this.#granted += unread; + this.#host.scheduler.binary(encodeCredit(this.number, unread)); + } + + #cancelReadable(): void { + if (this.#stopSent || this.#failure !== undefined) return; + this.#inQueue = []; + this.#wantRead = false; + if (this.#inEnded) return; + this.#stopSent = true; + this.#host.scheduler.binary(encodeEnvelope(ENVELOPE.STOP, this.number)); + } + + #buildView(): LinkChannel { + const readable = new LinkReadableStream( + { + start: (controller) => { + this.#readableController = controller; + }, + pull: () => { + this.#wantRead = true; + this.#deliver(); + }, + cancel: () => this.#cancelReadable(), + }, + { highWaterMark: 0 }, + ); + const writable = new LinkWritableStream( + { + start: (controller) => { + this.#writableController = controller; + }, + write: (chunk) => this.#write(chunk), + close: () => { + if (this.#failure !== undefined) throw this.#failure; + if (this.#endQueued) return; + this.#queueEnd(); + const error = new LinkChannelError("closed", "the channel's sending direction ended"); + for (const waiter of this.#creditWaiters.splice(0)) waiter.reject(error); + this.#updateStall(); + }, + abort: (reason) => { + this.abort(reason === undefined ? undefined : String(reason)); + }, + }, + new LinkByteLengthQueuingStrategy({ highWaterMark: WRITER_BUFFER }), + ); + const currentCredit = () => this.credit; + return { + abort: (reason) => this.abort(reason), + get credit() { + return currentCredit(); + }, + id: this.id, + readable, + whenCredit: () => this.#whenCredit(), + writable, + }; + } + + async #write(chunk: Uint8Array): Promise { + let offset = 0; + while (offset < chunk.length) { + if (this.#failure !== undefined) throw this.#failure; + if (this.#stoppedByPeer) throw new LinkChannelError("stopped"); + const n = Math.min(this.credit, chunk.length - offset); + if (n > 0) { + this.#take(chunk.subarray(offset, offset + n)); + offset += n; + continue; + } + await new Promise((resolve, reject) => { + this.#pendingWrite = { reject, resolve }; + this.#updateStall(); + }); + } + } + + #whenCredit(): Promise { + if (this.#failure !== undefined) return Promise.reject(this.#failure); + if (this.#stoppedByPeer) return Promise.reject(new LinkChannelError("stopped")); + if (this.#endQueued) { + return Promise.reject( + new LinkChannelError("closed", "the channel's sending direction ended"), + ); + } + if (this.credit > 0) return Promise.resolve(); + return new Promise((resolve, reject) => { + this.#creditWaiters.push({ reject, resolve }); + this.#updateStall(); + }); + } +} diff --git a/packages/bridge/src/link/envelope.ts b/packages/bridge/src/link/envelope.ts new file mode 100644 index 00000000..4c68781e --- /dev/null +++ b/packages/bridge/src/link/envelope.ts @@ -0,0 +1,110 @@ +export const KIB = 1024; +export const MAX_DATA_BYTES = 64 * KIB; +export const CHANNEL_WINDOW = 4 * KIB * KIB; +export const MAX_CHANNEL_NUMBER = 0xffffffff; + +export const ENVELOPE = { OPEN: 1, DATA: 2, END: 3, STOP: 4, CREDIT: 5, RESET: 6 } as const; +export type EnvelopeType = (typeof ENVELOPE)[keyof typeof ENVELOPE]; + +const RESET_CODES = { aborted: 1, stalled: 2, "not-accepted": 3, invalid: 4 } as const; +export type ResetReason = keyof typeof RESET_CODES; + +const HEADER_BYTES = 5; +const CHANNEL_ID = /^[A-Za-z0-9-]{1,32}$/; + +export interface ParsedEnvelope { + readonly type: number; + readonly number: number; + readonly payload: Uint8Array; +} + +export function encodeEnvelope( + type: EnvelopeType, + number: number, + payload: Uint8Array = new Uint8Array(0), +): Uint8Array { + const bytes = new Uint8Array(HEADER_BYTES + payload.length); + bytes[0] = type; + new DataView(bytes.buffer).setUint32(1, number); + bytes.set(payload, HEADER_BYTES); + return bytes; +} + +export function encodeCredit(number: number, amount: number): Uint8Array { + const payload = new Uint8Array(4); + new DataView(payload.buffer).setUint32(0, amount); + return encodeEnvelope(ENVELOPE.CREDIT, number, payload); +} + +export function encodeReset(number: number, reason: ResetReason): Uint8Array { + return encodeEnvelope(ENVELOPE.RESET, number, new Uint8Array([RESET_CODES[reason]])); +} + +export function parseEnvelope(bytes: Uint8Array): ParsedEnvelope | undefined { + if (bytes.length < HEADER_BYTES) return undefined; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { number: view.getUint32(1), payload: bytes.slice(HEADER_BYTES), type: bytes[0] ?? 0 }; +} + +export function isEnvelopeType(type: number): type is EnvelopeType { + return type >= ENVELOPE.OPEN && type <= ENVELOPE.RESET; +} + +export function parseCredit(payload: Uint8Array): number | undefined { + if (payload.length !== 4) return undefined; + return new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getUint32(0); +} + +export function parseResetReason(payload: Uint8Array): ResetReason | undefined { + const code = payload[0]; + for (const [reason, value] of Object.entries(RESET_CODES)) { + if (value === code) return reason as ResetReason; + } + return undefined; +} + +// why: counted the way Buffer.byteLength counts, so the link needs no Buffer (a browser has none). +export function utf8Length(text: string): number { + let bytes = 0; + for (let at = 0; at < text.length; at += 1) { + const code = text.charCodeAt(at); + if (code < 0x80) { + bytes += 1; + } else if (code < 0x800) { + bytes += 2; + } else if (code >= 0xd800 && code <= 0xdbff && isLowSurrogate(text.charCodeAt(at + 1))) { + bytes += 4; + at += 1; + } else { + bytes += 3; + } + } + return bytes; +} + +function isLowSurrogate(code: number): boolean { + return code >= 0xdc00 && code <= 0xdfff; +} + +export function isChannelId(id: string): boolean { + return CHANNEL_ID.test(id); +} + +export function parseChannelId(payload: Uint8Array): string | undefined { + const id = decodeAscii(payload); + return isChannelId(id) ? id : undefined; +} + +// harness: channel ids are ASCII, so the link needs no TextEncoder or TextDecoder (not every +// Hermes has TextDecoder). A byte over 0x7f decodes to a character the id pattern refuses. +export function encodeAscii(text: string): Uint8Array { + const bytes = new Uint8Array(text.length); + for (let at = 0; at < text.length; at += 1) bytes[at] = text.charCodeAt(at) & 0xff; + return bytes; +} + +function decodeAscii(bytes: Uint8Array): string { + let text = ""; + for (let at = 0; at < bytes.length; at += 1) text += String.fromCharCode(bytes[at] ?? 0); + return text; +} diff --git a/packages/bridge/src/link/index.ts b/packages/bridge/src/link/index.ts new file mode 100644 index 00000000..396fd071 --- /dev/null +++ b/packages/bridge/src/link/index.ts @@ -0,0 +1,321 @@ +import type { Clock, IpcConnection, Logger, UplinkTransportConnection } from "./ports.js"; +import { Channel, LinkChannelError, type ChannelHost, type LinkChannel } from "./channel.js"; +import { + ENVELOPE, + MAX_CHANNEL_NUMBER, + isChannelId, + isEnvelopeType, + parseChannelId, + parseCredit, + parseEnvelope, + parseResetReason, + type ParsedEnvelope, +} from "./envelope.js"; +import { Scheduler } from "./scheduler.js"; + +export { LinkChannelError } from "./channel.js"; +export type { LinkChannel, LinkChannelErrorReason } from "./channel.js"; +export { globalClock } from "./ports.js"; +export type { + Clock, + IpcConnection, + Logger, + TimerHandle, + UplinkTransportConnection, +} from "./ports.js"; +export { + connectStandardWebSocket, + StandardWebSocketConnection, + type StandardWebSocket, + type StandardWebSocketDialOptions, + type StandardWebSocketOptions, +} from "./standard-websocket.js"; + +export interface LinkConnection extends IpcConnection { + openChannel(options: { readonly signal: AbortSignal }): Promise; + acceptChannel(id: string): LinkChannel; +} + +export interface LinkOptions { + readonly end: "worker" | "gateway"; + readonly clock: Clock; + readonly logger: Logger; + readonly firstChannelNumber?: number; + readonly generateId?: () => string; +} + +const OPEN_LIMIT = 64; + +interface OpenWaiter { + readonly resolve: (channel: LinkChannel) => void; + readonly reject: (error: unknown) => void; + readonly signal: AbortSignal; + readonly onAbort: () => void; +} + +class Link implements LinkConnection, ChannelHost { + readonly clock: Clock; + readonly logger: Logger; + readonly scheduler: Scheduler; + readonly #transport: UplinkTransportConnection; + readonly #ownParity: number; + readonly #generateId: () => string; + readonly #channels = new Map(); + readonly #unaccepted = new Map(); + readonly #waiters: OpenWaiter[] = []; + readonly #dataListeners = new Set<(chunk: string) => void>(); + readonly #closeListeners = new Set<() => void>(); + readonly #errorListeners = new Set<(error: Error) => void>(); + readonly #pendingText: string[] = []; + #nextOwn: number; + #highestPeer = 0; + #ownOpen = 0; + #peerOpen = 0; + #closed = false; + + constructor(transport: UplinkTransportConnection, options: LinkOptions) { + this.clock = options.clock; + this.logger = options.logger; + this.#transport = transport; + this.#ownParity = options.end === "worker" ? 1 : 0; + this.#generateId = options.generateId ?? randomId; + this.#nextOwn = options.firstChannelNumber ?? (options.end === "worker" ? 1 : 2); + this.scheduler = new Scheduler(transport); + transport.onData((chunk) => this.#onText(chunk)); + transport.onBinary((bytes) => this.#onBinary(bytes)); + transport.onError((error) => { + for (const listener of this.#errorListeners) listener(error); + }); + transport.onSent(() => { + for (const channel of this.#channels.values()) channel.transportMoved(); + }); + transport.onClose(() => this.#onTransportClosed()); + } + + get closed(): boolean { + return this.#closed || this.#transport.closed; + } + + unsentBytes(): number { + return this.#transport.unsentBytes; + } + + onData(listener: (chunk: string) => void): () => void { + this.#dataListeners.add(listener); + if (this.#pendingText.length > 0) { + for (const chunk of this.#pendingText.splice(0)) listener(chunk); + } + return () => { + this.#dataListeners.delete(listener); + }; + } + + onClose(listener: () => void): () => void { + if (this.#closed) { + listener(); + return () => undefined; + } + this.#closeListeners.add(listener); + return () => { + this.#closeListeners.delete(listener); + }; + } + + onError(listener: (error: Error) => void): () => void { + this.#errorListeners.add(listener); + return () => { + this.#errorListeners.delete(listener); + }; + } + + write(contents: string): Promise { + return this.scheduler.text(contents); + } + + async close(): Promise { + await this.#transport.close(); + this.#onTransportClosed(); + } + + openChannel(options: { readonly signal: AbortSignal }): Promise { + const { signal } = options; + if (this.#closed) return Promise.reject(new LinkChannelError("closed")); + if (signal.aborted) return Promise.reject(signal.reason); + if (this.#ownOpen < OPEN_LIMIT && this.#waiters.length === 0) { + try { + return Promise.resolve(this.#open().view); + } catch (error: unknown) { + return Promise.reject(error); + } + } + return new Promise((resolve, reject) => { + const onAbort = () => { + const at = this.#waiters.findIndex((waiter) => waiter.onAbort === onAbort); + if (at >= 0) this.#waiters.splice(at, 1); + reject(signal.reason); + }; + signal.addEventListener("abort", onAbort, { once: true }); + this.#waiters.push({ onAbort, reject, resolve, signal }); + }); + } + + acceptChannel(id: string): LinkChannel { + const channel = this.#unaccepted.get(id); + if (channel === undefined) { + throw new Error(`channel ${id} was not opened by the peer, or was already accepted or ended`); + } + this.#unaccepted.delete(id); + channel.markAccepted(); + return channel.view; + } + + channelDone(channel: Channel): void { + if (this.#channels.get(channel.number) !== channel) return; + this.#forget(channel); + this.#drainWaiters(); + } + + #forget(channel: Channel): void { + this.#channels.delete(channel.number); + if (this.#unaccepted.get(channel.id) === channel) this.#unaccepted.delete(channel.id); + if (channel.ownedByUs) this.#ownOpen -= 1; + else this.#peerOpen -= 1; + } + + #open(): Channel { + if (this.#nextOwn > MAX_CHANNEL_NUMBER) { + this.logger.warn("link: this side has used every channel number on the connection"); + throw new Error("the link has no channel numbers left on this connection"); + } + let id = this.#newId(); + while (this.#channels.size > 0 && [...this.#channels.values()].some((c) => c.id === id)) { + id = this.#newId(); + } + const number = this.#nextOwn; + this.#nextOwn += 2; + const channel = new Channel(this, number, id, true); + this.#channels.set(number, channel); + this.#ownOpen += 1; + return channel; + } + + #newId(): string { + const id = this.#generateId(); + if (!isChannelId(id)) { + throw new Error("link: `generateId` must make ids of 1 to 32 characters from [A-Za-z0-9-]"); + } + return id; + } + + #drainWaiters(): void { + while (this.#waiters.length > 0 && this.#ownOpen < OPEN_LIMIT && !this.#closed) { + const waiter = this.#waiters.shift(); + if (waiter === undefined) break; + waiter.signal.removeEventListener("abort", waiter.onAbort); + try { + waiter.resolve(this.#open().view); + } catch (error: unknown) { + waiter.reject(error); + } + } + } + + #onText(chunk: string): void { + if (this.#dataListeners.size === 0) { + this.#pendingText.push(chunk); + return; + } + for (const listener of this.#dataListeners) listener(chunk); + } + + #onTransportClosed(): void { + if (this.#closed) return; + this.#closed = true; + this.scheduler.close(); + for (const waiter of this.#waiters.splice(0)) { + waiter.signal.removeEventListener("abort", waiter.onAbort); + waiter.reject(new LinkChannelError("closed")); + } + const error = new LinkChannelError("closed"); + for (const channel of this.#channels.values()) channel.closeWith(error); + this.#channels.clear(); + this.#unaccepted.clear(); + for (const listener of this.#closeListeners) listener(); + } + + #warn(rule: string): void { + this.logger.warn(`link: dropped a message from the peer: ${rule}`); + } + + #onBinary(bytes: Uint8Array): void { + const envelope = parseEnvelope(bytes); + if (envelope === undefined) return this.#warn("a message too short to read"); + if (!isEnvelopeType(envelope.type)) return this.#warn("an unknown envelope type"); + if (envelope.type === ENVELOPE.OPEN) return this.#onOpen(envelope.number, envelope.payload); + const channel = this.#channels.get(envelope.number); + if (channel === undefined) { + if (!this.#isLate(envelope.number)) this.#warn("a channel number the peer never opened"); + return; + } + this.#dispatch(channel, envelope); + } + + #dispatch(channel: Channel, envelope: ParsedEnvelope): void { + switch (envelope.type) { + case ENVELOPE.DATA: + return channel.onData(envelope.payload); + case ENVELOPE.END: + return channel.onEnd(); + case ENVELOPE.STOP: + return channel.onStop(); + case ENVELOPE.CREDIT: + return channel.onCredit(parseCredit(envelope.payload)); + case ENVELOPE.RESET: + return channel.onReset(parseResetReason(envelope.payload)); + } + } + + #isLate(number: number): boolean { + if (number < 1) return false; + if (number % 2 === this.#ownParity) return number < this.#nextOwn; + return number <= this.#highestPeer; + } + + #onOpen(number: number, payload: Uint8Array): void { + if (number % 2 === this.#ownParity || number < 1) + return this.#warn("an OPEN with the wrong parity"); + if (number <= this.#highestPeer) { + return this.#warn( + this.#channels.has(number) + ? "an OPEN reusing a live number" + : "an OPEN reusing an ended number", + ); + } + if (this.#peerOpen >= OPEN_LIMIT) return this.#warn("a 65th OPEN from the peer"); + const id = parseChannelId(payload); + if (id === undefined) return this.#warn("an OPEN with an id outside [A-Za-z0-9-]{1,32}"); + if (this.#unaccepted.has(id)) return this.#warn("an OPEN reusing an id not yet accepted"); + this.#highestPeer = number; + const channel = new Channel(this, number, id, false); + this.#channels.set(number, channel); + this.#unaccepted.set(id, channel); + this.#peerOpen += 1; + } +} + +function randomId(): string { + const source = globalThis.crypto as Crypto | undefined; + if (typeof source?.randomUUID === "function") return source.randomUUID().replace(/-/g, ""); + if (typeof source?.getRandomValues === "function") { + const bytes = source.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + } + throw new Error("link: this runtime has no crypto to make channel ids; pass `generateId`"); +} + +export function createLink( + transport: UplinkTransportConnection, + options: LinkOptions, +): LinkConnection { + return new Link(transport, options); +} diff --git a/packages/bridge/src/link/ports.ts b/packages/bridge/src/link/ports.ts new file mode 100644 index 00000000..7cc1d897 --- /dev/null +++ b/packages/bridge/src/link/ports.ts @@ -0,0 +1,42 @@ +// The parts of simlock's `src/ports` the link uses. Types only, plus `globalClock`, +// so the link runs unchanged in Node, a browser, and React Native. + +export interface TimerHandle { + readonly _timerHandle?: never; +} + +export interface Clock { + now(): number; + setTimer(delayMs: number, callback: () => void): TimerHandle; + cancel(handle: TimerHandle): void; +} + +export const globalClock: Clock = { + cancel: (handle) => clearTimeout(handle as unknown as ReturnType), + now: () => Date.now(), + setTimer: (delayMs, callback) => setTimeout(callback, delayMs) as unknown as TimerHandle, +}; + +export interface Logger { + debug(message: string, fields?: Record): void; + info(message: string, fields?: Record): void; + warn(message: string, fields?: Record): void; + error(message: string, fields?: Record): void; + child(module: string): Logger; +} + +export interface IpcConnection { + readonly closed: boolean; + onData(listener: (chunk: string) => void): () => void; + onClose(listener: () => void): () => void; + onError(listener: (error: Error) => void): () => void; + write(contents: string): Promise; + close(): Promise; +} + +export interface UplinkTransportConnection extends IpcConnection { + writeBinary(bytes: Uint8Array): Promise; + onBinary(listener: (bytes: Uint8Array) => void): () => void; + readonly unsentBytes: number; + onSent(listener: () => void): () => void; +} diff --git a/packages/bridge/src/link/scheduler.ts b/packages/bridge/src/link/scheduler.ts new file mode 100644 index 00000000..040e701f --- /dev/null +++ b/packages/bridge/src/link/scheduler.ts @@ -0,0 +1,132 @@ +import type { UplinkTransportConnection } from "./ports.js"; +import { KIB, utf8Length } from "./envelope.js"; + +const UNSENT_MARK = 256 * KIB; +const PRIORITY_SHARE = 64 * KIB; + +export interface BulkMessage { + readonly bytes: Uint8Array; + onHanded(): void; + onWritten(): void; +} + +export interface BulkSource { + takeNext(): BulkMessage | undefined; + readonly hasNext: boolean; +} + +type PriorityMessage = + | { readonly kind: "text"; readonly text: string; readonly resolve: () => void } + | { readonly kind: "binary"; readonly bytes: Uint8Array; readonly onHanded?: () => void }; + +export class Scheduler { + readonly #transport: UplinkTransportConnection; + readonly #priority: PriorityMessage[] = []; + readonly #ring: BulkSource[] = []; + readonly #inRing = new Set(); + #prioritySinceBulk = 0; + #pumping = false; + #scheduled = false; + #closed = false; + + constructor(transport: UplinkTransportConnection) { + this.#transport = transport; + transport.onSent(() => this.wake()); + } + + text(text: string): Promise { + if (this.#closed) return Promise.resolve(); + return new Promise((resolve) => { + this.#priority.push({ kind: "text", resolve, text }); + this.wake(); + }); + } + + binary(bytes: Uint8Array, onHanded?: () => void): void { + if (this.#closed) return; + this.#priority.push( + onHanded === undefined ? { bytes, kind: "binary" } : { bytes, kind: "binary", onHanded }, + ); + this.wake(); + } + + addBulk(source: BulkSource): void { + if (this.#inRing.has(source)) return; + this.#inRing.add(source); + this.#ring.push(source); + this.wake(); + } + + removeBulk(source: BulkSource): void { + if (!this.#inRing.delete(source)) return; + this.#ring.splice(this.#ring.indexOf(source), 1); + } + + wake(): void { + if (this.#scheduled || this.#closed) return; + this.#scheduled = true; + queueMicrotask(() => { + this.#scheduled = false; + this.#pump(); + }); + } + + close(): void { + this.#closed = true; + for (const message of this.#priority.splice(0)) { + if (message.kind === "text") message.resolve(); + } + this.#ring.length = 0; + this.#inRing.clear(); + } + + #pump(): void { + if (this.#pumping) return; + this.#pumping = true; + try { + while (!this.#closed) { + const canBulk = this.#ring.length > 0 && this.#transport.unsentBytes < UNSENT_MARK; + const bulkDue = canBulk && this.#prioritySinceBulk >= PRIORITY_SHARE; + if (this.#priority.length > 0 && !bulkDue) { + this.#sendPriority(); + } else if (canBulk) { + this.#sendBulk(); + } else { + break; + } + } + } finally { + this.#pumping = false; + } + } + + #sendPriority(): void { + const message = this.#priority.shift(); + if (message === undefined) return; + if (message.kind === "text") { + this.#prioritySinceBulk += utf8Length(message.text); + const done = () => message.resolve(); + this.#transport.write(message.text).then(done, done); + return; + } + this.#prioritySinceBulk += message.bytes.length; + message.onHanded?.(); + this.#transport.writeBinary(message.bytes).catch(() => undefined); + } + + #sendBulk(): void { + const source = this.#ring.shift(); + if (source === undefined) return; + const message = source.takeNext(); + if (source.hasNext) { + this.#ring.push(source); + } else { + this.#inRing.delete(source); + } + if (message === undefined) return; + this.#prioritySinceBulk = 0; + message.onHanded(); + const written = () => message.onWritten(); + this.#transport.writeBinary(message.bytes).then(written, written); + } +} diff --git a/packages/bridge/src/link/standard-websocket.ts b/packages/bridge/src/link/standard-websocket.ts new file mode 100644 index 00000000..00f94e8c --- /dev/null +++ b/packages/bridge/src/link/standard-websocket.ts @@ -0,0 +1,251 @@ +import { globalClock, type Clock, type TimerHandle, type UplinkTransportConnection } from "./ports.js"; + +export { globalClock } from "./ports.js"; + +export interface StandardWebSocket { + binaryType: string; + // harness: React Native declares bufferedAmount but never sets it. + readonly bufferedAmount: number | undefined; + readonly readyState: number; + send(data: string | ArrayBufferView): void; + close(code?: number, reason?: string): void; + addEventListener(type: "open" | "close" | "error" | "message", listener: never): void; + removeEventListener(type: "open" | "close" | "error" | "message", listener: never): void; +} + +const OPEN = 1; + +export interface StandardWebSocketOptions { + readonly clock?: Clock; + readonly pollMs?: number; +} + +const DEFAULT_POLL_MS = 4; + +interface MessageLike { + readonly data: unknown; +} + +interface Waiter { + readonly target: number; + readonly resolve: () => void; +} + +export class StandardWebSocketConnection implements UplinkTransportConnection { + readonly #socket: StandardWebSocket; + readonly #clock: Clock; + readonly #pollMs: number; + readonly #dataListeners = new Set<(chunk: string) => void>(); + readonly #binaryListeners = new Set<(bytes: Uint8Array) => void>(); + readonly #closeListeners = new Set<() => void>(); + readonly #errorListeners = new Set<(error: Error) => void>(); + readonly #sentListeners = new Set<() => void>(); + readonly #pending: string[] = []; + #waiters: Waiter[] = []; + #queued = 0; + #flushed = 0; + #checkScheduled = false; + #timer: TimerHandle | undefined; + #closed = false; + + constructor(socket: StandardWebSocket, options: StandardWebSocketOptions = {}) { + this.#socket = socket; + this.#clock = options.clock ?? globalClock; + this.#pollMs = options.pollMs ?? DEFAULT_POLL_MS; + // why: the default, "blob", cannot be read without awaiting, which would reorder messages. + socket.binaryType = "arraybuffer"; + const onMessage = (event: MessageLike) => { + this.#onMessage(event.data); + }; + const onClose = () => this.#markClosed(); + const onError = (event: { readonly error?: unknown }) => { + const error = event.error instanceof Error ? event.error : new Error("WebSocket error"); + for (const listener of this.#errorListeners) listener(error); + this.#markClosed(); + }; + socket.addEventListener("message", onMessage as never); + socket.addEventListener("close", onClose as never); + socket.addEventListener("error", onError as never); + } + + get closed(): boolean { + return this.#closed; + } + + get #buffered(): number { + return this.#socket.bufferedAmount ?? 0; + } + + get unsentBytes(): number { + return this.#buffered; + } + + onData(listener: (chunk: string) => void): () => void { + this.#dataListeners.add(listener); + if (this.#pending.length > 0) { + for (const chunk of this.#pending.splice(0)) listener(chunk); + } + return () => { + this.#dataListeners.delete(listener); + }; + } + + onBinary(listener: (bytes: Uint8Array) => void): () => void { + this.#binaryListeners.add(listener); + return () => { + this.#binaryListeners.delete(listener); + }; + } + + onClose(listener: () => void): () => void { + if (this.#closed) { + listener(); + return () => undefined; + } + this.#closeListeners.add(listener); + return () => { + this.#closeListeners.delete(listener); + }; + } + + onError(listener: (error: Error) => void): () => void { + this.#errorListeners.add(listener); + return () => { + this.#errorListeners.delete(listener); + }; + } + + onSent(listener: () => void): () => void { + this.#sentListeners.add(listener); + return () => { + this.#sentListeners.delete(listener); + }; + } + + write(contents: string): Promise { + return this.#send(contents); + } + + writeBinary(bytes: Uint8Array): Promise { + return this.#send(bytes); + } + + async close(): Promise { + if (this.#closed) return; + this.#socket.close(); + this.#markClosed(); + } + + #send(data: string | Uint8Array): Promise { + if (this.#closed || this.#socket.readyState !== OPEN) return Promise.resolve(); + // why: the standard send() has no completion callback, so what has left the buffer is worked out from bufferedAmount. + const before = this.#buffered; + this.#socket.send(data); + this.#queued += Math.max(0, this.#buffered - before); + return new Promise((resolve) => { + this.#waiters.push({ resolve, target: this.#queued }); + this.#scheduleCheck(); + }); + } + + #onMessage(data: unknown): void { + if (typeof data === "string") { + if (this.#dataListeners.size === 0) { + this.#pending.push(data); + } else { + for (const listener of this.#dataListeners) listener(data); + } + } else if (data instanceof ArrayBuffer) { + const bytes = new Uint8Array(data); + for (const listener of this.#binaryListeners) listener(bytes); + } else if (ArrayBuffer.isView(data)) { + const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + for (const listener of this.#binaryListeners) listener(bytes); + } + // why: a peer's message, a credit most of all, says bytes are moving, and is a cheaper look than a timer. + if (this.#waiters.length > 0 || this.#flushed < this.#queued) this.#check(); + } + + #scheduleCheck(): void { + if (this.#checkScheduled) return; + // why: a write must not settle, or report sent, before the call that made it has returned. + this.#checkScheduled = true; + void Promise.resolve().then(() => { + this.#checkScheduled = false; + this.#check(); + }); + } + + #check(): void { + if (this.#closed) return; + const buffered = this.#buffered; + const flushed = Math.max(this.#flushed, this.#queued - buffered); + const moved = flushed > this.#flushed; + this.#flushed = flushed; + if (this.#waiters.some((waiter) => waiter.target <= flushed)) { + const due = this.#waiters.filter((waiter) => waiter.target <= flushed); + this.#waiters = this.#waiters.filter((waiter) => waiter.target > flushed); + for (const waiter of due) waiter.resolve(); + } + if (moved) for (const listener of this.#sentListeners) listener(); + if (buffered === 0 && this.#timer !== undefined) { + this.#clock.cancel(this.#timer); + this.#timer = undefined; + } + // why: a background tab throttles timers to about one a second, so a large transfer there slows until the tab is shown again. + if (buffered > 0 && this.#timer === undefined) { + this.#timer = this.#clock.setTimer(this.#pollMs, () => { + this.#timer = undefined; + this.#check(); + }); + } + } + + #markClosed(): void { + if (this.#closed) return; + this.#closed = true; + if (this.#timer !== undefined) { + this.#clock.cancel(this.#timer); + this.#timer = undefined; + } + for (const waiter of this.#waiters.splice(0)) waiter.resolve(); + for (const listener of this.#closeListeners) listener(); + } +} + +export interface StandardWebSocketDialOptions extends StandardWebSocketOptions { + readonly url: string; + readonly protocols?: string | string[]; + readonly createSocket?: (url: string, protocols?: string | string[]) => StandardWebSocket; +} + +export async function connectStandardWebSocket( + options: StandardWebSocketDialOptions, +): Promise { + const create = + options.createSocket ?? + ((url: string, protocols?: string | string[]) => + new WebSocket(url, protocols) as unknown as StandardWebSocket); + const socket = create(options.url, options.protocols); + const connection = new StandardWebSocketConnection(socket, options); + await new Promise((resolve, reject) => { + if (socket.readyState === OPEN) return resolve(); + const onOpen = () => { + cleanup(); + resolve(); + }; + const onFail = () => { + cleanup(); + reject(new Error(`could not open a WebSocket to ${options.url}`)); + }; + const cleanup = () => { + socket.removeEventListener("open", onOpen as never); + socket.removeEventListener("error", onFail as never); + socket.removeEventListener("close", onFail as never); + }; + socket.addEventListener("open", onOpen as never); + socket.addEventListener("error", onFail as never); + socket.addEventListener("close", onFail as never); + }); + return connection; +} diff --git a/packages/bridge/src/link/streams.ts b/packages/bridge/src/link/streams.ts new file mode 100644 index 00000000..cb0811aa --- /dev/null +++ b/packages/bridge/src/link/streams.ts @@ -0,0 +1,34 @@ +import { + ByteLengthQueuingStrategy as PonyfillByteLengthQueuingStrategy, + ReadableStream as PonyfillReadableStream, + WritableStream as PonyfillWritableStream, +} from "web-streams-polyfill"; + +// harness: React Native has no WHATWG streams. The ponyfill fills the gap without +// installing globals, so the app under test sees the runtime it would see in production. + +interface StreamGlobals { + readonly ReadableStream?: typeof globalThis.ReadableStream; + readonly WritableStream?: typeof globalThis.WritableStream; + readonly ByteLengthQueuingStrategy?: typeof globalThis.ByteLengthQueuingStrategy; +} + +const host = globalThis as StreamGlobals; + +// why: one source for all three, so a native stream never meets a ponyfilled strategy. +const native = + typeof host.ReadableStream === "function" && + typeof host.WritableStream === "function" && + typeof host.ByteLengthQueuingStrategy === "function"; + +export const LinkReadableStream: typeof globalThis.ReadableStream = native + ? (host.ReadableStream as typeof globalThis.ReadableStream) + : (PonyfillReadableStream as unknown as typeof globalThis.ReadableStream); + +export const LinkWritableStream: typeof globalThis.WritableStream = native + ? (host.WritableStream as typeof globalThis.WritableStream) + : (PonyfillWritableStream as unknown as typeof globalThis.WritableStream); + +export const LinkByteLengthQueuingStrategy: typeof globalThis.ByteLengthQueuingStrategy = native + ? (host.ByteLengthQueuingStrategy as typeof globalThis.ByteLengthQueuingStrategy) + : (PonyfillByteLengthQueuingStrategy as unknown as typeof globalThis.ByteLengthQueuingStrategy); diff --git a/packages/bridge/src/rpc-peer.ts b/packages/bridge/src/rpc-peer.ts index ed0dbe58..e413ecb7 100644 --- a/packages/bridge/src/rpc-peer.ts +++ b/packages/bridge/src/rpc-peer.ts @@ -5,7 +5,10 @@ import { serializeBridgeMessage, type BridgeControlMessage, } from './protocol.js'; -import type { RpcTransport } from './transport.js'; + +export type RpcTransport = { + send: (message: string) => void; +}; type RpcMethod = { bivarianceHack(...args: unknown[]): unknown; diff --git a/packages/bridge/src/server.ts b/packages/bridge/src/server.ts index 990e221e..bc529d1a 100644 --- a/packages/bridge/src/server.ts +++ b/packages/bridge/src/server.ts @@ -7,20 +7,22 @@ import os from 'node:os'; import path from 'node:path'; import { randomUUID } from 'node:crypto'; import { logger } from '@react-native-harness/tools'; -import { BinaryStore, parseBinaryFrame } from './binary-transfer.js'; +import { receiveBinary } from './binary.js'; import { AppBridgeDisconnectedError, DeviceNotRespondingError, } from './errors.js'; import { createHeartbeat } from './heartbeat.js'; import { matchImageSnapshot } from './image-snapshot.js'; +import { + createLink, + globalClock, + StandardWebSocketConnection, + type LinkConnection, + type StandardWebSocket, +} from './link/index.js'; import { serializeBridgeMessage } from './protocol.js'; import { createRpcPeer } from './rpc-peer.js'; -import { - createRpcTransport, - type BridgeTransport, -} from './transport.js'; -import { createNodeWebSocketTransport } from './websocket-server-transport.js'; import type { BridgeServerFunctions, BridgeClientFunctions, @@ -92,17 +94,17 @@ const createWss = (transport: TransportOptions): Promise => { }; const receiveScreenshot = async ( - binaryStore: BinaryStore, + link: LinkConnection, reference: BinaryDataReference, ): Promise => { - const data = binaryStore.get(reference.transferId); - if (!data) { + const data = await receiveBinary(link, reference.channelId); + + if (data.length !== reference.size) { throw new Error( - `Binary data for transfer ${reference.transferId} not found or expired`, + `Screenshot transfer ended after ${data.length} of ${reference.size} bytes`, ); } - binaryStore.delete(reference.transferId); const file = path.join(os.tmpdir(), `harness-screenshot-${randomUUID()}.png`); await fs.writeFile(file, data); return { path: file }; @@ -117,7 +119,7 @@ export const createHarnessBridge = async ( const emitter = new EventEmitter(); let currentConnection: AppConnection | null = null; - let activeSession: { disconnect: (reason?: Error) => void; transport: BridgeTransport } | null = + let activeSession: { disconnect: (reason?: Error) => void; link: LinkConnection } | null = null; const connectionWaiters: Array<{ resolve: (c: AppConnection) => void; @@ -131,33 +133,35 @@ export const createHarnessBridge = async ( } bridgeLogger.debug('app connected'); - const transport = createNodeWebSocketTransport(ws); - const binaryStore = new BinaryStore(); + const link = createLink( + new StandardWebSocketConnection(ws as unknown as StandardWebSocket), + { + end: 'gateway', + clock: globalClock, + logger: bridgeLogger.child('link'), + }, + ); let readyConnection: AppConnection | null = null; let disconnected = false; let offMessage: () => void = noop; let offClose: () => void = noop; let offError: () => void = noop; - const closeTransport = () => { - if (transport.state === 'closing' || transport.state === 'closed') { - return; - } - - transport.close(1012); - }; - const rpc = createRpcPeer< BridgeServerFunctions, BridgeClientFunctions, BridgeEvents >({ localMethods: { - 'device.screenshot.receive': (ref) => receiveScreenshot(binaryStore, ref), + 'device.screenshot.receive': (ref) => receiveScreenshot(link, ref), 'test.matchImageSnapshot': (screenshot, testPath, opts) => matchImageSnapshot(screenshot, testPath, opts, context.platform.name), }, - transport: createRpcTransport(transport), + transport: { + send: (message) => { + void link.write(message); + }, + }, onEvent: (event) => { emitter.emit('event', event); }, @@ -169,7 +173,7 @@ export const createHarnessBridge = async ( const heartbeat = createHeartbeat({ sendPing: (id) => { - transport.send(serializeBridgeMessage({ type: 'ping', id })); + void link.write(serializeBridgeMessage({ type: 'ping', id })); }, onTimeout: () => { bridgeLogger.warn('app heartbeat timed out'); @@ -188,9 +192,8 @@ export const createHarnessBridge = async ( offError(); bridgeLogger.debug('app disconnected'); heartbeat.dispose(); - binaryStore.dispose(); - if (activeSession?.transport === transport) { + if (activeSession?.link === link) { activeSession = null; } @@ -199,14 +202,14 @@ export const createHarnessBridge = async ( } rpc.close(reason ?? new AppBridgeDisconnectedError('app-disconnected')); - closeTransport(); + void link.close(); if (readyConnection) { emitter.emit('disconnected'); } }; - activeSession = { disconnect, transport }; + activeSession = { disconnect, link }; const handleControlMessage = async (message: string) => { const controlMessage = await rpc.handleMessage(message); @@ -242,7 +245,7 @@ export const createHarnessBridge = async ( return; } case 'ping': { - transport.send( + void link.write( serializeBridgeMessage({ type: 'pong', id: controlMessage.id }), ); return; @@ -254,21 +257,7 @@ export const createHarnessBridge = async ( } }; - const handleBinaryMessage = (message: Uint8Array) => { - try { - const { transferId, data } = parseBinaryFrame(message); - binaryStore.add(transferId, data); - } catch (error) { - bridgeLogger.warn('failed to parse binary frame: %s', error); - } - }; - - offMessage = transport.onMessage((message) => { - if (typeof message !== 'string') { - handleBinaryMessage(message); - return; - } - + offMessage = link.onData((message) => { void handleControlMessage(message).catch((error) => { bridgeLogger.warn('failed to handle bridge message: %s', error); disconnect( @@ -279,17 +268,17 @@ export const createHarnessBridge = async ( }); }); - offClose = transport.onClose(() => { - disconnect(); - }); - - offError = transport.onError((error) => { + offError = link.onError((error) => { disconnect( error instanceof Error ? error : new AppBridgeDisconnectedError('socket-error'), ); }); + + offClose = link.onClose(() => { + disconnect(); + }); }); return { diff --git a/packages/bridge/src/shared.ts b/packages/bridge/src/shared.ts index 64dc802e..3cfa22d5 100644 --- a/packages/bridge/src/shared.ts +++ b/packages/bridge/src/shared.ts @@ -148,7 +148,8 @@ export type BridgeClientFunctions = { export type BinaryDataReference = { type: 'binary'; - transferId: number; + /** The link channel carrying the bytes. */ + channelId: string; size: number; mimeType: 'image/png'; }; diff --git a/packages/bridge/src/transport.ts b/packages/bridge/src/transport.ts deleted file mode 100644 index f08e9e6c..00000000 --- a/packages/bridge/src/transport.ts +++ /dev/null @@ -1,47 +0,0 @@ -export type BridgeTransportState = 'connecting' | 'open' | 'closing' | 'closed'; - -export type BridgeTransportMessage = string | Uint8Array; - -export type BridgeTransportCloseEvent = { - code: number; - reason: string; -}; - -export type BridgeTransport = { - readonly state: BridgeTransportState; - send: (message: BridgeTransportMessage) => void; - close: (code?: number, reason?: string) => void; - onOpen: (listener: () => void) => () => void; - onMessage: (listener: (message: BridgeTransportMessage) => void) => () => void; - onClose: (listener: (event: BridgeTransportCloseEvent) => void) => () => void; - onError: (listener: (error: Error) => void) => () => void; -}; - -export type RpcTransport = { - readonly state: BridgeTransportState; - send: (message: string) => void; -}; - -export const toTransportState = (readyState: number): BridgeTransportState => { - switch (readyState) { - case 0: - return 'connecting'; - case 1: - return 'open'; - case 2: - return 'closing'; - default: - return 'closed'; - } -}; - -export const createRpcTransport = (transport: BridgeTransport): RpcTransport => { - return { - get state() { - return transport.state; - }, - send: (message) => { - transport.send(message); - }, - }; -}; diff --git a/packages/bridge/src/websocket-client-transport.ts b/packages/bridge/src/websocket-client-transport.ts deleted file mode 100644 index ed587acd..00000000 --- a/packages/bridge/src/websocket-client-transport.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { - toTransportState, - type BridgeTransport, -} from './transport.js'; - -type BrowserWebSocketLike = { - readonly readyState: number; - binaryType: BinaryType; - send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void; - close: (code?: number, reason?: string) => void; - addEventListener: { - (type: 'open', listener: () => void): void; - ( - type: 'message', - listener: (event: MessageEvent) => void, - ): void; - (type: 'close', listener: (event: CloseEvent) => void): void; - ( - type: 'error', - listener: (event: Event & { message?: string }) => void, - ): void; - }; - removeEventListener: { - (type: 'open', listener: () => void): void; - ( - type: 'message', - listener: (event: MessageEvent) => void, - ): void; - (type: 'close', listener: (event: CloseEvent) => void): void; - ( - type: 'error', - listener: (event: Event & { message?: string }) => void, - ): void; - }; -}; - -export const createWebSocketClientTransport = (url: string): BridgeTransport => { - const socket = new WebSocket(url) as BrowserWebSocketLike; - socket.binaryType = 'arraybuffer'; - - return { - get state() { - return toTransportState(socket.readyState); - }, - send: (message) => { - socket.send(message); - }, - close: (code, reason) => { - socket.close(code, reason); - }, - onOpen: (listener) => { - socket.addEventListener('open', listener); - return () => socket.removeEventListener('open', listener); - }, - onMessage: (listener) => { - const handleMessage = (event: MessageEvent) => { - if (typeof event.data === 'string') { - listener(event.data); - return; - } - - listener(new Uint8Array(event.data)); - }; - - socket.addEventListener('message', handleMessage); - return () => socket.removeEventListener('message', handleMessage); - }, - onClose: (listener) => { - const handleClose = (event: CloseEvent) => { - listener({ code: event.code, reason: event.reason }); - }; - - socket.addEventListener('close', handleClose); - return () => socket.removeEventListener('close', handleClose); - }, - onError: (listener) => { - const handleError = (event: Event & { message?: string }) => { - listener(new Error(event.message || 'Harness connection error')); - }; - - socket.addEventListener('error', handleError); - return () => socket.removeEventListener('error', handleError); - }, - }; -}; diff --git a/packages/bridge/src/websocket-server-transport.ts b/packages/bridge/src/websocket-server-transport.ts deleted file mode 100644 index dfd495a6..00000000 --- a/packages/bridge/src/websocket-server-transport.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { RawData, WebSocket as NodeWebSocket } from 'ws'; -import { - toTransportState, - type BridgeTransport, -} from './transport.js'; - -const toUint8Array = (message: RawData): Uint8Array => { - const buffer = Array.isArray(message) - ? Buffer.concat(message) - : Buffer.isBuffer(message) - ? message - : Buffer.from(message); - - return new Uint8Array(buffer); -}; - -export const createNodeWebSocketTransport = ( - socket: NodeWebSocket, -): BridgeTransport => { - return { - get state() { - return toTransportState(socket.readyState); - }, - send: (message) => { - socket.send(message); - }, - close: (code, reason) => { - socket.close(code, reason); - }, - onOpen: () => { - return () => undefined; - }, - onMessage: (listener) => { - const handleMessage = (message: RawData, isBinary: boolean) => { - listener(isBinary ? toUint8Array(message) : message.toString()); - }; - - socket.on('message', handleMessage); - return () => socket.off('message', handleMessage); - }, - onClose: (listener) => { - const handleClose = (code: number, reason: Buffer) => { - listener({ code, reason: reason.toString() }); - }; - - socket.on('close', handleClose); - return () => socket.off('close', handleClose); - }, - onError: (listener) => { - socket.on('error', listener); - return () => socket.off('error', listener); - }, - }; -}; diff --git a/packages/jest/src/__tests__/bridge.test.ts b/packages/jest/src/__tests__/bridge.test.ts index af61c426..b609ae7c 100644 --- a/packages/jest/src/__tests__/bridge.test.ts +++ b/packages/jest/src/__tests__/bridge.test.ts @@ -3,6 +3,7 @@ * connectToHarness (app side). Tests the full connection lifecycle and * RPC round-trip without coupling tests to the transport internals. */ +import fs from 'node:fs/promises'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { HarnessBridge } from '@react-native-harness/bridge/server'; import { createHarnessBridge } from '@react-native-harness/bridge/server'; @@ -211,6 +212,30 @@ describe('bridge: createHarnessBridge + connectToHarness', () => { }); }); + describe('screenshots', () => { + it('transferScreenshot delivers the bytes to a file on the CLI side', async () => { + const connectionPromise = bridge.nextConnection(); + const handle = await connect(); + handle.reportReady(device); + await connectionPromise; + + const data = new Uint8Array(5 * 1024 * 1024); + for (let i = 0; i < data.length; i += 1) { + data[i] = i % 251; + } + + const file = await handle.transferScreenshot(data, { + width: 100, + height: 200, + }); + const written = await fs.readFile(file.path); + await fs.rm(file.path, { force: true }); + + expect(written.equals(Buffer.from(data))).toBe(true); + handle.disconnect(); + }); + }); + describe('dispose', () => { it('rejects pending nextConnection() waiters', async () => { const pending = bridge.nextConnection(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e41bc20..4c8638d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,6 +238,9 @@ importers: tslib: specifier: ^2.3.0 version: 2.8.1 + web-streams-polyfill: + specifier: ^4.3.0 + version: 4.3.0 ws: specifier: ^8.18.2 version: 8.18.2 @@ -8179,6 +8182,10 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-streams-polyfill@4.3.0: + resolution: {integrity: sha512-/Gnggvj9oSrEvJbDyyPtAnxBt5fGQM2iWOKQNu7ie1OxDgK40iZpyV3TKaRiEzVj1oA1UxKnEy9XPXh6PW3eVw==} + engines: {node: '>= 8'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -18321,6 +18328,8 @@ snapshots: web-namespaces@2.0.1: {} + web-streams-polyfill@4.3.0: {} + webidl-conversions@3.0.1: {} webidl-conversions@5.0.0: {}