Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions ts/packages/core/src/domain/mesh-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,10 +760,12 @@ function createSessionCore(
return exchange;
}

let requestTimer: ReturnType<typeof setTimeout> | undefined;

return Promise.race([
exchange,
new Promise<ManageOutcome>((resolve) => {
setTimeout(() => {
requestTimer = setTimeout(() => {
if (pendingManageRequests.delete(requestId)) {
// A relay-connect has no acknowledgement and the hub gives no notice when a pairing is lost (the target reconnected, or the hub itself restarted), so this session cannot otherwise tell a live pairing from a dead one. An unanswered relayed request is the only evidence it gets, and keeping the pairing would blackhole every later request to that device for as long as this connection lives, whereas forgetting it costs one extra relay-connect. The secure channel that rode the pairing goes with it, since a peer that reconnected no longer holds it.
if (targetDevice !== undefined) {
Expand All @@ -774,7 +776,9 @@ function createSessionCore(
}
}, timeoutMs);
}),
]);
]).finally(() => {
clearTimeout(requestTimer);
});
},
async sendRevocationAnnounce(
entries: readonly RevocationEntry[],
Expand Down
160 changes: 160 additions & 0 deletions ts/packages/core/test/mesh-session-request-timeouts.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createMeshSession } from "../src/domain/mesh-session.js";
import type {
ManageCommand,
ManageRequestFrame,
} from "../src/generated/protocol.js";
import {
fakeTransport,
MANAGE_REQUEST_TIMEOUT_MS,
testClock,
testIdentity,
} from "./mesh-session-fixtures.js";

const command: ManageCommand = {
verb: "exec:proc",
params: { verb: "exec.list" },
};
const scope = { kind: "folder" } as const;
const sessions: ReturnType<typeof createMeshSession>[] = [];

async function connected() {
const { transport, connection } = fakeTransport();
const link = connection.connection;
const session = createMeshSession(
{ ...transport, connect: async () => Promise.resolve(link) },
testIdentity,
testClock,
);
sessions.push(session);
await session.connect("ws://node", ["core/management"]);

return { session, connection, link };
}

beforeEach(() => {
vi.useFakeTimers();
});

afterEach(async () => {
await Promise.all(sessions.splice(0).map(async (session) => session.close()));
vi.useRealTimers();
});

describe("manage-request timeout cleanup", () => {
it.each([
{ result: "ok" } as const,
{ result: "error", code: "denied" } as const,
])("clears the timer after a response: %j", async (outcome) => {
const { session, connection } = await connected();
const baseline = vi.getTimerCount();
const pending = session.sendManageRequest(
command,
scope,
undefined,
undefined,
MANAGE_REQUEST_TIMEOUT_MS,
);
await vi.advanceTimersByTimeAsync(0);
const sent = connection.sent.at(-1) as ManageRequestFrame;
connection.push({
type: "manage-response",
"request-id": sent["request-id"],
outcome,
});
await expect(pending).resolves.toEqual(outcome);
expect(vi.getTimerCount()).toBe(baseline);
});

it("clears the timer when the session closes before the response", async () => {
const { session } = await connected();
const pending = session.sendManageRequest(
command,
scope,
undefined,
undefined,
MANAGE_REQUEST_TIMEOUT_MS,
);
const rejected = expect(pending).rejects.toThrow(
"connection closed before a response arrived",
);
await session.close();
await rejected;
expect(vi.getTimerCount()).toBe(0);
});

it("clears the timer when the connection drops", async () => {
const { session, connection } = await connected();
const pending = session.sendManageRequest(
command,
scope,
undefined,
undefined,
MANAGE_REQUEST_TIMEOUT_MS,
);
const rejected = expect(pending).rejects.toThrow(
"disconnected before a response arrived",
);
connection.fail(new Error("dropped"));
await rejected;
await session.close();
expect(vi.getTimerCount()).toBe(0);
});

it("clears the timer when transmission fails", async () => {
const { session, link } = await connected();
const baseline = vi.getTimerCount();
vi.spyOn(link, "send").mockRejectedValueOnce(new Error("write failed"));
await expect(
session.sendManageRequest(
command,
scope,
undefined,
undefined,
MANAGE_REQUEST_TIMEOUT_MS,
),
).rejects.toThrow("write failed");
expect(vi.getTimerCount()).toBe(baseline);
});

it("clears only the answered request's timer while another can still time out", async () => {
const { session, connection } = await connected();
const baseline = vi.getTimerCount();
const first = session.sendManageRequest(
command,
scope,
undefined,
undefined,
MANAGE_REQUEST_TIMEOUT_MS,
);
const second = session.sendManageRequest(
command,
scope,
undefined,
undefined,
MANAGE_REQUEST_TIMEOUT_MS,
);
const secondOutcome = second.catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(0);
const requests = connection.sent.filter(
(frame): frame is ManageRequestFrame => frame.type === "manage-request",
);
expect(requests).toHaveLength(2);
const request = requests[0];
if (request === undefined) throw new Error("missing request");
connection.push({
type: "manage-response",
"request-id": request["request-id"],
outcome: { result: "ok" },
});
await expect(first).resolves.toEqual({ result: "ok" });
expect(vi.getTimerCount()).toBe(baseline + 1);
await vi.advanceTimersByTimeAsync(MANAGE_REQUEST_TIMEOUT_MS);
await expect(secondOutcome).resolves.toEqual({
result: "error",
code: "timeout",
});
await session.close();
expect(vi.getTimerCount()).toBe(0);
});
});
Loading