[Updt] Mtp 0.3.0
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Failing after 3m32s
/ build-desktop (linux) (push) Failing after 2m47s
/ build-mobile (push) Failing after 5m29s
/ release (push) Has been skipped
Some checks failed
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Failing after 3m32s
/ build-desktop (linux) (push) Failing after 2m47s
/ build-mobile (push) Failing after 5m29s
/ release (push) Has been skipped
This commit is contained in:
parent
57c7ceb27a
commit
2a55c87df1
33 changed files with 1825 additions and 785 deletions
|
|
@ -1,6 +1,62 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { isPushType, validateResponse } from "./context";
|
||||
import { RequestIdAllocator } from "./requestIds";
|
||||
|
||||
vi.mock("@methanium/ui", () => ({
|
||||
toast: {
|
||||
dismiss: vi.fn(),
|
||||
error: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tensamin/shared/log", () => ({ log: vi.fn() }));
|
||||
vi.mock("@tensamin/storage/context", () => ({
|
||||
useStorage: () => ({ load: vi.fn() }),
|
||||
}));
|
||||
|
||||
const { completeInitialSynchronization, isPushType, validateResponse } =
|
||||
await import("./context");
|
||||
|
||||
const validState = {
|
||||
SessionId: 7,
|
||||
VersionNumber: 2,
|
||||
CacheSchemaVersion: 0,
|
||||
SyncMode: "full",
|
||||
Contacts: [],
|
||||
Communities: [],
|
||||
Calls: [],
|
||||
Messages: [],
|
||||
DeletedMessageIds: [],
|
||||
DeletedContactIds: [],
|
||||
};
|
||||
|
||||
function mockInitialSyncClient(
|
||||
state: { type: string; data: unknown } = {
|
||||
type: "ClientStateSync",
|
||||
data: validState,
|
||||
},
|
||||
acknowledgement: unknown = { type: "ClientStateAck", data: {} },
|
||||
) {
|
||||
const handlers = new Map<string, (message: never) => void>();
|
||||
const request = vi.fn().mockResolvedValue(acknowledgement);
|
||||
const client = {
|
||||
auth: vi.fn(async () => {
|
||||
handlers.get(state.type)?.(state as never);
|
||||
}),
|
||||
subscribe: vi.fn((type: string, handler: (message: never) => void) => {
|
||||
handlers.set(type, handler);
|
||||
return () => handlers.delete(type);
|
||||
}),
|
||||
request,
|
||||
disconnect: vi.fn(),
|
||||
} as unknown as Parameters<typeof completeInitialSynchronization>[0];
|
||||
return { client, handlers, request };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("MTP protocol dispatch", () => {
|
||||
it("preserves protocol errors for the request layer", () => {
|
||||
|
|
@ -19,3 +75,146 @@ describe("MTP protocol dispatch", () => {
|
|||
expect(isPushType("UnknownMessage")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser initial synchronization", () => {
|
||||
it("validates state, sends a nonzero acknowledgement, then resolves", async () => {
|
||||
const { client, request } = mockInitialSyncClient();
|
||||
|
||||
const state = await completeInitialSynchronization(
|
||||
client,
|
||||
new RequestIdAllocator(),
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(state.data).toEqual(validState);
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
"ClientStateAck",
|
||||
{ SessionId: 7, VersionNumber: 2 },
|
||||
{ id: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not acknowledge malformed state", async () => {
|
||||
const { client, request } = mockInitialSyncClient({
|
||||
type: "ClientStateSync",
|
||||
data: { ...validState, SyncMode: "invalid" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
completeInitialSynchronization(
|
||||
client,
|
||||
new RequestIdAllocator(),
|
||||
new AbortController().signal,
|
||||
),
|
||||
).rejects.toThrow("Response validation failed");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects ErrorNoIota during initial synchronization", async () => {
|
||||
const { client, request } = mockInitialSyncClient({
|
||||
type: "ErrorNoIota",
|
||||
data: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
completeInitialSynchronization(
|
||||
client,
|
||||
new RequestIdAllocator(),
|
||||
new AbortController().signal,
|
||||
),
|
||||
).rejects.toThrow("No Iota is currently connected");
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an acknowledgement protocol error", async () => {
|
||||
const { client } = mockInitialSyncClient(undefined, {
|
||||
type: "ErrorInvalidData",
|
||||
data: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
completeInitialSynchronization(
|
||||
client,
|
||||
new RequestIdAllocator(),
|
||||
new AbortController().signal,
|
||||
),
|
||||
).rejects.toThrow("State acknowledgement failed: ErrorInvalidData");
|
||||
});
|
||||
|
||||
it("times out a missing acknowledgement", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { client } = mockInitialSyncClient();
|
||||
vi.mocked(client.request).mockReturnValue(new Promise(() => {}));
|
||||
const result = completeInitialSynchronization(
|
||||
client,
|
||||
new RequestIdAllocator(),
|
||||
new AbortController().signal,
|
||||
100,
|
||||
10,
|
||||
);
|
||||
const assertion = expect(result).rejects.toThrow(
|
||||
"State acknowledgement timed out",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it("stops immediately when the connection attempt is cancelled", async () => {
|
||||
const { client } = mockInitialSyncClient();
|
||||
vi.mocked(client.auth).mockImplementation(() => new Promise(() => {}));
|
||||
const controller = new AbortController();
|
||||
const result = completeInitialSynchronization(
|
||||
client,
|
||||
new RequestIdAllocator(),
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
controller.abort(new Error("MTP connection lost"));
|
||||
await expect(result).rejects.toThrow("MTP connection lost");
|
||||
});
|
||||
|
||||
it("times out authentication while waiting for initial state", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { client, handlers } = mockInitialSyncClient();
|
||||
vi.mocked(client.auth).mockImplementation(() => {
|
||||
handlers.get("ClientStateSync")?.({
|
||||
type: "ClientStateSync",
|
||||
data: validState,
|
||||
} as never);
|
||||
return new Promise(() => {});
|
||||
});
|
||||
const result = completeInitialSynchronization(
|
||||
client,
|
||||
new RequestIdAllocator(),
|
||||
new AbortController().signal,
|
||||
100,
|
||||
10,
|
||||
);
|
||||
const assertion = expect(result).rejects.toThrow(
|
||||
"MTP authentication timed out",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it("uses a fresh request ID namespace for each connection", async () => {
|
||||
const first = mockInitialSyncClient();
|
||||
const second = mockInitialSyncClient();
|
||||
|
||||
await completeInitialSynchronization(
|
||||
first.client,
|
||||
new RequestIdAllocator(),
|
||||
new AbortController().signal,
|
||||
);
|
||||
await completeInitialSynchronization(
|
||||
second.client,
|
||||
new RequestIdAllocator(),
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
expect(first.request.mock.calls[0]?.[2]).toEqual({ id: 1 });
|
||||
expect(second.request.mock.calls[0]?.[2]).toEqual({ id: 1 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,7 +27,20 @@ import { log } from "@tensamin/shared/log";
|
|||
import { ProtocolError } from "@tensamin/shared/errors";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
|
||||
import { RECONNECT_RESET, RECONNECT_TRIES, RETRY_INTERVAL } from "./values";
|
||||
import { fromWireMessage, toWireData } from "./protocolFields";
|
||||
import { RequestIdAllocator } from "./requestIds";
|
||||
import {
|
||||
DISCOVERY_TIMEOUT,
|
||||
INITIAL_SYNC_TIMEOUT,
|
||||
RECONNECT_JITTER,
|
||||
RECONNECT_LONG_INTERVAL,
|
||||
RECONNECT_RESET,
|
||||
RECONNECT_TRIES,
|
||||
RETRY_INTERVAL,
|
||||
STATE_ACK_TIMEOUT,
|
||||
} from "./values";
|
||||
|
||||
type BrowserMtpClient = Awaited<ReturnType<typeof MTPClient.create>>;
|
||||
|
||||
function base64ToUint8Array(b64: string) {
|
||||
const bin = atob(b64);
|
||||
|
|
@ -51,7 +64,6 @@ export type ProtocolMessage<
|
|||
export type BoundSendFn = <T extends keyof Schemas & string>(
|
||||
type: T,
|
||||
data?: z.infer<Schemas[T]["request"]>,
|
||||
options?: { id?: number },
|
||||
) => Promise<ProtocolMessage<T>>;
|
||||
|
||||
export type PushHandler = (message: ProtocolMessage) => void | Promise<void>;
|
||||
|
|
@ -72,6 +84,29 @@ export function isPushType(type: string): boolean {
|
|||
return (PUSH_TYPES as readonly string[]).includes(type);
|
||||
}
|
||||
|
||||
function normalizeMtpMessage<T extends { data: unknown }>(message: T): T {
|
||||
return fromWireMessage(message);
|
||||
}
|
||||
|
||||
async function requestWithId(
|
||||
client: BrowserMtpClient,
|
||||
ids: RequestIdAllocator,
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
) {
|
||||
let id: number;
|
||||
try {
|
||||
id = ids.allocate();
|
||||
} catch (error) {
|
||||
client.disconnect();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return client.request(type, toWireData(data) as Record<string, unknown>, {
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
function removeMissingContacts(
|
||||
contacts: Contacts,
|
||||
message: ProtocolMessage,
|
||||
|
|
@ -161,6 +196,120 @@ export function validateResponse<T extends keyof Schemas & string>(
|
|||
} as ProtocolMessage<T>;
|
||||
}
|
||||
|
||||
function abortError(signal: AbortSignal): Error {
|
||||
return signal.reason instanceof Error
|
||||
? signal.reason
|
||||
: new Error("Initial state synchronization was cancelled");
|
||||
}
|
||||
|
||||
function withDeadline<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
timeoutMessage: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
if (signal.aborted) {
|
||||
reject(abortError(signal));
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
const finish = (complete: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
complete();
|
||||
};
|
||||
const timeout = setTimeout(
|
||||
() => finish(() => reject(new Error(timeoutMessage))),
|
||||
timeoutMs,
|
||||
);
|
||||
const onAbort = () => finish(() => reject(abortError(signal)));
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
promise.then(
|
||||
(value) => finish(() => resolve(value)),
|
||||
(error: unknown) => finish(() => reject(error)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeInitialSynchronization(
|
||||
client: BrowserMtpClient,
|
||||
ids: RequestIdAllocator,
|
||||
signal: AbortSignal,
|
||||
syncTimeoutMs = INITIAL_SYNC_TIMEOUT,
|
||||
ackTimeoutMs = STATE_ACK_TIMEOUT,
|
||||
): Promise<ProtocolMessage<"ClientStateSync">> {
|
||||
const stateSync = new Promise<ProtocolMessage<"ClientStateSync">>(
|
||||
(resolve, reject) => {
|
||||
let unsubscribeStateSync = () => {};
|
||||
let unsubscribeNoIota = () => {};
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribeStateSync();
|
||||
unsubscribeNoIota();
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
reject(abortError(signal));
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Initial state synchronization timed out"));
|
||||
}, syncTimeoutMs);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
unsubscribeStateSync = client.subscribe("ClientStateSync", (message) => {
|
||||
cleanup();
|
||||
try {
|
||||
resolve(
|
||||
validateResponse("ClientStateSync", normalizeMtpMessage(message)),
|
||||
);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
unsubscribeNoIota = client.subscribe("ErrorNoIota", () => {
|
||||
cleanup();
|
||||
reject(new Error("No Iota is currently connected"));
|
||||
});
|
||||
},
|
||||
);
|
||||
const [, state] = await Promise.all([
|
||||
withDeadline(
|
||||
client.auth(),
|
||||
syncTimeoutMs,
|
||||
"MTP authentication timed out",
|
||||
signal,
|
||||
),
|
||||
stateSync,
|
||||
]);
|
||||
if (state.type.startsWith("Error")) {
|
||||
throw new Error(`State synchronization failed: ${state.type}`);
|
||||
}
|
||||
const acknowledgement = normalizeMtpMessage(
|
||||
await withDeadline(
|
||||
requestWithId(client, ids, "ClientStateAck", {
|
||||
SessionId: state.data.SessionId,
|
||||
VersionNumber: state.data.VersionNumber,
|
||||
}),
|
||||
ackTimeoutMs,
|
||||
"State acknowledgement timed out",
|
||||
signal,
|
||||
),
|
||||
);
|
||||
if (acknowledgement.type.startsWith("Error")) {
|
||||
throw new Error(`State acknowledgement failed: ${acknowledgement.type}`);
|
||||
}
|
||||
if (signal.aborted) throw abortError(signal);
|
||||
return state;
|
||||
}
|
||||
|
||||
function useMessageHandlers() {
|
||||
const interceptorsRef = useRef(new Set<MTPInterceptor>());
|
||||
const pushHandlersRef = useRef(new Set<PushHandler>());
|
||||
|
|
@ -205,6 +354,7 @@ function BrowserProvider(props: {
|
|||
const clientRef = useRef<Awaited<ReturnType<typeof MTPClient.create>> | null>(
|
||||
null,
|
||||
);
|
||||
const requestIdsRef = useRef<RequestIdAllocator | null>(null);
|
||||
const {
|
||||
addInterceptor,
|
||||
interceptorsRef,
|
||||
|
|
@ -223,19 +373,24 @@ function BrowserProvider(props: {
|
|||
|
||||
// Validation override functions
|
||||
const send: BoundSendFn = useMemo(
|
||||
() => async (type, data, options) => {
|
||||
() => async (type, data) => {
|
||||
const client = clientRef.current;
|
||||
const ids = requestIdsRef.current;
|
||||
|
||||
if (!client) {
|
||||
throw new Error("mtp is not connected");
|
||||
}
|
||||
if (!ids) {
|
||||
throw new Error("MTP request allocator is unavailable");
|
||||
}
|
||||
|
||||
const message = await client.request(
|
||||
const rawMessage = await requestWithId(
|
||||
client,
|
||||
ids,
|
||||
type,
|
||||
(data ?? {}) as Record<string, unknown>,
|
||||
options,
|
||||
);
|
||||
const response = validateResponse(type, message);
|
||||
const response = validateResponse(type, normalizeMtpMessage(rawMessage));
|
||||
setFreshContacts((contacts) => removeMissingContacts(contacts, response));
|
||||
if (response.type.startsWith("Error")) {
|
||||
const errorData = response.data as Record<string, unknown>;
|
||||
|
|
@ -260,7 +415,7 @@ function BrowserProvider(props: {
|
|||
}
|
||||
|
||||
return client.subscribe(type, (message) => {
|
||||
handler(validateResponse(type, message));
|
||||
handler(validateResponse(type, normalizeMtpMessage(message)));
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
|
@ -291,33 +446,39 @@ function BrowserProvider(props: {
|
|||
|
||||
const scheduleReconnect = (error: unknown) => {
|
||||
if (disposed || reconnectScheduled) return;
|
||||
if (attempts >= RECONNECT_TRIES) {
|
||||
attempts += 1;
|
||||
const shortRetry = attempts <= RECONNECT_TRIES;
|
||||
if (!shortRetry) {
|
||||
log(0, "mtp", "red", "Reconnection attempts exhausted", error);
|
||||
sonnerToast.error("Connection failed", {
|
||||
id: "mtp-connection-toast",
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message.split(":")[0]
|
||||
: "Connection lost",
|
||||
? `${error.message.split(":")[0]}. Retrying in the background.`
|
||||
: "Connection lost. Retrying in the background.",
|
||||
icon: null,
|
||||
duration: Infinity,
|
||||
closeButton: true,
|
||||
promise: null,
|
||||
} as unknown as Parameters<typeof sonnerToast.error>[1]);
|
||||
return;
|
||||
} else {
|
||||
sonnerToast.loading(
|
||||
`Reconnecting to server... (attempt ${attempts} of ${RECONNECT_TRIES})`,
|
||||
{ id: "mtp-connection-toast" },
|
||||
);
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
sonnerToast.loading(
|
||||
`Reconnecting to server... (attempt ${attempts} of ${RECONNECT_TRIES})`,
|
||||
{ id: "mtp-connection-toast" },
|
||||
);
|
||||
const baseDelay = shortRetry ? RETRY_INTERVAL : RECONNECT_LONG_INTERVAL;
|
||||
const jitter = 1 + (Math.random() * 2 - 1) * RECONNECT_JITTER;
|
||||
reconnectScheduled = true;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectScheduled = false;
|
||||
reconnectTimer = null;
|
||||
void connect();
|
||||
}, RETRY_INTERVAL);
|
||||
reconnectTimer = setTimeout(
|
||||
() => {
|
||||
reconnectScheduled = false;
|
||||
reconnectTimer = null;
|
||||
void connect();
|
||||
},
|
||||
Math.round(baseDelay * jitter),
|
||||
);
|
||||
};
|
||||
|
||||
async function connect() {
|
||||
|
|
@ -326,10 +487,16 @@ function BrowserProvider(props: {
|
|||
const generation = ++connectionGeneration;
|
||||
let client: Awaited<ReturnType<typeof MTPClient.create>> | null = null;
|
||||
let failed = false;
|
||||
let connectionReady = false;
|
||||
const attemptAbort = new AbortController();
|
||||
const cleanup = () => {
|
||||
attemptAbort.abort(
|
||||
new Error("Initial state synchronization was cancelled"),
|
||||
);
|
||||
client?.disconnect();
|
||||
if (clientRef.current === client) {
|
||||
clientRef.current = null;
|
||||
requestIdsRef.current = null;
|
||||
}
|
||||
clearReconnectResetTimer();
|
||||
if (generation === connectionGeneration) {
|
||||
|
|
@ -359,19 +526,18 @@ function BrowserProvider(props: {
|
|||
omikronPublicKey = forcedOmikronPublicKey;
|
||||
} else {
|
||||
log(2, "mtp", "purple", "Fetching Omikron data.");
|
||||
const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`);
|
||||
const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`, {
|
||||
signal: AbortSignal.any([
|
||||
attemptAbort.signal,
|
||||
AbortSignal.timeout(DISCOVERY_TIMEOUT),
|
||||
]),
|
||||
});
|
||||
|
||||
if (data.status === 404) {
|
||||
sonnerToast.error("We couldn't reach your Iota", {
|
||||
description:
|
||||
"Check your network connection and try restarting your Iota",
|
||||
icon: null,
|
||||
duration: Infinity,
|
||||
closeButton: true,
|
||||
});
|
||||
resolveConnectionRef.current?.();
|
||||
cleanup();
|
||||
return;
|
||||
throw new Error("No Omikron assignment is currently available");
|
||||
}
|
||||
if (!data.ok) {
|
||||
throw new Error(`Omikron discovery failed: HTTP ${data.status}`);
|
||||
}
|
||||
const omikronData = (await data.json()) as {
|
||||
id: number;
|
||||
|
|
@ -404,7 +570,10 @@ function BrowserProvider(props: {
|
|||
clientId: userId,
|
||||
keyring: base64ToUint8Array(keyring),
|
||||
},
|
||||
hostPublicKey: omikronPublicKey,
|
||||
hostPublicKey: {
|
||||
value: omikronPublicKey,
|
||||
encoding: "base64",
|
||||
},
|
||||
descriptor: "client",
|
||||
pings: true,
|
||||
logger: (event) => {
|
||||
|
|
@ -418,10 +587,12 @@ function BrowserProvider(props: {
|
|||
!failed
|
||||
) {
|
||||
failed = true;
|
||||
clientRef.current = null;
|
||||
setIdentified(false);
|
||||
setIdentifying(false);
|
||||
scheduleReconnect(new Error("MTP connection lost"));
|
||||
const error = new Error("MTP connection lost");
|
||||
attemptAbort.abort(error);
|
||||
if (connectionReady) {
|
||||
cleanup();
|
||||
scheduleReconnect(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -456,11 +627,12 @@ function BrowserProvider(props: {
|
|||
const activeClient = client;
|
||||
|
||||
clientRef.current = activeClient;
|
||||
requestIdsRef.current = new RequestIdAllocator();
|
||||
for (const type of PUSH_TYPES) {
|
||||
activeClient.subscribe(type, (message) => {
|
||||
let validated: ProtocolMessage;
|
||||
try {
|
||||
validated = validateResponse(type, message);
|
||||
validated = validateResponse(type, normalizeMtpMessage(message));
|
||||
} catch (error) {
|
||||
log(1, "mtp", "red", "Failed to validate push message", error, {
|
||||
type,
|
||||
|
|
@ -482,80 +654,45 @@ function BrowserProvider(props: {
|
|||
if (validated.type === "GetStates") {
|
||||
lastInitialStateRef.current = validated;
|
||||
}
|
||||
if (
|
||||
validated.type === "ErrorNoIota" &&
|
||||
clientRef.current === activeClient &&
|
||||
!failed
|
||||
) {
|
||||
failed = true;
|
||||
const error = new Error("No Iota is currently connected");
|
||||
attemptAbort.abort(error);
|
||||
cleanup();
|
||||
scheduleReconnect(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
setReadyState(activeClient.state);
|
||||
|
||||
clearReconnectTimer();
|
||||
|
||||
// Schedule reconnect reset
|
||||
clearReconnectResetTimer();
|
||||
reconnectResetTimer = setTimeout(() => {
|
||||
attempts = 0;
|
||||
reconnectResetTimer = null;
|
||||
}, RECONNECT_RESET * 1_000);
|
||||
|
||||
setReadyState(activeClient.state);
|
||||
setIdentifying(true);
|
||||
|
||||
const stateSync = new Promise<ProtocolMessage<"ClientStateSync">>(
|
||||
(resolve, reject) => {
|
||||
let unsubscribeStateSync = () => {};
|
||||
let unsubscribeNoIota = () => {};
|
||||
const cleanupStateSync = () => {
|
||||
clearTimeout(timeout);
|
||||
unsubscribeStateSync();
|
||||
unsubscribeNoIota();
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
cleanupStateSync();
|
||||
reject(new Error("Initial state synchronization timed out"));
|
||||
}, 120_000);
|
||||
unsubscribeStateSync = activeClient.subscribe(
|
||||
"ClientStateSync",
|
||||
(message) => {
|
||||
cleanupStateSync();
|
||||
try {
|
||||
resolve(validateResponse("ClientStateSync", message));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
unsubscribeNoIota = activeClient.subscribe("ErrorNoIota", () => {
|
||||
cleanupStateSync();
|
||||
reject(new Error("No Iota is currently connected"));
|
||||
});
|
||||
},
|
||||
const ids = requestIdsRef.current;
|
||||
if (!ids) throw new Error("MTP request allocator is unavailable");
|
||||
const finalResponse = await completeInitialSynchronization(
|
||||
activeClient,
|
||||
ids,
|
||||
attemptAbort.signal,
|
||||
);
|
||||
const [, finalResponse] = await Promise.all([
|
||||
activeClient.auth(),
|
||||
stateSync,
|
||||
]);
|
||||
|
||||
if (finalResponse.type.startsWith("Error")) {
|
||||
throw new Error(
|
||||
`State synchronization failed: ${finalResponse.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
const acknowledgement = await activeClient.request("ClientStateAck", {
|
||||
SessionId: finalResponse.data.SessionId,
|
||||
VersionNumber: finalResponse.data.VersionNumber,
|
||||
});
|
||||
if (acknowledgement.type.startsWith("Error")) {
|
||||
throw new Error(
|
||||
`State acknowledgement failed: ${acknowledgement.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (disposed || clientRef.current !== activeClient) return;
|
||||
|
||||
setFreshContacts(finalResponse.data.Contacts);
|
||||
setFreshCommunities(finalResponse.data.Communities);
|
||||
setFreshCalls(finalResponse.data.Calls);
|
||||
connectionReady = true;
|
||||
setIdentifying(false);
|
||||
setIdentified(true);
|
||||
|
||||
clearReconnectTimer();
|
||||
clearReconnectResetTimer();
|
||||
reconnectResetTimer = setTimeout(() => {
|
||||
attempts = 0;
|
||||
reconnectResetTimer = null;
|
||||
}, RECONNECT_RESET * 1_000);
|
||||
resolveConnectionRef.current?.();
|
||||
} catch (connectError) {
|
||||
if (disposed || generation !== connectionGeneration) {
|
||||
|
|
@ -589,6 +726,7 @@ function BrowserProvider(props: {
|
|||
|
||||
clientRef.current?.disconnect();
|
||||
clientRef.current = null;
|
||||
requestIdsRef.current = null;
|
||||
setReadyState(ConnectionState.Disconnected);
|
||||
setIdentified(false);
|
||||
setIdentifying(false);
|
||||
|
|
@ -650,9 +788,9 @@ function BrowserProvider(props: {
|
|||
}, [connected, identified, mtpUrl, send, subscribe, subscribePush, mtpRef]);
|
||||
|
||||
const sendQueued: BoundSendFn = useMemo(
|
||||
() => async (type, data, options) => {
|
||||
() => async (type, data) => {
|
||||
const mtp = await mtpRef.get();
|
||||
const response = await mtp.send(type, data, options);
|
||||
const response = await mtp.send(type, data);
|
||||
for (const interceptor of interceptorsRef.current) {
|
||||
void Promise.resolve(
|
||||
interceptor({ type, data, response: response as ProtocolMessage }),
|
||||
|
|
@ -724,16 +862,32 @@ function TauriProvider(props: {
|
|||
if (next.error) {
|
||||
log(0, "android", "orange", "MTP connection failed", next.error);
|
||||
}
|
||||
setSnapshot(next);
|
||||
if (!next.identified || next.state === undefined) return;
|
||||
if (!next.identified) {
|
||||
setSnapshot(next);
|
||||
return;
|
||||
}
|
||||
if (next.state === undefined) {
|
||||
setSnapshot({
|
||||
...next,
|
||||
identified: false,
|
||||
error: "Native MTP connection omitted initial state",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const parsed = schemas.ClientStateSync.response.safeParse(next.state);
|
||||
if (!parsed.success) {
|
||||
log(0, "mtp", "red", "Invalid native MTP state", parsed.error);
|
||||
setSnapshot({
|
||||
...next,
|
||||
identified: false,
|
||||
error: "Invalid ClientStateSync payload",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setFreshContacts(parsed.data.Contacts);
|
||||
setFreshCommunities(parsed.data.Communities);
|
||||
setFreshCalls(parsed.data.Calls);
|
||||
setSnapshot(next);
|
||||
}, []);
|
||||
|
||||
const dispatchMessage = useCallback(
|
||||
|
|
@ -744,7 +898,7 @@ function TauriProvider(props: {
|
|||
try {
|
||||
validated = validateResponse(
|
||||
message.type as keyof Schemas & string,
|
||||
message,
|
||||
normalizeMtpMessage(message),
|
||||
);
|
||||
} catch (error) {
|
||||
log(1, "mtp", "red", "Failed to validate native MTP message", error);
|
||||
|
|
@ -846,13 +1000,12 @@ function TauriProvider(props: {
|
|||
}, [props.blockConnection]);
|
||||
|
||||
const send = useCallback<BoundSendFn>(
|
||||
async (type, data, options) => {
|
||||
async (type, data) => {
|
||||
const response = await invoke<ProtocolMessage>("mtp_request", {
|
||||
typeName: type,
|
||||
data: data ?? {},
|
||||
id: options?.id,
|
||||
});
|
||||
const validated = validateResponse(type, response);
|
||||
const validated = validateResponse(type, normalizeMtpMessage(response));
|
||||
setFreshContacts((contacts) =>
|
||||
removeMissingContacts(contacts, validated),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
export { Provider, useMTP } from "./context";
|
||||
export { RequestIdAllocator } from "./requestIds";
|
||||
export type {
|
||||
BoundSendFn,
|
||||
MTPExchange,
|
||||
|
|
|
|||
44
packages/mtp/src/protocolFields.test.ts
Normal file
44
packages/mtp/src/protocolFields.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { fromWireData, toWireData } from "./protocolFields";
|
||||
|
||||
describe("MTP protocol field translation", () => {
|
||||
it("maps application message fields to MTP wire fields", () => {
|
||||
expect(
|
||||
toWireData({
|
||||
Messages: [{ Content: "abc", MessageId: 4 }],
|
||||
}),
|
||||
).toEqual({
|
||||
Messages: [{ AppContent: "abc", AppMessageId: 4 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps MTP wire fields back to application fields", () => {
|
||||
expect(
|
||||
fromWireData({
|
||||
AppCreatedAt: 123,
|
||||
Message: { AppContent: "abc", AppMessageId: 4 },
|
||||
}),
|
||||
).toEqual({
|
||||
CreatedAt: 123,
|
||||
Message: { Content: "abc", MessageId: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves byte arrays and unrelated fields", () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const translated = toWireData({ Payload: bytes, Other: "value" }) as {
|
||||
Payload: Uint8Array;
|
||||
Other: string;
|
||||
};
|
||||
|
||||
expect(translated.Payload).toBe(bytes);
|
||||
expect(translated.Other).toBe("value");
|
||||
});
|
||||
|
||||
it("rejects field mapping collisions", () => {
|
||||
expect(() => toWireData({ Content: "a", AppContent: "b" })).toThrow(
|
||||
"MTP field translation collision",
|
||||
);
|
||||
});
|
||||
});
|
||||
58
packages/mtp/src/protocolFields.ts
Normal file
58
packages/mtp/src/protocolFields.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
const APPLICATION_TO_WIRE_FIELDS = {
|
||||
Content: "AppContent",
|
||||
CreatedAt: "AppCreatedAt",
|
||||
MessageId: "AppMessageId",
|
||||
} as const;
|
||||
|
||||
const WIRE_TO_APPLICATION_FIELDS = {
|
||||
AppContent: "Content",
|
||||
AppCreatedAt: "CreatedAt",
|
||||
AppMessageId: "MessageId",
|
||||
} as const;
|
||||
|
||||
function mapProtocolFields(
|
||||
value: unknown,
|
||||
fieldMap: Readonly<Record<string, string>>,
|
||||
): unknown {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value !== "object" ||
|
||||
value instanceof Uint8Array
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => mapProtocolFields(item, fieldMap));
|
||||
}
|
||||
|
||||
const source = value as Record<string, unknown>;
|
||||
const target: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, child] of Object.entries(source)) {
|
||||
const mappedKey = fieldMap[key] ?? key;
|
||||
|
||||
if (mappedKey in target) {
|
||||
throw new Error(`MTP field translation collision for ${mappedKey}`);
|
||||
}
|
||||
|
||||
target[mappedKey] = mapProtocolFields(child, fieldMap);
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
export function toWireData(value: unknown): unknown {
|
||||
return mapProtocolFields(value, APPLICATION_TO_WIRE_FIELDS);
|
||||
}
|
||||
|
||||
export function fromWireData(value: unknown): unknown {
|
||||
return mapProtocolFields(value, WIRE_TO_APPLICATION_FIELDS);
|
||||
}
|
||||
|
||||
export function fromWireMessage<T extends { data: unknown }>(message: T): T {
|
||||
return {
|
||||
...message,
|
||||
data: fromWireData(message.data),
|
||||
};
|
||||
}
|
||||
25
packages/mtp/src/requestIds.test.ts
Normal file
25
packages/mtp/src/requestIds.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { RequestIdAllocator } from "./requestIds";
|
||||
|
||||
describe("MTP request ID allocation", () => {
|
||||
it("allocates nonzero request IDs monotonically", () => {
|
||||
const ids = new RequestIdAllocator();
|
||||
|
||||
expect(ids.allocate()).toBe(1);
|
||||
expect(ids.allocate()).toBe(2);
|
||||
expect(new RequestIdAllocator().allocate()).toBe(1);
|
||||
});
|
||||
|
||||
it("does not wrap exhausted request IDs", () => {
|
||||
const ids = new RequestIdAllocator(0x1_0000_0000);
|
||||
|
||||
expect(() => ids.allocate()).toThrow("MTP request ID space exhausted");
|
||||
});
|
||||
|
||||
it("rejects invalid allocator states", () => {
|
||||
expect(() => new RequestIdAllocator(0)).toThrow(
|
||||
"invalid MTP request ID allocator state",
|
||||
);
|
||||
});
|
||||
});
|
||||
24
packages/mtp/src/requestIds.ts
Normal file
24
packages/mtp/src/requestIds.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
const MAX_MTP_REQUEST_ID = 0xffff_ffff;
|
||||
|
||||
export class RequestIdAllocator {
|
||||
#next: number;
|
||||
|
||||
constructor(next = 1) {
|
||||
if (
|
||||
!Number.isSafeInteger(next) ||
|
||||
next <= 0 ||
|
||||
next > MAX_MTP_REQUEST_ID + 1
|
||||
) {
|
||||
throw new RangeError("invalid MTP request ID allocator state");
|
||||
}
|
||||
this.#next = next;
|
||||
}
|
||||
|
||||
allocate(): number {
|
||||
if (this.#next > MAX_MTP_REQUEST_ID) {
|
||||
throw new Error("MTP request ID space exhausted for this connection");
|
||||
}
|
||||
|
||||
return this.#next++;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,8 @@
|
|||
export const RETRY_INTERVAL = 3_000;
|
||||
export const RECONNECT_TRIES = 3;
|
||||
export const RECONNECT_RESET = 6;
|
||||
export const RECONNECT_LONG_INTERVAL = 60_000;
|
||||
export const RECONNECT_JITTER = 0.2;
|
||||
export const DISCOVERY_TIMEOUT = 20_000;
|
||||
export const INITIAL_SYNC_TIMEOUT = 120_000;
|
||||
export const STATE_ACK_TIMEOUT = 30_000;
|
||||
|
|
|
|||
Loading…
Reference in a new issue