Fixed ttp frfr

This commit is contained in:
Alois 2026-03-24 23:33:32 +01:00
commit ec5ee847e0
4 changed files with 588 additions and 633 deletions

View file

@ -5,7 +5,8 @@ import { useStorage } from "@tensamin/storage/context";
import { createTransportClient, READY_STATE, type BoundSendFn } from "./core"; import { createTransportClient, READY_STATE, type BoundSendFn } from "./core";
import { import {
PING_INTERVAL, PING_INTERVAL,
RETRY_COUNT, RECONNECT_RESET,
RECONNECT_TRIES,
RETRY_INTERVAL, RETRY_INTERVAL,
TRANSPORT_URL, TRANSPORT_URL,
} from "./values"; } from "./values";
@ -224,6 +225,7 @@ export default function Provider(props: { children: React.ReactNode }) {
React.useEffect(() => { React.useEffect(() => {
let attempts = 0; let attempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectScheduled = false; let reconnectScheduled = false;
let disposed = false; let disposed = false;
@ -241,6 +243,31 @@ export default function Provider(props: { children: React.ReactNode }) {
reconnectScheduled = false; 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. * Schedules a delayed reconnect attempt unless retries are exhausted.
* @param reason Optional reason for reconnect scheduling. * @param reason Optional reason for reconnect scheduling.
@ -251,7 +278,7 @@ export default function Provider(props: { children: React.ReactNode }) {
return; return;
} }
if (attempts >= RETRY_COUNT) { if (attempts >= RECONNECT_TRIES) {
setError("Connection Failed"); setError("Connection Failed");
setErrorDescription( setErrorDescription(
"Unable to connect to the server after multiple attempts. Please check your internet connection or try again later.", "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); setReadyState(state);
if (state === READY_STATE.OPEN) { if (state === READY_STATE.OPEN) {
attempts = 0;
clearReconnectTimer(); clearReconnectTimer();
scheduleReconnectReset();
identificationStartedRef.current = false; identificationStartedRef.current = false;
setConnected(true); setConnected(true);
setIdentified(false); setIdentified(false);
@ -285,32 +312,24 @@ export default function Provider(props: { children: React.ReactNode }) {
return; return;
} }
clearReconnectResetTimer();
identificationStartedRef.current = false; identificationStartedRef.current = false;
setConnected(false); setConnected(false);
setIdentified(false); setIdentified(false);
}, },
onClose: ({ error: closeError, intentional }) => { onClose: ({ error: closeError, intentional }) => {
if (isStopSendingError(closeError)) { clearReconnectResetTimer();
clearReconnectTimer();
setConnected(false); setConnected(false);
setIdentified(false); setIdentified(false);
setIdentifying(false); setIdentifying(false);
setError("Connection closed");
setErrorDescription(
"The connection was forcefully closed by the Omikron.",
);
log(0, "Socket", "red", "Connection closed", closeError);
return;
}
setConnected(false);
setIdentified(false);
if (disposed || intentional) { if (disposed || intentional) {
return; return;
} }
log(0, "Socket", "red", "Disconnected", closeError); log(0, "Socket", "red", "Disconnected", closeError, {
stopSending: isStopSendingError(closeError),
});
scheduleReconnect(closeError); scheduleReconnect(closeError);
}, },
}); });
@ -333,15 +352,6 @@ export default function Provider(props: { children: React.ReactNode }) {
return; 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); log(0, "Socket", "red", "Connection attempt failed", connectError);
scheduleReconnect(connectError); scheduleReconnect(connectError);
} }
@ -352,6 +362,7 @@ export default function Provider(props: { children: React.ReactNode }) {
return () => { return () => {
disposed = true; disposed = true;
clearReconnectTimer(); clearReconnectTimer();
clearReconnectResetTimer();
if (clientRef.current === transportClient) { if (clientRef.current === transportClient) {
clientRef.current = null; clientRef.current = null;

View file

@ -1,597 +1,493 @@
import { describe, expect, test } from "bun:test"; import { describe, it, expect } from "bun:test";
import { socket } from "@tensamin/shared/data";
import { import {
createTransportClient,
decodeCommunicationMessage,
encodeCommunicationMessage, encodeCommunicationMessage,
decodeCommunicationMessage,
createTransportClient,
type TypedMessage, type TypedMessage,
type SchemaMap,
} from "./core"; } from "./core";
import { z } from "zod";
/** type MockStreamWriter = {
* Creates a representative protocol message used for round-trip codec tests. write: (chunk: Uint8Array) => Promise<void>;
* @returns Typed protocol message with mixed payload data kinds. releaseLock: () => void;
*/ close: () => Promise<void>;
function createRoundTripMessage(): TypedMessage<Record<string, unknown>> {
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",
},
],
},
}; };
}
function installFakeWebTransport(streamChunks: Uint8Array[]) { type MockStream = {
const globalScope = globalThis as typeof globalThis & { getWriter: () => MockStreamWriter;
WebTransport?: unknown;
}; };
const originalWebTransport = globalScope.WebTransport;
class FakeWebTransport { type MockReader = {
readonly ready = Promise.resolve(); read: () => Promise<{ done: boolean; value?: Uint8Array }>;
releaseLock: () => void;
cancel: () => Promise<void>;
};
readonly closed: Promise<void>; type MockTransportInstance = {
ready: Promise<void>;
closed: Promise<void>;
createUnidirectionalStream: () => Promise<MockStream>;
incomingUnidirectionalStreams: {
getReader: () => MockReader;
};
close: () => void;
};
private resolveClosed!: () => void; type MemoryStorage = {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
clear: () => void;
};
readonly incomingUnidirectionalStreams: ReadableStream< function ensureLocalStorage() {
ReadableStream<Uint8Array> const globalWithStorage = globalThis as unknown as {
>; localStorage?: MemoryStorage;
};
constructor() { if (globalWithStorage.localStorage) {
this.closed = new Promise<void>((resolve) => {
this.resolveClosed = resolve;
});
const incomingStream = new ReadableStream<ReadableStream<Uint8Array>>({
start: (controller) => {
controller.enqueue(
new ReadableStream<Uint8Array>({
start(innerController) {
const emitChunk = (index: number) => {
if (index >= streamChunks.length) {
innerController.close();
return; return;
} }
innerController.enqueue(streamChunks[index]); const storage = new Map<string, string>();
setTimeout(() => emitChunk(index + 1), 10); globalWithStorage.localStorage = {
getItem: (key) => storage.get(key) ?? null,
setItem: (key, value) => {
storage.set(key, value);
},
removeItem: (key) => {
storage.delete(key);
},
clear: () => {
storage.clear();
},
};
}
function createMockWebTransport() {
ensureLocalStorage();
let readyResolve!: () => void;
let closedResolve!: () => void;
const ready = new Promise<void>((resolve) => {
readyResolve = resolve;
});
const closed = new Promise<void>((resolve) => {
closedResolve = resolve;
});
const writer: MockStreamWriter = {
write: async () => {},
releaseLock: () => {},
close: async () => {},
}; };
emitChunk(0); const stream: MockStream = {
}, getWriter: () => writer,
};
const transport: MockTransportInstance = {
ready,
closed,
createUnidirectionalStream: async () => stream,
incomingUnidirectionalStreams: {
getReader: () => ({
read: async () => ({ done: true }),
releaseLock: () => {},
cancel: async () => {},
}), }),
);
}, },
}); close: () => {},
};
this.incomingUnidirectionalStreams = incomingStream; class MockWebTransport implements MockTransportInstance {
ready = transport.ready;
closed = transport.closed;
createUnidirectionalStream = transport.createUnidirectionalStream;
incomingUnidirectionalStreams = transport.incomingUnidirectionalStreams;
close = transport.close;
} }
createUnidirectionalStream() { return {
return new WritableStream<Uint8Array>({ readyResolve,
write() { closedResolve,
return undefined; MockWebTransport,
},
close() {
return undefined;
},
});
}
close() {
this.resolveClosed();
}
}
globalScope.WebTransport = FakeWebTransport as never;
return () => {
globalScope.WebTransport = originalWebTransport;
}; };
} }
function concatBytes(chunks: Uint8Array[]) { async function expectRejection(
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); promise: Promise<unknown>,
const buffer = new Uint8Array(totalLength); messageSubstring?: string,
let offset = 0; ) {
try {
for (const chunk of chunks) { await promise;
buffer.set(chunk, offset); expect(false).toBe(true);
offset += chunk.byteLength; } catch (error) {
if (messageSubstring) {
expect(getErrorMessage(error).includes(messageSubstring)).toBe(true);
}
}
} }
return buffer; function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
} }
function chunkBytes(bytes: Uint8Array, sizes: number[]) { function writeU32BigEndian(buffer: Uint8Array, offset: number, value: number) {
const chunks: Uint8Array[] = []; new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).setUint32(
let offset = 0; offset,
value,
for (const size of sizes) { false,
if (offset >= bytes.byteLength) { );
break;
} }
const end = Math.min(offset + size, bytes.byteLength); function wrapForDecode(body: Uint8Array) {
chunks.push(bytes.subarray(offset, end)); const frame = new Uint8Array(body.byteLength + 4);
offset = end; writeU32BigEndian(frame, 0, body.byteLength);
} frame.set(body, 4);
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);
view.setUint32(0, payload.byteLength, false);
frame.set(payload, 4);
return frame; return frame;
} }
function installFakeLocalStorage() { describe("Core Protocol", () => {
const globalScope = globalThis as typeof globalThis & { describe("encodeCommunicationMessage", () => {
localStorage?: Storage; it("encodes message with id", () => {
}; const message: TypedMessage = {
const originalLocalStorage = globalScope.localStorage; id: 123,
const store = new Map<string, string>(); 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;
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 { const encoded = encodeCommunicationMessage(message);
calls,
restore() { expect(encoded instanceof Uint8Array).toBe(true);
globalConsole.log = originalLog; expect(encoded.byteLength > 0).toBe(true);
}, });
it("encodes message without id", () => {
const message: TypedMessage = {
id: 0,
type: "pong",
data: {},
}; };
}
describe("TTP communication codec", () => { const encoded = encodeCommunicationMessage(message);
test("encodes and decodes a mixed payload message", () => { expect(encoded instanceof Uint8Array).toBe(true);
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",
},
],
});
}); });
test("decodes an empty error payload as an empty object", () => { it("encodes message with string data", () => {
const innerFrame = new Uint8Array(6); const message: TypedMessage = {
const view = new DataView(innerFrame.buffer);
view.setUint32(0, 2, false);
innerFrame[4] = 0;
innerFrame[5] = 0;
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({
id: 1, id: 1,
type: "message", type: "message",
data: { unknown_key: 1 }, data: { content: "hello", sender_id: 42 },
}), };
).toThrow("Unknown data type");
const encoded = encodeCommunicationMessage(message);
const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
expect(decoded.type).toBe("message");
expect(decoded.id).toBe(1);
}); });
test("skips unknown container keys without failing decode", () => { it("throws on unknown message type", () => {
const encoded = encodeCommunicationMessage({ const message: TypedMessage = {
id: 9, id: 1,
type: "unknown_type",
data: {},
};
expect(() => encodeCommunicationMessage(message)).toThrow(
"Unknown communication type",
);
});
});
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", type: "error",
data: { data: {},
accepted: true, };
},
});
const corrupted = encoded.slice(); const encoded = encodeCommunicationMessage(original);
corrupted[corrupted.length - 1] = 215; const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
const decoded = decodeCommunicationMessage(frameBytes(corrupted));
expect(decoded.id).toBe(9);
expect(decoded.type).toBe("error"); expect(decoded.type).toBe("error");
expect(decoded.data).toEqual({}); });
}); });
test("aligns live message schema with backend protocol name", () => { describe("createTransportClient", () => {
expect(socket.message_live !== undefined).toBe(true); it("creates client with schemas", () => {
expect((socket as Record<string, unknown>).live_message).toEqual(undefined); const { MockWebTransport } = createMockWebTransport();
}); const globalWithWebTransport = globalThis as unknown as {
WebTransport?: new (url: string) => MockTransportInstance;
};
globalWithWebTransport.WebTransport = MockWebTransport;
test("does not log raw binary frames unless enabled", async () => { const schemas: SchemaMap = {
const ignoredFrame = encodeCommunicationMessage({ ping: {
id: 0, request: z.object({}),
type: "error_internal", response: z.object({}),
data: {
error_type: "transport_noise",
}, },
};
const client = createTransportClient(schemas);
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({ it("returns CLOSED ready state initially", () => {
id: 7, const client = createTransportClient({});
type: "identification", expect(client.readyState()).toBe(3); // CLOSED
data: { });
challenge: "Zm9v",
public_key: "Zm9v", it("rejects send when not connected", async () => {
const { 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 streamBytes = concatBytes([ const client = createTransportClient(schemas);
frameBytes(ignoredFrame),
frameBytes(identificationFrame), await expectRejection(
]); client.send("ping", {}),
const restoreWebTransport = installFakeWebTransport( "Transport is not connected",
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"); it("calls readyStateChange callback", async () => {
const { readyResolve, MockWebTransport } = createMockWebTransport();
const globalWithWebTransport = globalThis as unknown as {
WebTransport?: new (url: string) => MockTransportInstance;
};
globalWithWebTransport.WebTransport = MockWebTransport;
const response = client.send("identification", { user_id: 1 }, { id: 7 }); const readyStateChanges: number[] = [];
const client = createTransportClient(
const timeout = new Promise<never>((_, reject) => { {},
setTimeout(() => { {
reject(new Error("identification response timed out")); onReadyStateChange: (state) => readyStateChanges.push(state),
}, 250);
});
const result = await Promise.race([response, timeout]);
expect(result).toEqual({
id: 7,
type: "identification",
data: {
challenge: "Zm9v",
public_key: "Zm9v",
}, },
});
expect(consoleSpy.calls);
await client.close("test-complete");
} finally {
consoleSpy.restore();
restoreLocalStorage();
restoreWebTransport();
}
});
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",
},
});
const identificationFrame = encodeCommunicationMessage({
id: 7,
type: "identification",
data: {
challenge: "Zm9v",
public_key: "Zm9v",
},
});
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();
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<never>((_, 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 logMessages = consoleSpy.calls
.filter((entry) => typeof entry[0] === "string")
.map((entry) => entry[0] as string);
expect(logMessages).toContain(
`[Socket] Outgoing binary message (${requestFrame.byteLength} bytes)`,
);
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( const connectPromise = client.connect("http://localhost:8000");
(entry) => readyResolve();
entry[0] === await connectPromise;
`[Socket] Outgoing binary message (${requestFrame.byteLength} bytes)`,
expect(readyStateChanges).toContain(0); // CONNECTING
expect(readyStateChanges).toContain(1); // OPEN
});
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 closeEvents: Array<{ intentional: boolean; error?: unknown }> = [];
const client = createTransportClient(
{},
{
onClose: (event) => closeEvents.push(event),
},
); );
expect(outgoingCall?.[1]).toEqual(requestFrame);
const incomingCall = consoleSpy.calls.find( const connectPromise = client.connect("http://localhost:8000");
(entry) => readyResolve();
entry[0] === await connectPromise;
`[Socket] Incoming binary message (${identificationTransportFrame.byteLength} bytes)`,
);
expect(incomingCall?.[1]).toEqual(identificationTransportFrame);
await client.close("test-complete"); const closePromise = client.close();
} finally { closedResolve();
consoleSpy.restore(); await closePromise;
restoreLocalStorage();
restoreWebTransport(); expect(closeEvents.length > 0).toBe(true);
} expect(closeEvents[closeEvents.length - 1].intentional).toBe(true);
}); });
test("resolves an identification response before the stream closes", async () => { it("rejects pending requests on close", async () => {
const ignoredFrame = encodeCommunicationMessage({ const { readyResolve, closedResolve, MockWebTransport } =
id: 0, createMockWebTransport();
type: "error_internal", const globalWithWebTransport = globalThis as unknown as {
data: { WebTransport?: new (url: string) => MockTransportInstance;
error_type: "transport_noise", };
globalWithWebTransport.WebTransport = MockWebTransport;
const schemas: SchemaMap = {
ping: {
request: z.object({}),
response: z.object({}),
}, },
}); };
const identificationFrame = encodeCommunicationMessage({ const client = createTransportClient(schemas);
id: 7, const connectPromise = client.connect("http://localhost:8000");
type: "identification", readyResolve();
data: { await connectPromise;
challenge: "Zm9v",
public_key: "Zm9v",
},
});
const streamBytes = concatBytes([ const sendPromise = client.send("ping", {});
frameBytes(ignoredFrame), const closePromise = client.close();
frameBytes(identificationFrame), closedResolve();
]); await closePromise;
const restoreWebTransport = installFakeWebTransport(
chunkBytes(streamBytes, [2, 5, 1, 7]),
);
const restoreLocalStorage = installFakeLocalStorage();
try { await expectRejection(sendPromise);
const client = createTransportClient(socket, {
url: "https://example.test",
}); });
});
await client.connect("https://example.test"); describe("Push subscriptions", () => {
it("subscribes and unsubscribes from push events", () => {
const client = createTransportClient({});
const response = client.send("identification", { user_id: 1 }, { id: 7 }); const handler = () => {};
const unsubscribe = client.subscribePush(handler);
const timeout = new Promise<never>((_, reject) => { expect(typeof unsubscribe).toBe("function");
setTimeout(() => { unsubscribe();
reject(new Error("identification response timed out")); });
}, 250);
}); });
const result = await Promise.race([response, timeout]); describe("Data type encoding", () => {
it("encodes boolean true", () => {
const message: TypedMessage = {
id: 1,
type: "message",
data: { signed: true },
};
expect(result).toEqual({ const encoded = encodeCommunicationMessage(message);
id: 7, const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
type: "identification",
data: { expect(decoded.data.signed).toBe(true);
challenge: "Zm9v",
public_key: "Zm9v",
},
}); });
it("encodes boolean false", () => {
const message: TypedMessage = {
id: 1,
type: "message",
data: { signed: false },
};
await client.close("test-complete"); const encoded = encodeCommunicationMessage(message);
} finally { const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
restoreLocalStorage();
restoreWebTransport(); 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));
test("resolves an identification response that arrives after another frame on the same stream", async () => { expect(decoded.data.user_id).toBe(42);
const ignoredFrame = encodeCommunicationMessage({ expect(decoded.data.sender_id).toBe(100);
id: 0,
type: "error_internal",
data: {
error_type: "transport_noise",
},
}); });
const identificationFrame = encodeCommunicationMessage({ it("encodes strings", () => {
id: 7, const message: TypedMessage = {
type: "identification", id: 1,
data: { type: "message",
challenge: "Zm9v", data: { content: "test message", username: "alice" },
public_key: "Zm9v", };
},
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));
const streamBytes = concatBytes([ expect(decoded.data.status).toBe(null);
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"); it("encodes arrays of numbers", () => {
const message: TypedMessage = {
id: 1,
type: "message",
data: { user_ids: [1, 2, 3] },
};
const response = client.send("identification", { user_id: 1 }, { id: 7 }); const encoded = encodeCommunicationMessage(message);
const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
const timeout = new Promise<never>((_, reject) => { expect(decoded.data.user_ids).toEqual([1, 2, 3]);
setTimeout(() => {
reject(new Error("identification response timed out"));
}, 250);
}); });
const result = await Promise.race([response, timeout]); it("encodes nested containers", () => {
const message: TypedMessage = {
id: 1,
type: "message",
data: { user: { username: "alice", display: "Alice" } },
};
expect(result).toEqual({ const encoded = encodeCommunicationMessage(message);
id: 7, const decoded = decodeCommunicationMessage(wrapForDecode(encoded));
type: "identification",
data: { expect(decoded.data.user).toEqual({
challenge: "Zm9v", username: "alice",
public_key: "Zm9v", display: "Alice",
}, });
}); });
await client.close("test-complete");
} finally {
restoreLocalStorage();
restoreWebTransport();
}
}); });
}); });

View file

@ -61,6 +61,11 @@ type ActiveConnection = {
streamReader: ReadableStreamDefaultReader<ReadableStream<Uint8Array>> | null; streamReader: ReadableStreamDefaultReader<ReadableStream<Uint8Array>> | null;
intentional: boolean; intentional: boolean;
closeNotified: boolean; closeNotified: boolean;
acceptLoopDone: Promise<void> | null;
resolveAcceptLoopDone: (() => void) | null;
activeIncomingTasks: Set<Promise<void>>;
sendStream: WritableStream<Uint8Array> | null;
sendWriter: WritableStreamDefaultWriter<Uint8Array> | null;
}; };
/** /**
@ -532,32 +537,6 @@ export function createTransportClient<T extends SchemaMap>(
notifyClosed(connection, error); 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. * Handles decoded incoming messages and resolves request promises or push listeners.
* @param message Decoded incoming message. * @param message Decoded incoming message.
@ -680,40 +659,88 @@ export function createTransportClient<T extends SchemaMap>(
const startIncomingLoop = (connection: ActiveConnection) => { const startIncomingLoop = (connection: ActiveConnection) => {
connection.streamReader = connection.streamReader =
connection.transport.incomingUnidirectionalStreams.getReader(); connection.transport.incomingUnidirectionalStreams.getReader();
connection.acceptLoopDone = new Promise<void>((resolve) => {
connection.resolveAcceptLoopDone = resolve;
});
void (async () => { void (async () => {
try { try {
while (currentConnection === connection && !connection.intentional) { while (!connection.closeNotified) {
const result = await connection.streamReader?.read(); 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) { if (!result || result.done) {
break; break;
} }
const closedByPeer = await processIncomingStream( const shouldDiscardFrames =
connection.intentional || currentConnection !== connection;
const task = (async () => {
try {
await processIncomingStream(
result.value, result.value,
connection, connection,
handleIncomingMessage, handleIncomingMessage,
handleRecoverableDecodeFailure, handleRecoverableDecodeFailure,
handleConnectionFailure, handleConnectionFailure,
shouldDiscardFrames,
); );
} catch (error) {
if (closedByPeer) { log(
return; 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( handleConnectionFailure(
connection, connection,
new Error("Transport stream closed"), new Error("Transport stream closed"),
); );
} }
} catch (error) { } catch (error) {
log(0, "Socket", "red", "Incoming transport stream failed", error); log(0, "Socket", "red", "Incoming stream accept loop failed", error);
handleConnectionFailure(connection, error); handleConnectionFailure(connection, error);
} finally { } finally {
connection.streamReader?.releaseLock(); connection.streamReader?.releaseLock();
connection.streamReader = null; connection.streamReader = null;
const resolveAcceptLoopDone = connection.resolveAcceptLoopDone;
connection.resolveAcceptLoopDone = null;
resolveAcceptLoopDone?.();
} }
})(); })();
}; };
@ -756,6 +783,11 @@ export function createTransportClient<T extends SchemaMap>(
streamReader: null, streamReader: null,
intentional: false, intentional: false,
closeNotified: false, closeNotified: false,
acceptLoopDone: null,
resolveAcceptLoopDone: null,
activeIncomingTasks: new Set(),
sendStream: null,
sendWriter: null,
}; };
currentConnection = connection; currentConnection = connection;
@ -793,21 +825,23 @@ export function createTransportClient<T extends SchemaMap>(
connection.intentional = true; connection.intentional = true;
setReadyState(READY_STATE.CLOSING); setReadyState(READY_STATE.CLOSING);
const acceptLoopDone = connection.acceptLoopDone;
rejectPending(new Error("Transport closed")); rejectPending(new Error("Transport closed"));
try {
connection.sendWriter?.releaseLock();
await connection.sendStream?.abort();
} catch {
// Ignore errors during stream abort
}
try { try {
await writeCloseFrame(connection.transport); await writeCloseFrame(connection.transport);
} catch (error) { } catch (error) {
log(1, "Socket", "yellow", "Failed to send close sentinel", 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 { try {
connection.transport.close({ connection.transport.close({
closeCode: APPLICATION_CLOSE_CODE, closeCode: APPLICATION_CLOSE_CODE,
@ -819,6 +853,10 @@ export function createTransportClient<T extends SchemaMap>(
try { try {
await connection.transport.closed.catch(() => undefined); await connection.transport.closed.catch(() => undefined);
await acceptLoopDone;
if (connection.activeIncomingTasks.size > 0) {
await Promise.allSettled([...connection.activeIncomingTasks]);
}
} finally { } finally {
notifyClosed(connection); notifyClosed(connection);
} }
@ -898,9 +936,9 @@ export function createTransportClient<T extends SchemaMap>(
}); });
if (!expectsResponse) { if (!expectsResponse) {
return writeMessage(connection.transport, messageBytes).catch( return writeMessageOnPersistentStream(connection, messageBytes).catch(
(error) => { (error) => {
closeFromStopSending(connection, error); handleConnectionFailure(connection, error);
throw error; throw error;
}, },
); );
@ -923,12 +961,14 @@ export function createTransportClient<T extends SchemaMap>(
timeoutId, timeoutId,
}); });
void writeMessage(connection.transport, messageBytes).catch((error) => { void writeMessageOnPersistentStream(connection, messageBytes).catch(
closeFromStopSending(connection, error); (error) => {
handleConnectionFailure(connection, error);
clearTimeout(timeoutId); clearTimeout(timeoutId);
pending.delete(requestId); pending.delete(requestId);
reject(error); reject(error);
}); },
);
}); });
} catch (error) { } catch (error) {
return Promise.reject(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. * Ensures outbound message payloads are plain object records.
* @param value Candidate payload. * @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. * 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. * @param payload Encoded message payload bytes.
* @returns Promise that resolves when frame writing is complete. * @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) { if (payload.byteLength >= CLOSE_FRAME_LEN) {
throw new Error("Message too large for transport frame"); throw new Error("Message too large for transport frame");
} }
const stream = await transport.createUnidirectionalStream();
const writer = stream.getWriter();
try {
const frame = new Uint8Array(4 + payload.byteLength); const frame = new Uint8Array(4 + payload.byteLength);
writeU32(frame, 0, payload.byteLength); writeU32(frame, 0, payload.byteLength);
frame.set(payload, 4); frame.set(payload, 4);
logBinaryMessage("Outgoing", frame); const writeAndCatch = async (): Promise<boolean> => {
try {
await writer.write(frame); if (!connection.sendStream || !connection.sendWriter) {
await writer.close(); connection.sendStream =
} finally { await connection.transport.createUnidirectionalStream();
writer.releaseLock(); connection.sendWriter = connection.sendStream.getWriter();
} }
logBinaryMessage("Outgoing", frame);
await connection.sendWriter.write(frame);
return true;
} catch {
return false;
}
};
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 connection Active connection instance.
* @param handleIncomingFrame Handler for decoded protocol messages. * @param handleIncomingFrame Handler for decoded protocol messages.
* @param handleDecodeFailure Handler for recoverable frame decode failures. * @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. * @returns True when the peer close sentinel was received.
*/ */
async function processIncomingStream( async function processIncomingStream(
@ -1229,9 +1261,11 @@ async function processIncomingStream(
handleIncomingFrame: (message: TypedMessage) => void, handleIncomingFrame: (message: TypedMessage) => void,
handleDecodeFailure: (error: RecoverableMessageDecodeError) => void, handleDecodeFailure: (error: RecoverableMessageDecodeError) => void,
handleStreamFailure: (connection: ActiveConnection, error?: unknown) => void, handleStreamFailure: (connection: ActiveConnection, error?: unknown) => void,
discardFrames: boolean,
) { ) {
const reader = stream.getReader(); const reader = stream.getReader();
let bufferedBytes = new Uint8Array(0) as Uint8Array<ArrayBufferLike>; let bufferedBytes = new Uint8Array(0) as Uint8Array<ArrayBufferLike>;
let peerCloseDetected = false;
try { try {
while (true) { while (true) {
@ -1242,10 +1276,18 @@ async function processIncomingStream(
bufferedBytes = appendBytes(bufferedBytes, value); bufferedBytes = appendBytes(bufferedBytes, value);
if (discardFrames || peerCloseDetected) {
bufferedBytes = new Uint8Array(0) as Uint8Array<ArrayBufferLike>;
continue;
}
while (bufferedBytes.byteLength >= 4) { while (bufferedBytes.byteLength >= 4) {
const declaredLength = readU32(bufferedBytes, 0); const declaredLength = readU32(bufferedBytes, 0);
if (declaredLength === CLOSE_FRAME_LEN) { if (declaredLength === CLOSE_FRAME_LEN) {
peerCloseDetected = true;
bufferedBytes = new Uint8Array(0) as Uint8Array<ArrayBufferLike>;
try { try {
connection.transport.close({ connection.transport.close({
closeCode: APPLICATION_CLOSE_CODE, closeCode: APPLICATION_CLOSE_CODE,
@ -1259,7 +1301,7 @@ async function processIncomingStream(
connection, connection,
new Error("Transport closed by peer"), new Error("Transport closed by peer"),
); );
return true; break;
} }
const expectedLength = 4 + declaredLength; const expectedLength = 4 + declaredLength;
@ -1277,20 +1319,24 @@ async function processIncomingStream(
} catch (error) { } catch (error) {
if (error instanceof RecoverableMessageDecodeError) { if (error instanceof RecoverableMessageDecodeError) {
handleDecodeFailure(error); 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"); throw new Error("Received truncated transport frame");
} }
return false; return peerCloseDetected;
} finally { } finally {
// We cancel the reader to signal the stream is naturally dropped, matching Rust's receiver behavior.
reader.cancel().catch(() => {});
reader.releaseLock(); reader.releaseLock();
} }
} }

View file

@ -1,5 +1,7 @@
export const RESPONSE_TIMEOUT = 15_000; export const RESPONSE_TIMEOUT = 15_000;
export const RETRY_COUNT = 10; export const RETRY_COUNT = 10;
export const RETRY_INTERVAL = 3_000; 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 TRANSPORT_URL = "https://methanium.net:959";
export const RECONNECT_TRIES = 3;
export const RECONNECT_RESET = 6;