Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from "../ipc-reconnect-policy.js";
import * as ipcReconnectPolicy from "../ipc-reconnect-policy.js";
import {
RuntimeHostHandlerUnavailableError,
RuntimeHostReconnectingIpcMain,
RuntimeHostTargetChangedError,
} from "../runtime-host-reconnecting-ipc-main.js";
Expand Down Expand Up @@ -338,6 +339,56 @@ test("holds an invocation across a Runtime Host candidate replacement", async ()
assert.equal(ipc.size, 0);
});

test("bounds invocation while an active Runtime Host has no handler", async () => {
const ipc = ipcHarness();
const router = new RuntimeHostReconnectingIpcMain(ipc, {
handlerWaitTimeoutMs: 5,
});
const target = router.createTarget("target-a");
target.handle("sessions:send", async () => "sent");
router.activate("target-a");
target.removeHandler("sessions:send");

await assert.rejects(
() => ipc.invoke("sessions:send", scope("target-a")),
RuntimeHostHandlerUnavailableError,
);
target.handle("sessions:send", async () => "retried");
assert.equal(
await ipc.invoke("sessions:send", scope("target-a")),
"retried",
);
router.close();
});

test("bounds reconnectable reads when no replacement handler becomes available", async () => {
const ipc = ipcHarness();
const router = new RuntimeHostReconnectingIpcMain(ipc, {
handlerWaitTimeoutMs: 5,
});
const target = router.createTarget("target-a");
const failRead = deferred();
target.handleReconnectableRead?.("taskReadiness:getSnapshot", async () => {
await failRead.promise;
throw new RuntimeHostOperationError(
"session.catalog.query",
"host_draining",
"Runtime Host is draining",
);
});
router.activate("target-a");

const reading = ipc.invoke("taskReadiness:getSnapshot", scope("target-a"));
target.removeHandler("taskReadiness:getSnapshot");
failRead.resolve();

await assert.rejects(
() => reading,
RuntimeHostHandlerUnavailableError,
);
router.close();
});

test("does not return a late read from a replaced Runtime Host candidate", async () => {
const ipc = ipcHarness();
const router = new RuntimeHostReconnectingIpcMain(ipc);
Expand Down
40 changes: 36 additions & 4 deletions apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ type ReconcileIpcHandler = (
type ReconciliationUnavailableIpcHandler = ReconcileIpcHandler;

const DEFAULT_RECONCILIATION_WAIT_TIMEOUT_MS = 15_000;
const DEFAULT_HANDLER_WAIT_TIMEOUT_MS = 5_000;

export interface RuntimeHostReconnectingIpcMainOptions {
readonly reconciliationWaitTimeoutMs?: number;
readonly handlerWaitTimeoutMs?: number;
}

class ReconciliationWaitExpiredError extends Error {
Expand Down Expand Up @@ -81,6 +83,13 @@ export class RuntimeHostTargetChangedError extends Error {
}
}

export class RuntimeHostHandlerUnavailableError extends Error {
constructor() {
super("Runtime Host handler remained unavailable after the reconnection window");
this.name = "RuntimeHostHandlerUnavailableError";
}
}

/**
* Keeps Electron IPC registration stable across reconnects while fencing each
* target generation. Reconnectable reads may move to a replacement candidate,
Expand All @@ -91,6 +100,7 @@ export class RuntimeHostReconnectingIpcMain {
readonly #slots = new Map<string, HandlerSlot>();
readonly #activeEpochs = new Set<string>();
readonly #reconciliationWaitTimeoutMs: number;
readonly #handlerWaitTimeoutMs: number;
#closed = false;

constructor(
Expand All @@ -104,6 +114,12 @@ export class RuntimeHostReconnectingIpcMain {
throw new TypeError("Runtime Host reconciliation wait timeout must be positive");
}
this.#reconciliationWaitTimeoutMs = reconciliationWaitTimeoutMs;
const handlerWaitTimeoutMs =
options.handlerWaitTimeoutMs ?? DEFAULT_HANDLER_WAIT_TIMEOUT_MS;
if (!Number.isSafeInteger(handlerWaitTimeoutMs) || handlerWaitTimeoutMs <= 0) {
throw new TypeError("Runtime Host handler wait timeout must be positive");
}
this.#handlerWaitTimeoutMs = handlerWaitTimeoutMs;
}

createTarget(epoch: string): RuntimeHostTargetIpcMain {
Expand Down Expand Up @@ -233,14 +249,29 @@ export class RuntimeHostReconnectingIpcMain {
): Promise<unknown> {
const epoch = this.#requireTargetEpoch(args[0]);
let handler: BoundHandler =
slot.handlers.get(epoch) ?? await this.#waitForHandler(slot, epoch);
slot.handlers.get(epoch) ??
await this.#waitForHandler(
slot,
epoch,
undefined,
this.#handlerWaitTimeoutMs,
() => new RuntimeHostHandlerUnavailableError(),
);
let reconciliationContext: unknown;
let reconciling = false;
let reconciliationDeadline: number | undefined;
const waitForReplacement = async (
previous: BoundHandler,
): Promise<BoundHandler | undefined> => {
if (!reconciling) return this.#waitForHandler(slot, epoch, previous);
if (!reconciling) {
return this.#waitForHandler(
slot,
epoch,
previous,
this.#handlerWaitTimeoutMs,
() => new RuntimeHostHandlerUnavailableError(),
);
}
const remainingMs = Math.max(
0,
(reconciliationDeadline ?? Date.now()) - Date.now(),
Expand Down Expand Up @@ -314,6 +345,7 @@ export class RuntimeHostReconnectingIpcMain {
epoch: string,
previous?: BoundHandler,
timeoutMs?: number,
timeoutError: () => Error = () => new ReconciliationWaitExpiredError(),
): Promise<BoundHandler> {
try {
this.#assertActive(epoch);
Expand All @@ -325,7 +357,7 @@ export class RuntimeHostReconnectingIpcMain {
return Promise.resolve(current);
}
if (timeoutMs !== undefined && timeoutMs <= 0) {
return Promise.reject(new ReconciliationWaitExpiredError());
return Promise.reject(timeoutError());
}
return new Promise((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
Expand All @@ -344,7 +376,7 @@ export class RuntimeHostReconnectingIpcMain {
if (timeoutMs !== undefined) {
timeout = setTimeout(() => {
if (!slot.waiters.delete(waiter)) return;
waiter.reject(new ReconciliationWaitExpiredError());
waiter.reject(timeoutError());
}, timeoutMs);
}
});
Expand Down