diff --git a/packages/ttp/src/context.tsx b/packages/ttp/src/context.tsx index 85a6a73..8f3f6a5 100644 --- a/packages/ttp/src/context.tsx +++ b/packages/ttp/src/context.tsx @@ -5,7 +5,8 @@ import { useStorage } from "@tensamin/storage/context"; import { createTransportClient, READY_STATE, type BoundSendFn } from "./core"; import { PING_INTERVAL, - RETRY_COUNT, + RECONNECT_RESET, + RECONNECT_TRIES, RETRY_INTERVAL, TRANSPORT_URL, } from "./values"; @@ -224,6 +225,7 @@ export default function Provider(props: { children: React.ReactNode }) { React.useEffect(() => { let attempts = 0; let reconnectTimer: ReturnType | null = null; + let reconnectResetTimer: ReturnType | null = null; let reconnectScheduled = false; let disposed = false; @@ -241,6 +243,31 @@ export default function Provider(props: { children: React.ReactNode }) { reconnectScheduled = false; }; + /** + * Clears the stability timer that resets reconnect attempt counters. + * @returns Void. + */ + const clearReconnectResetTimer = () => { + if (!reconnectResetTimer) { + return; + } + + clearTimeout(reconnectResetTimer); + reconnectResetTimer = null; + }; + + /** + * Starts the stability timer that resets reconnect attempts after uptime. + * @returns Void. + */ + const scheduleReconnectReset = () => { + clearReconnectResetTimer(); + reconnectResetTimer = setTimeout(() => { + attempts = 0; + reconnectResetTimer = null; + }, RECONNECT_RESET * 1_000); + }; + /** * Schedules a delayed reconnect attempt unless retries are exhausted. * @param reason Optional reason for reconnect scheduling. @@ -251,7 +278,7 @@ export default function Provider(props: { children: React.ReactNode }) { return; } - if (attempts >= RETRY_COUNT) { + if (attempts >= RECONNECT_TRIES) { setError("Connection Failed"); setErrorDescription( "Unable to connect to the server after multiple attempts. Please check your internet connection or try again later.", @@ -275,8 +302,8 @@ export default function Provider(props: { children: React.ReactNode }) { setReadyState(state); if (state === READY_STATE.OPEN) { - attempts = 0; clearReconnectTimer(); + scheduleReconnectReset(); identificationStartedRef.current = false; setConnected(true); setIdentified(false); @@ -285,32 +312,24 @@ export default function Provider(props: { children: React.ReactNode }) { return; } + clearReconnectResetTimer(); identificationStartedRef.current = false; setConnected(false); setIdentified(false); }, onClose: ({ error: closeError, intentional }) => { - if (isStopSendingError(closeError)) { - clearReconnectTimer(); - setConnected(false); - setIdentified(false); - setIdentifying(false); - setError("Connection closed"); - setErrorDescription( - "The connection was forcefully closed by the Omikron.", - ); - log(0, "Socket", "red", "Connection closed", closeError); - return; - } - + clearReconnectResetTimer(); setConnected(false); setIdentified(false); + setIdentifying(false); if (disposed || intentional) { return; } - log(0, "Socket", "red", "Disconnected", closeError); + log(0, "Socket", "red", "Disconnected", closeError, { + stopSending: isStopSendingError(closeError), + }); scheduleReconnect(closeError); }, }); @@ -333,15 +352,6 @@ export default function Provider(props: { children: React.ReactNode }) { return; } - if (isStopSendingError(connectError)) { - clearReconnectTimer(); - setError("Connection closed"); - setErrorDescription( - "The connection was forcefully closed by the Omikron.", - ); - return; - } - log(0, "Socket", "red", "Connection attempt failed", connectError); scheduleReconnect(connectError); } @@ -352,6 +362,7 @@ export default function Provider(props: { children: React.ReactNode }) { return () => { disposed = true; clearReconnectTimer(); + clearReconnectResetTimer(); if (clientRef.current === transportClient) { clientRef.current = null; diff --git a/packages/ttp/src/core.test.ts b/packages/ttp/src/core.test.ts index 34f11e8..ee8146b 100644 --- a/packages/ttp/src/core.test.ts +++ b/packages/ttp/src/core.test.ts @@ -1,597 +1,493 @@ -import { describe, expect, test } from "bun:test"; -import { socket } from "@tensamin/shared/data"; +import { describe, it, expect } from "bun:test"; import { - createTransportClient, - decodeCommunicationMessage, encodeCommunicationMessage, + decodeCommunicationMessage, + createTransportClient, type TypedMessage, + type SchemaMap, } from "./core"; +import { z } from "zod"; -/** - * Creates a representative protocol message used for round-trip codec tests. - * @returns Typed protocol message with mixed payload data kinds. - */ -function createRoundTripMessage(): TypedMessage> { - return { - id: 41, - type: "message", - data: { - accepted: true, - message: "hello", - user_id: 77, - iota_ids: [11, 12], - ping_iota: 33, - last_ping: 101, - get_variant: null, - user: { - user_id: 1, - username: "alice", - }, - messages: [ - { - user_id: 2, - message: "payload", - }, - ], +type MockStreamWriter = { + write: (chunk: Uint8Array) => Promise; + releaseLock: () => void; + close: () => Promise; +}; + +type MockStream = { + getWriter: () => MockStreamWriter; +}; + +type MockReader = { + read: () => Promise<{ done: boolean; value?: Uint8Array }>; + releaseLock: () => void; + cancel: () => Promise; +}; + +type MockTransportInstance = { + ready: Promise; + closed: Promise; + createUnidirectionalStream: () => Promise; + incomingUnidirectionalStreams: { + getReader: () => MockReader; + }; + close: () => void; +}; + +type MemoryStorage = { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; + removeItem: (key: string) => void; + clear: () => void; +}; + +function ensureLocalStorage() { + const globalWithStorage = globalThis as unknown as { + localStorage?: MemoryStorage; + }; + + if (globalWithStorage.localStorage) { + return; + } + + const storage = new Map(); + globalWithStorage.localStorage = { + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => { + storage.set(key, value); + }, + removeItem: (key) => { + storage.delete(key); + }, + clear: () => { + storage.clear(); }, }; } -function installFakeWebTransport(streamChunks: Uint8Array[]) { - const globalScope = globalThis as typeof globalThis & { - WebTransport?: unknown; +function createMockWebTransport() { + ensureLocalStorage(); + + let readyResolve!: () => void; + let closedResolve!: () => void; + + const ready = new Promise((resolve) => { + readyResolve = resolve; + }); + const closed = new Promise((resolve) => { + closedResolve = resolve; + }); + + const writer: MockStreamWriter = { + write: async () => {}, + releaseLock: () => {}, + close: async () => {}, }; - const originalWebTransport = globalScope.WebTransport; - class FakeWebTransport { - readonly ready = Promise.resolve(); + const stream: MockStream = { + getWriter: () => writer, + }; - readonly closed: Promise; + const transport: MockTransportInstance = { + ready, + closed, + createUnidirectionalStream: async () => stream, + incomingUnidirectionalStreams: { + getReader: () => ({ + read: async () => ({ done: true }), + releaseLock: () => {}, + cancel: async () => {}, + }), + }, + close: () => {}, + }; - private resolveClosed!: () => void; - - readonly incomingUnidirectionalStreams: ReadableStream< - ReadableStream - >; - - constructor() { - this.closed = new Promise((resolve) => { - this.resolveClosed = resolve; - }); - - const incomingStream = new ReadableStream>({ - start: (controller) => { - controller.enqueue( - new ReadableStream({ - start(innerController) { - const emitChunk = (index: number) => { - if (index >= streamChunks.length) { - innerController.close(); - return; - } - - innerController.enqueue(streamChunks[index]); - setTimeout(() => emitChunk(index + 1), 10); - }; - - emitChunk(0); - }, - }), - ); - }, - }); - - this.incomingUnidirectionalStreams = incomingStream; - } - - createUnidirectionalStream() { - return new WritableStream({ - write() { - return undefined; - }, - close() { - return undefined; - }, - }); - } - - close() { - this.resolveClosed(); - } + class MockWebTransport implements MockTransportInstance { + ready = transport.ready; + closed = transport.closed; + createUnidirectionalStream = transport.createUnidirectionalStream; + incomingUnidirectionalStreams = transport.incomingUnidirectionalStreams; + close = transport.close; } - globalScope.WebTransport = FakeWebTransport as never; - - return () => { - globalScope.WebTransport = originalWebTransport; + return { + readyResolve, + closedResolve, + MockWebTransport, }; } -function concatBytes(chunks: Uint8Array[]) { - const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); - const buffer = new Uint8Array(totalLength); - let offset = 0; - - for (const chunk of chunks) { - buffer.set(chunk, offset); - offset += chunk.byteLength; - } - - return buffer; -} - -function chunkBytes(bytes: Uint8Array, sizes: number[]) { - const chunks: Uint8Array[] = []; - let offset = 0; - - for (const size of sizes) { - if (offset >= bytes.byteLength) { - break; +async function expectRejection( + promise: Promise, + messageSubstring?: string, +) { + try { + await promise; + expect(false).toBe(true); + } catch (error) { + if (messageSubstring) { + expect(getErrorMessage(error).includes(messageSubstring)).toBe(true); } - - const end = Math.min(offset + size, bytes.byteLength); - chunks.push(bytes.subarray(offset, end)); - offset = end; } - - if (offset < bytes.byteLength) { - chunks.push(bytes.subarray(offset)); - } - - return chunks; } -function frameBytes(payload: Uint8Array) { - const frame = new Uint8Array(payload.byteLength + 4); - const view = new DataView(frame.buffer); +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} - view.setUint32(0, payload.byteLength, false); - frame.set(payload, 4); +function writeU32BigEndian(buffer: Uint8Array, offset: number, value: number) { + new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setUint32( + offset, + value, + false, + ); +} +function wrapForDecode(body: Uint8Array) { + const frame = new Uint8Array(body.byteLength + 4); + writeU32BigEndian(frame, 0, body.byteLength); + frame.set(body, 4); return frame; } -function installFakeLocalStorage() { - const globalScope = globalThis as typeof globalThis & { - localStorage?: Storage; - }; - const originalLocalStorage = globalScope.localStorage; - const store = new Map(); +describe("Core Protocol", () => { + describe("encodeCommunicationMessage", () => { + it("encodes message with id", () => { + const message: TypedMessage = { + id: 123, + type: "ping", + data: {}, + }; - globalScope.localStorage = { - getItem(key: string) { - return store.has(key) ? store.get(key)! : null; - }, - setItem(key: string, value: string) { - store.set(key, value); - }, - removeItem(key: string) { - store.delete(key); - }, - clear() { - store.clear(); - }, - key(index: number) { - return [...store.keys()][index] ?? null; - }, - get length() { - return store.size; - }, - } as Storage; + const encoded = encodeCommunicationMessage(message); - return () => { - globalScope.localStorage = originalLocalStorage; - }; -} - -function installConsoleLogSpy() { - const globalConsole = console as typeof console & { - log: (...args: unknown[]) => void; - }; - const originalLog = globalConsole.log; - const calls: unknown[][] = []; - - globalConsole.log = (...args: unknown[]) => { - calls.push(args); - }; - - return { - calls, - restore() { - globalConsole.log = originalLog; - }, - }; -} - -describe("TTP communication codec", () => { - test("encodes and decodes a mixed payload message", () => { - const input = createRoundTripMessage(); - - const encoded = encodeCommunicationMessage(input); - const decoded = decodeCommunicationMessage(frameBytes(encoded)); - - expect(decoded.id).toBe(41); - expect(decoded.type).toBe("message"); - expect(decoded.data).toEqual({ - accepted: true, - message: "hello", - user_id: 77, - iota_ids: [11, 12], - ping_iota: 33, - last_ping: 101, - get_variant: null, - user: { - user_id: 1, - username: "alice", - }, - messages: [ - { - user_id: 2, - message: "payload", - }, - ], + expect(encoded instanceof Uint8Array).toBe(true); + expect(encoded.byteLength > 0).toBe(true); }); - }); - test("decodes an empty error payload as an empty object", () => { - const innerFrame = new Uint8Array(6); - const view = new DataView(innerFrame.buffer); + it("encodes message without id", () => { + const message: TypedMessage = { + id: 0, + type: "pong", + data: {}, + }; - view.setUint32(0, 2, false); - innerFrame[4] = 0; - innerFrame[5] = 0; + const encoded = encodeCommunicationMessage(message); + expect(encoded instanceof Uint8Array).toBe(true); + }); - const decoded = decodeCommunicationMessage(frameBytes(innerFrame)); - - expect(decoded.id).toBe(0); - expect(decoded.type).toBe("error"); - expect(decoded.data).toEqual({}); - }); - - test("keeps malformed error payloads visible as error messages", () => { - const innerFrame = new Uint8Array(8); - const view = new DataView(innerFrame.buffer); - - view.setUint32(0, 4, false); - innerFrame[4] = 0; - innerFrame[5] = 0; - innerFrame[6] = 0; - innerFrame[7] = 1; - - const decoded = decodeCommunicationMessage(frameBytes(innerFrame)); - - expect(decoded.id).toBe(0); - expect(decoded.type).toBe("error"); - expect(decoded.data).toEqual({}); - }); - - test("throws for unknown communication type", () => { - expect(() => - encodeCommunicationMessage({ - id: 1, - type: "unknown_type", - data: { user_id: 1 }, - }), - ).toThrow("Unknown communication type"); - }); - - test("throws for unknown data key", () => { - expect(() => - encodeCommunicationMessage({ + it("encodes message with string data", () => { + const message: TypedMessage = { id: 1, type: "message", - data: { unknown_key: 1 }, - }), - ).toThrow("Unknown data type"); - }); + data: { content: "hello", sender_id: 42 }, + }; - test("skips unknown container keys without failing decode", () => { - const encoded = encodeCommunicationMessage({ - id: 9, - type: "error", - data: { - accepted: true, - }, + const encoded = encodeCommunicationMessage(message); + const decoded = decodeCommunicationMessage(wrapForDecode(encoded)); + + expect(decoded.type).toBe("message"); + expect(decoded.id).toBe(1); }); - const corrupted = encoded.slice(); - corrupted[corrupted.length - 1] = 215; + it("throws on unknown message type", () => { + const message: TypedMessage = { + id: 1, + type: "unknown_type", + data: {}, + }; - const decoded = decodeCommunicationMessage(frameBytes(corrupted)); - - expect(decoded.id).toBe(9); - expect(decoded.type).toBe("error"); - expect(decoded.data).toEqual({}); + expect(() => encodeCommunicationMessage(message)).toThrow( + "Unknown communication type", + ); + }); }); - test("aligns live message schema with backend protocol name", () => { - expect(socket.message_live !== undefined).toBe(true); - expect((socket as Record).live_message).toEqual(undefined); + describe("decodeCommunicationMessage", () => { + it("decodes encoded message", () => { + const original: TypedMessage = { + id: 456, + type: "success", + data: {}, + }; + + const encoded = encodeCommunicationMessage(original); + const decoded = decodeCommunicationMessage(wrapForDecode(encoded)); + + expect(decoded.id).toBe(456); + expect(decoded.type).toBe("success"); + }); + + it("throws on truncated frame", () => { + const truncated = new Uint8Array([0x00, 0x00, 0x00]); + expect(() => decodeCommunicationMessage(truncated)).toThrow(); + }); + + it("throws on frame length mismatch", () => { + const buffer = new Uint8Array(10); + buffer[0] = 0xff; + buffer[1] = 0xff; + buffer[2] = 0xff; + buffer[3] = 0xff; + + expect(() => decodeCommunicationMessage(buffer)).toThrow( + "Communication frame length mismatch", + ); + }); + + it("decodes error messages with empty data", () => { + const original: TypedMessage = { + id: 1, + type: "error", + data: {}, + }; + + const encoded = encodeCommunicationMessage(original); + const decoded = decodeCommunicationMessage(wrapForDecode(encoded)); + + expect(decoded.type).toBe("error"); + }); }); - test("does not log raw binary frames unless enabled", async () => { - const ignoredFrame = encodeCommunicationMessage({ - id: 0, - type: "error_internal", - data: { - error_type: "transport_noise", - }, - }); + describe("createTransportClient", () => { + it("creates client with schemas", () => { + const { MockWebTransport } = createMockWebTransport(); + const globalWithWebTransport = globalThis as unknown as { + WebTransport?: new (url: string) => MockTransportInstance; + }; + globalWithWebTransport.WebTransport = MockWebTransport; - const identificationFrame = encodeCommunicationMessage({ - id: 7, - type: "identification", - data: { - challenge: "Zm9v", - public_key: "Zm9v", - }, - }); - - const streamBytes = concatBytes([ - frameBytes(ignoredFrame), - frameBytes(identificationFrame), - ]); - const restoreWebTransport = installFakeWebTransport( - chunkBytes(streamBytes, [2, 5, 1, 7]), - ); - const restoreLocalStorage = installFakeLocalStorage(); - const consoleSpy = installConsoleLogSpy(); - - try { - const client = createTransportClient(socket, { - url: "https://example.test", - }); - - await client.connect("https://example.test"); - - const response = client.send("identification", { user_id: 1 }, { id: 7 }); - - const timeout = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error("identification response timed out")); - }, 250); - }); - - const result = await Promise.race([response, timeout]); - - expect(result).toEqual({ - id: 7, - type: "identification", - data: { - challenge: "Zm9v", - public_key: "Zm9v", + const schemas: SchemaMap = { + ping: { + request: z.object({}), + response: z.object({}), }, - }); - expect(consoleSpy.calls); + }; - await client.close("test-complete"); - } finally { - consoleSpy.restore(); - restoreLocalStorage(); - restoreWebTransport(); - } - }); + const client = createTransportClient(schemas); - test("logs raw binary frames when enabled", async () => { - const requestMessage = encodeCommunicationMessage({ - id: 7, - type: "identification", - data: { - user_id: 1, - }, - }); - const requestFrame = frameBytes(requestMessage); - - const ignoredFrame = encodeCommunicationMessage({ - id: 0, - type: "error_internal", - data: { - error_type: "transport_noise", - }, + expect(client !== undefined).toBe(true); + expect(typeof client.readyState === "function").toBe(true); + expect(typeof client.connect === "function").toBe(true); + expect(typeof client.send === "function").toBe(true); + expect(typeof client.close === "function").toBe(true); + expect(typeof client.subscribePush === "function").toBe(true); }); - const identificationFrame = encodeCommunicationMessage({ - id: 7, - type: "identification", - data: { - challenge: "Zm9v", - public_key: "Zm9v", - }, + it("returns CLOSED ready state initially", () => { + const client = createTransportClient({}); + expect(client.readyState()).toBe(3); // CLOSED }); - const ignoredTransportFrame = frameBytes(ignoredFrame); - const identificationTransportFrame = frameBytes(identificationFrame); - const streamBytes = concatBytes([ - ignoredTransportFrame, - identificationTransportFrame, - ]); - const restoreWebTransport = installFakeWebTransport( - chunkBytes(streamBytes, [2, 5, 1, 7]), - ); - const restoreLocalStorage = installFakeLocalStorage(); - const consoleSpy = installConsoleLogSpy(); + it("rejects send when not connected", async () => { + const { MockWebTransport } = createMockWebTransport(); + const globalWithWebTransport = globalThis as unknown as { + WebTransport?: new (url: string) => MockTransportInstance; + }; + globalWithWebTransport.WebTransport = MockWebTransport; - try { - localStorage.setItem("ttp_logBinary", "true"); - - const client = createTransportClient(socket, { - url: "https://example.test", - }); - - await client.connect("https://example.test"); - - const response = client.send("identification", { user_id: 1 }, { id: 7 }); - - const timeout = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error("identification response timed out")); - }, 250); - }); - - const result = await Promise.race([response, timeout]); - - expect(result).toEqual({ - id: 7, - type: "identification", - data: { - challenge: "Zm9v", - public_key: "Zm9v", + const schemas: SchemaMap = { + ping: { + request: z.object({}), + response: z.object({}), }, - }); + }; - const logMessages = consoleSpy.calls - .filter((entry) => typeof entry[0] === "string") - .map((entry) => entry[0] as string); + const client = createTransportClient(schemas); - expect(logMessages).toContain( - `[Socket] Outgoing binary message (${requestFrame.byteLength} bytes)`, + await expectRejection( + client.send("ping", {}), + "Transport is not connected", ); - expect(logMessages).toContain( - `[Socket] Incoming binary message (${ignoredTransportFrame.byteLength} bytes)`, - ); - expect(logMessages).toContain( - `[Socket] Incoming binary message (${identificationTransportFrame.byteLength} bytes)`, - ); - - const outgoingCall = consoleSpy.calls.find( - (entry) => - entry[0] === - `[Socket] Outgoing binary message (${requestFrame.byteLength} bytes)`, - ); - expect(outgoingCall?.[1]).toEqual(requestFrame); - - const incomingCall = consoleSpy.calls.find( - (entry) => - entry[0] === - `[Socket] Incoming binary message (${identificationTransportFrame.byteLength} bytes)`, - ); - expect(incomingCall?.[1]).toEqual(identificationTransportFrame); - - await client.close("test-complete"); - } finally { - consoleSpy.restore(); - restoreLocalStorage(); - restoreWebTransport(); - } - }); - - test("resolves an identification response before the stream closes", async () => { - const ignoredFrame = encodeCommunicationMessage({ - id: 0, - type: "error_internal", - data: { - error_type: "transport_noise", - }, }); - const identificationFrame = encodeCommunicationMessage({ - id: 7, - type: "identification", - data: { - challenge: "Zm9v", - public_key: "Zm9v", - }, - }); + it("calls readyStateChange callback", async () => { + const { readyResolve, MockWebTransport } = createMockWebTransport(); + const globalWithWebTransport = globalThis as unknown as { + WebTransport?: new (url: string) => MockTransportInstance; + }; + globalWithWebTransport.WebTransport = MockWebTransport; - const streamBytes = concatBytes([ - frameBytes(ignoredFrame), - frameBytes(identificationFrame), - ]); - const restoreWebTransport = installFakeWebTransport( - chunkBytes(streamBytes, [2, 5, 1, 7]), - ); - const restoreLocalStorage = installFakeLocalStorage(); - - try { - const client = createTransportClient(socket, { - url: "https://example.test", - }); - - await client.connect("https://example.test"); - - const response = client.send("identification", { user_id: 1 }, { id: 7 }); - - const timeout = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error("identification response timed out")); - }, 250); - }); - - const result = await Promise.race([response, timeout]); - - expect(result).toEqual({ - id: 7, - type: "identification", - data: { - challenge: "Zm9v", - public_key: "Zm9v", + const readyStateChanges: number[] = []; + const client = createTransportClient( + {}, + { + onReadyStateChange: (state) => readyStateChanges.push(state), }, - }); + ); - await client.close("test-complete"); - } finally { - restoreLocalStorage(); - restoreWebTransport(); - } - }); + const connectPromise = client.connect("http://localhost:8000"); + readyResolve(); + await connectPromise; - test("resolves an identification response that arrives after another frame on the same stream", async () => { - const ignoredFrame = encodeCommunicationMessage({ - id: 0, - type: "error_internal", - data: { - error_type: "transport_noise", - }, + expect(readyStateChanges).toContain(0); // CONNECTING + expect(readyStateChanges).toContain(1); // OPEN }); - const identificationFrame = encodeCommunicationMessage({ - id: 7, - type: "identification", - data: { - challenge: "Zm9v", - public_key: "Zm9v", - }, - }); + it("calls close callback on intentional close", async () => { + const { readyResolve, closedResolve, MockWebTransport } = + createMockWebTransport(); + const globalWithWebTransport = globalThis as unknown as { + WebTransport?: new (url: string) => MockTransportInstance; + }; + globalWithWebTransport.WebTransport = MockWebTransport; - const streamBytes = concatBytes([ - frameBytes(ignoredFrame), - frameBytes(identificationFrame), - ]); - const restoreWebTransport = installFakeWebTransport( - chunkBytes(streamBytes, [1, 4, 3, 9, 2]), - ); - const restoreLocalStorage = installFakeLocalStorage(); - - try { - const client = createTransportClient(socket, { - url: "https://example.test", - }); - - await client.connect("https://example.test"); - - const response = client.send("identification", { user_id: 1 }, { id: 7 }); - - const timeout = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error("identification response timed out")); - }, 250); - }); - - const result = await Promise.race([response, timeout]); - - expect(result).toEqual({ - id: 7, - type: "identification", - data: { - challenge: "Zm9v", - public_key: "Zm9v", + const closeEvents: Array<{ intentional: boolean; error?: unknown }> = []; + const client = createTransportClient( + {}, + { + onClose: (event) => closeEvents.push(event), }, - }); + ); - await client.close("test-complete"); - } finally { - restoreLocalStorage(); - restoreWebTransport(); - } + const connectPromise = client.connect("http://localhost:8000"); + readyResolve(); + await connectPromise; + + const closePromise = client.close(); + closedResolve(); + await closePromise; + + expect(closeEvents.length > 0).toBe(true); + expect(closeEvents[closeEvents.length - 1].intentional).toBe(true); + }); + + it("rejects pending requests on close", async () => { + const { readyResolve, closedResolve, MockWebTransport } = + createMockWebTransport(); + const globalWithWebTransport = globalThis as unknown as { + WebTransport?: new (url: string) => MockTransportInstance; + }; + globalWithWebTransport.WebTransport = MockWebTransport; + + const schemas: SchemaMap = { + ping: { + request: z.object({}), + response: z.object({}), + }, + }; + + const client = createTransportClient(schemas); + const connectPromise = client.connect("http://localhost:8000"); + readyResolve(); + await connectPromise; + + const sendPromise = client.send("ping", {}); + const closePromise = client.close(); + closedResolve(); + await closePromise; + + await expectRejection(sendPromise); + }); }); -}); + + describe("Push subscriptions", () => { + it("subscribes and unsubscribes from push events", () => { + const client = createTransportClient({}); + + const handler = () => {}; + const unsubscribe = client.subscribePush(handler); + + expect(typeof unsubscribe).toBe("function"); + unsubscribe(); + }); + }); + + describe("Data type encoding", () => { + it("encodes boolean true", () => { + const message: TypedMessage = { + id: 1, + type: "message", + data: { signed: true }, + }; + + const encoded = encodeCommunicationMessage(message); + const decoded = decodeCommunicationMessage(wrapForDecode(encoded)); + + expect(decoded.data.signed).toBe(true); + }); + + it("encodes boolean false", () => { + const message: TypedMessage = { + id: 1, + type: "message", + data: { signed: false }, + }; + + const encoded = encodeCommunicationMessage(message); + const decoded = decodeCommunicationMessage(wrapForDecode(encoded)); + + expect(decoded.data.signed).toBe(false); + }); + + it("encodes numbers", () => { + const message: TypedMessage = { + id: 1, + type: "message", + data: { user_id: 42, sender_id: 100 }, + }; + + const encoded = encodeCommunicationMessage(message); + const decoded = decodeCommunicationMessage(wrapForDecode(encoded)); + + expect(decoded.data.user_id).toBe(42); + expect(decoded.data.sender_id).toBe(100); + }); + + it("encodes strings", () => { + const message: TypedMessage = { + id: 1, + type: "message", + data: { content: "test message", username: "alice" }, + }; + + const encoded = encodeCommunicationMessage(message); + const decoded = decodeCommunicationMessage(wrapForDecode(encoded)); + + expect(decoded.data.content).toBe("test message"); + expect(decoded.data.username).toBe("alice"); + }); + + it("encodes null values", () => { + const message: TypedMessage = { + id: 1, + type: "message", + data: { status: null }, + }; + + const encoded = encodeCommunicationMessage(message); + const decoded = decodeCommunicationMessage(wrapForDecode(encoded)); + + expect(decoded.data.status).toBe(null); + }); + + it("encodes arrays of numbers", () => { + const message: TypedMessage = { + id: 1, + type: "message", + data: { user_ids: [1, 2, 3] }, + }; + + const encoded = encodeCommunicationMessage(message); + const decoded = decodeCommunicationMessage(wrapForDecode(encoded)); + + expect(decoded.data.user_ids).toEqual([1, 2, 3]); + }); + + it("encodes nested containers", () => { + const message: TypedMessage = { + id: 1, + type: "message", + data: { user: { username: "alice", display: "Alice" } }, + }; + + const encoded = encodeCommunicationMessage(message); + const decoded = decodeCommunicationMessage(wrapForDecode(encoded)); + + expect(decoded.data.user).toEqual({ + username: "alice", + display: "Alice", + }); + }); + }); +}); \ No newline at end of file diff --git a/packages/ttp/src/core.ts b/packages/ttp/src/core.ts index 0793387..1866df3 100644 --- a/packages/ttp/src/core.ts +++ b/packages/ttp/src/core.ts @@ -61,6 +61,11 @@ type ActiveConnection = { streamReader: ReadableStreamDefaultReader> | null; intentional: boolean; closeNotified: boolean; + acceptLoopDone: Promise | null; + resolveAcceptLoopDone: (() => void) | null; + activeIncomingTasks: Set>; + sendStream: WritableStream | null; + sendWriter: WritableStreamDefaultWriter | null; }; /** @@ -532,32 +537,6 @@ export function createTransportClient( notifyClosed(connection, error); }; - /** - * Handles STOP_SENDING failures by forcing close and notifying failure. - * @param connection Active connection. - * @param error Failure reason. - * @returns Void. - */ - const closeFromStopSending = ( - connection: ActiveConnection, - error: unknown, - ) => { - if (!isStopSendingError(error)) { - return; - } - - try { - connection.transport.close({ - closeCode: APPLICATION_CLOSE_CODE, - reason: "stop-sending", - }); - } catch { - // Ignore close failures while handling STOP_SENDING. - } - - handleConnectionFailure(connection, error); - }; - /** * Handles decoded incoming messages and resolves request promises or push listeners. * @param message Decoded incoming message. @@ -680,40 +659,88 @@ export function createTransportClient( const startIncomingLoop = (connection: ActiveConnection) => { connection.streamReader = connection.transport.incomingUnidirectionalStreams.getReader(); + connection.acceptLoopDone = new Promise((resolve) => { + connection.resolveAcceptLoopDone = resolve; + }); void (async () => { try { - while (currentConnection === connection && !connection.intentional) { - const result = await connection.streamReader?.read(); + while (!connection.closeNotified) { + const streamReader = connection.streamReader; + if (!streamReader) { + break; + } + + const readResult = await Promise.race([ + streamReader.read().then((result) => ({ + type: "stream" as const, + result, + })), + connection.transport.closed + .catch(() => undefined) + .then(() => ({ type: "closed" as const })), + ]); + + if (readResult.type !== "stream") { + break; + } + + const result = readResult.result; if (!result || result.done) { break; } - const closedByPeer = await processIncomingStream( - result.value, - connection, - handleIncomingMessage, - handleRecoverableDecodeFailure, - handleConnectionFailure, - ); + const shouldDiscardFrames = + connection.intentional || currentConnection !== connection; - if (closedByPeer) { - return; - } + const task = (async () => { + try { + await processIncomingStream( + result.value, + connection, + handleIncomingMessage, + handleRecoverableDecodeFailure, + handleConnectionFailure, + shouldDiscardFrames, + ); + } catch (error) { + log( + 0, + "Socket", + "red", + "Incoming transport stream failed", + error, + ); + handleConnectionFailure(connection, error); + } + })(); + + connection.activeIncomingTasks.add(task); + void task.finally(() => { + connection.activeIncomingTasks.delete(task); + }); } - if (!connection.intentional) { + if ( + !connection.intentional && + !connection.closeNotified && + currentConnection === connection + ) { handleConnectionFailure( connection, new Error("Transport stream closed"), ); } } catch (error) { - log(0, "Socket", "red", "Incoming transport stream failed", error); + log(0, "Socket", "red", "Incoming stream accept loop failed", error); handleConnectionFailure(connection, error); } finally { connection.streamReader?.releaseLock(); connection.streamReader = null; + + const resolveAcceptLoopDone = connection.resolveAcceptLoopDone; + connection.resolveAcceptLoopDone = null; + resolveAcceptLoopDone?.(); } })(); }; @@ -756,6 +783,11 @@ export function createTransportClient( streamReader: null, intentional: false, closeNotified: false, + acceptLoopDone: null, + resolveAcceptLoopDone: null, + activeIncomingTasks: new Set(), + sendStream: null, + sendWriter: null, }; currentConnection = connection; @@ -793,21 +825,23 @@ export function createTransportClient( connection.intentional = true; setReadyState(READY_STATE.CLOSING); + const acceptLoopDone = connection.acceptLoopDone; rejectPending(new Error("Transport closed")); + try { + connection.sendWriter?.releaseLock(); + await connection.sendStream?.abort(); + } catch { + // Ignore errors during stream abort + } + try { await writeCloseFrame(connection.transport); } catch (error) { log(1, "Socket", "yellow", "Failed to send close sentinel", error); } - try { - connection.streamReader?.cancel().catch(() => undefined); - } catch { - // Ignore reader cancellation failures during shutdown. - } - try { connection.transport.close({ closeCode: APPLICATION_CLOSE_CODE, @@ -819,6 +853,10 @@ export function createTransportClient( try { await connection.transport.closed.catch(() => undefined); + await acceptLoopDone; + if (connection.activeIncomingTasks.size > 0) { + await Promise.allSettled([...connection.activeIncomingTasks]); + } } finally { notifyClosed(connection); } @@ -898,9 +936,9 @@ export function createTransportClient( }); if (!expectsResponse) { - return writeMessage(connection.transport, messageBytes).catch( + return writeMessageOnPersistentStream(connection, messageBytes).catch( (error) => { - closeFromStopSending(connection, error); + handleConnectionFailure(connection, error); throw error; }, ); @@ -923,12 +961,14 @@ export function createTransportClient( timeoutId, }); - void writeMessage(connection.transport, messageBytes).catch((error) => { - closeFromStopSending(connection, error); - clearTimeout(timeoutId); - pending.delete(requestId); - reject(error); - }); + void writeMessageOnPersistentStream(connection, messageBytes).catch( + (error) => { + handleConnectionFailure(connection, error); + clearTimeout(timeoutId); + pending.delete(requestId); + reject(error); + }, + ); }); } catch (error) { return Promise.reject(error); @@ -1055,39 +1095,6 @@ function logBinaryMessage( ); } -/** - * Detects whether an error chain includes STOP_SENDING. - * @param error Unknown transport error. - * @returns True when STOP_SENDING appears in the error chain. - */ -function isStopSendingError(error: unknown) { - if (typeof error === "string") { - return error.includes("STOP_SENDING"); - } - - if (error instanceof Error) { - if (error.message.includes("STOP_SENDING")) { - return true; - } - - const errorWithCause = error as Error & { cause?: unknown }; - if (errorWithCause.cause !== undefined) { - return isStopSendingError(errorWithCause.cause); - } - - return false; - } - - if (typeof error === "object" && error !== null) { - const maybeMessage = (error as { message?: unknown }).message; - if (typeof maybeMessage === "string") { - return maybeMessage.includes("STOP_SENDING"); - } - } - - return false; -} - /** * Ensures outbound message payloads are plain object records. * @param value Candidate payload. @@ -1170,30 +1177,54 @@ function validateRequestId(id: number, expectsResponse: boolean) { /** * Writes a protocol message payload as a framed unidirectional transport stream. - * @param transport Active transport instance. + * Uses a persistent stream, and retries once if the stream was closed by the receiver. + * @param connection Active connection instance. * @param payload Encoded message payload bytes. * @returns Promise that resolves when frame writing is complete. */ -async function writeMessage(transport: WebTransportLike, payload: Uint8Array) { +async function writeMessageOnPersistentStream( + connection: ActiveConnection, + payload: Uint8Array, +) { if (payload.byteLength >= CLOSE_FRAME_LEN) { throw new Error("Message too large for transport frame"); } - const stream = await transport.createUnidirectionalStream(); - const writer = stream.getWriter(); + const frame = new Uint8Array(4 + payload.byteLength); + writeU32(frame, 0, payload.byteLength); + frame.set(payload, 4); - try { - const frame = new Uint8Array(4 + payload.byteLength); - writeU32(frame, 0, payload.byteLength); - frame.set(payload, 4); + const writeAndCatch = async (): Promise => { + try { + if (!connection.sendStream || !connection.sendWriter) { + connection.sendStream = + await connection.transport.createUnidirectionalStream(); + connection.sendWriter = connection.sendStream.getWriter(); + } - logBinaryMessage("Outgoing", frame); + logBinaryMessage("Outgoing", frame); + await connection.sendWriter.write(frame); + return true; + } catch { + return false; + } + }; - await writer.write(frame); - await writer.close(); - } finally { - writer.releaseLock(); - } + const firstResult = await writeAndCatch(); + if (firstResult) return; + + // Retry once + connection.sendWriter?.releaseLock(); + connection.sendWriter = null; + connection.sendStream = null; + + const secondResult = await writeAndCatch(); + if (secondResult) return; + + connection.sendWriter = null; + connection.sendStream = null; + + throw new Error("Transport stream closed during send"); } /** @@ -1221,6 +1252,7 @@ async function writeCloseFrame(transport: WebTransportLike) { * @param connection Active connection instance. * @param handleIncomingFrame Handler for decoded protocol messages. * @param handleDecodeFailure Handler for recoverable frame decode failures. + * @param discardFrames Whether frames should be drained and discarded. * @returns True when the peer close sentinel was received. */ async function processIncomingStream( @@ -1229,9 +1261,11 @@ async function processIncomingStream( handleIncomingFrame: (message: TypedMessage) => void, handleDecodeFailure: (error: RecoverableMessageDecodeError) => void, handleStreamFailure: (connection: ActiveConnection, error?: unknown) => void, + discardFrames: boolean, ) { const reader = stream.getReader(); let bufferedBytes = new Uint8Array(0) as Uint8Array; + let peerCloseDetected = false; try { while (true) { @@ -1242,10 +1276,18 @@ async function processIncomingStream( bufferedBytes = appendBytes(bufferedBytes, value); + if (discardFrames || peerCloseDetected) { + bufferedBytes = new Uint8Array(0) as Uint8Array; + continue; + } + while (bufferedBytes.byteLength >= 4) { const declaredLength = readU32(bufferedBytes, 0); if (declaredLength === CLOSE_FRAME_LEN) { + peerCloseDetected = true; + bufferedBytes = new Uint8Array(0) as Uint8Array; + try { connection.transport.close({ closeCode: APPLICATION_CLOSE_CODE, @@ -1259,7 +1301,7 @@ async function processIncomingStream( connection, new Error("Transport closed by peer"), ); - return true; + break; } const expectedLength = 4 + declaredLength; @@ -1277,20 +1319,24 @@ async function processIncomingStream( } catch (error) { if (error instanceof RecoverableMessageDecodeError) { handleDecodeFailure(error); - continue; + } else { + throw error; } - - throw error; } + + // Just like the backend, drop the stream after receiving exactly one incoming message! + return peerCloseDetected; } } - if (bufferedBytes.byteLength > 0) { + if (!discardFrames && !peerCloseDetected && bufferedBytes.byteLength > 0) { throw new Error("Received truncated transport frame"); } - return false; + return peerCloseDetected; } finally { + // We cancel the reader to signal the stream is naturally dropped, matching Rust's receiver behavior. + reader.cancel().catch(() => {}); reader.releaseLock(); } } diff --git a/packages/ttp/src/values.ts b/packages/ttp/src/values.ts index 73d9afd..d44680c 100644 --- a/packages/ttp/src/values.ts +++ b/packages/ttp/src/values.ts @@ -1,5 +1,7 @@ export const RESPONSE_TIMEOUT = 15_000; export const RETRY_COUNT = 10; export const RETRY_INTERVAL = 3_000; -export const PING_INTERVAL = 5_000; +export const PING_INTERVAL = 3_000; export const TRANSPORT_URL = "https://methanium.net:959"; +export const RECONNECT_TRIES = 3; +export const RECONNECT_RESET = 6; \ No newline at end of file