From 90d092a2dfbe57bc68f44364d825726aa58142b9 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 05:49:21 +0100 Subject: [PATCH] feat(core): add a requester-side timeout to sendManageRequest A held-open manage-request (a human approval, a not-yet-online peer) previously had no way for the caller to give up on it -- a never-answered request left the returned promise pending forever. An optional timeoutMs races the pending response against a timer, resolving { result: "error", code: "timeout" } instead of hanging; the pendingManageRequests entry is deleted atomically with the timer firing, so a response arriving just after the deadline is correctly dropped rather than resolving an already-settled promise. --- ts/packages/core/src/domain/mesh-session.ts | 21 ++++++-- ts/packages/core/test/mesh-session.test.ts | 53 +++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) 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);