-
Notifications
You must be signed in to change notification settings - Fork 364
fix(desktop): bound Runtime Host handler waits #3795
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Where does 5s come from, and why is it a third of the reconciliation window? These bound the same underlying event — a Host candidate that is restarting — and this one is the harsher of the two: reconciliation expiry degrades to I have not measured Host restart time, so this is a question rather than a finding: if 5s came from a measurement, please put the number in the comment; if it was chosen to be "clearly shorter than reconciliation", I would rather see it match the 15s already in the file until there is a reason to differ. |
||
|
|
||
| export interface RuntimeHostReconnectingIpcMainOptions { | ||
| readonly reconciliationWaitTimeoutMs?: number; | ||
| readonly handlerWaitTimeoutMs?: number; | ||
| } | ||
|
|
||
| class ReconciliationWaitExpiredError extends Error { | ||
|
|
@@ -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, | ||
|
|
@@ -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( | ||
|
|
@@ -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 { | ||
|
|
@@ -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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P3] The bound is per wait, not per invocation. |
||
| () => 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(), | ||
|
|
@@ -314,6 +345,7 @@ export class RuntimeHostReconnectingIpcMain { | |
| epoch: string, | ||
| previous?: BoundHandler, | ||
| timeoutMs?: number, | ||
| timeoutError: () => Error = () => new ReconciliationWaitExpiredError(), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P3] This default is the opposite of what a caller that forgets the argument should get. The two errors are not interchangeable: Every current call site is correct, which is why this is P3 rather than higher. But #waitForHandler(slot, epoch, previous?, deadline?: { ms: number; error: () => Error })That also removes the |
||
| ): Promise<BoundHandler> { | ||
| try { | ||
| this.#assertActive(epoch); | ||
|
|
@@ -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; | ||
|
|
@@ -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); | ||
| } | ||
| }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"Preserve same-epoch reconnect routing when a replacement arrives within the window" is the property this change must not break, and it is the one bullet without a new test. The existing replacement test now runs against the 5s default, so it passes without ever approaching the boundary. A case where the replacement registers just inside a small configured window would lock the behaviour rather than rely on the default being generous. Not a finding, just the coverage I would want.