diff --git a/ts/packages/core/src/domain/mesh-session.ts b/ts/packages/core/src/domain/mesh-session.ts index 9dcb281..ed12574 100644 --- a/ts/packages/core/src/domain/mesh-session.ts +++ b/ts/packages/core/src/domain/mesh-session.ts @@ -95,12 +95,13 @@ export interface MeshSession { sendRevocationAnnounce: ( entries: readonly RevocationEntry[], ) => Promise; - /** Sends a manage-request and resolves with the matching manage-response's outcome, correlated by request-id. When targetDevice is given, the request is routed to that specific peer via an established relay-connect pairing (wrapped as relay-data) rather than sent directly over this session's own Connection -- relay-hub deliberately drops manage-request/manage-response frames sent to it directly, since routing between two connected peers is not the relay role's business, so a specific peer reachable only through a relay hub can only be addressed this way. Absent, this sends directly over the Connection exactly as before. When token is given, it is attached to this one request instead of whatever setToken last set -- a single session routinely needs a different token per request when its peer shares more than one scope with this side (e.g. several core/room memberships over one connection), and a session-global token can only ever be correct for one of them. Absent, this request carries setToken's own session-global token exactly as before. */ + /** Sends a manage-request and resolves with the matching manage-response's outcome, correlated by request-id. When targetDevice is given, the request is routed to that specific peer via an established relay-connect pairing (wrapped as relay-data) rather than sent directly over this session's own Connection -- relay-hub deliberately drops manage-request/manage-response frames sent to it directly, since routing between two connected peers is not the relay role's business, so a specific peer reachable only through a relay hub can only be addressed this way. Absent, this sends directly over the Connection exactly as before. When token is given, it is attached to this one request instead of whatever setToken last set -- a single session routinely needs a different token per request when its peer shares more than one scope with this side (e.g. several core/room memberships over one connection), and a session-global token can only ever be correct for one of them. Absent, this request carries setToken's own session-global token exactly as before. When timeoutMs is given, the returned promise resolves with `{ result: "error", code: "timeout" }` rather than hanging forever if no manage-response arrives in time -- a held-open request (a human approval, a not-yet-online peer) otherwise has no way for the caller to give up on it. Absent, this request waits exactly as before, with no time limit of its own. */ sendManageRequest: ( command: ManageCommand, scope: Readonly, targetDevice?: DeviceId, token?: CapabilityToken, + timeoutMs?: number, ) => Promise; close: () => Promise; } @@ -569,6 +570,7 @@ function createSessionCore( scope: Readonly, targetDevice?: DeviceId, token?: CapabilityToken, + timeoutMs?: number, ): Promise { if (connection === null || state.status !== "connected") { throw new Error("not connected"); @@ -577,13 +579,26 @@ function createSessionCore( await ensureRelayPairing(targetDevice); } const frame = buildManageRequest(command, scope, token); + const requestId = frame["request-id"]; const outcome = new Promise((resolve, reject) => { - pendingManageRequests.set(frame["request-id"], { resolve, reject }); + pendingManageRequests.set(requestId, { resolve, reject }); }); frameLog.push({ direction: "sent", frame }); await transmit(frame, targetDevice !== undefined); emit(); - return outcome; + if (timeoutMs === undefined) { + return outcome; + } + return Promise.race([ + outcome, + new Promise((resolve) => { + setTimeout(() => { + if (pendingManageRequests.delete(requestId)) { + resolve({ result: "error", code: "timeout" }); + } + }, timeoutMs); + }), + ]); }, async sendRevocationAnnounce( entries: readonly RevocationEntry[], diff --git a/ts/packages/core/test/mesh-session.test.ts b/ts/packages/core/test/mesh-session.test.ts index 9299eda..4a209b1 100644 --- a/ts/packages/core/test/mesh-session.test.ts +++ b/ts/packages/core/test/mesh-session.test.ts @@ -467,6 +467,7 @@ describe("reconnect policy", () => { const TEST_TOKEN_SIGNATURE_BYTE = 3; const TEST_INCOMING_REQUEST_ID = 7; const OVERRIDE_TOKEN_BYTE = 9; +const MANAGE_REQUEST_TIMEOUT_MS = 5000; describe("capability tokens and manage-request plumbing", () => { const testCommand: ManageCommand = { @@ -612,6 +613,58 @@ describe("capability tokens and manage-request plumbing", () => { ); }); + it("resolves with a timeout outcome, not a hang, when no response arrives within timeoutMs", async () => { + vi.useFakeTimers(); + try { + const { transport } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const pending = session.sendManageRequest( + testCommand, + testScope, + undefined, + undefined, + MANAGE_REQUEST_TIMEOUT_MS, + ); + await vi.advanceTimersByTimeAsync(MANAGE_REQUEST_TIMEOUT_MS); + await expect(pending).resolves.toEqual({ + result: "error", + code: "timeout", + }); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not time out a request whose response arrives before timeoutMs elapses", async () => { + vi.useFakeTimers(); + try { + const { transport, connection } = fakeTransport(); + const session = createMeshSession(transport, testIdentity, testClock); + await session.connect("ws://node", ["core/management"]); + const pending = session.sendManageRequest( + testCommand, + testScope, + undefined, + undefined, + MANAGE_REQUEST_TIMEOUT_MS, + ); + await vi.advanceTimersByTimeAsync(0); + const sentRequest = connection.sent.at(-1) as ManageRequestFrame; + connection.push({ + type: "manage-response", + "request-id": sentRequest["request-id"], + outcome: { result: "ok" }, + } satisfies ManageResponseFrame); + await expect(pending).resolves.toEqual({ result: "ok" }); + await vi.advanceTimersByTimeAsync(MANAGE_REQUEST_TIMEOUT_MS); + await session.close(); + } finally { + vi.useRealTimers(); + } + }); + it("surfaces an incoming manage-request on incomingManageRequests, and sends the response frame from respond()", async () => { const { transport, connection } = fakeTransport(); const session = createMeshSession(transport, testIdentity, testClock);