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);
setIdentified(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); setConnected(false);
setIdentified(false); setIdentified(false);
setIdentifying(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;

File diff suppressed because it is too large Load diff

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 =
result.value, connection.intentional || currentConnection !== connection;
connection,
handleIncomingMessage,
handleRecoverableDecodeFailure,
handleConnectionFailure,
);
if (closedByPeer) { const task = (async () => {
return; 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( 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) => {
clearTimeout(timeoutId); handleConnectionFailure(connection, error);
pending.delete(requestId); clearTimeout(timeoutId);
reject(error); pending.delete(requestId);
}); 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 frame = new Uint8Array(4 + payload.byteLength);
const writer = stream.getWriter(); writeU32(frame, 0, payload.byteLength);
frame.set(payload, 4);
try { const writeAndCatch = async (): Promise<boolean> => {
const frame = new Uint8Array(4 + payload.byteLength); try {
writeU32(frame, 0, payload.byteLength); if (!connection.sendStream || !connection.sendWriter) {
frame.set(payload, 4); 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); const firstResult = await writeAndCatch();
await writer.close(); if (firstResult) return;
} finally {
writer.releaseLock(); // 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;