fix(desktop): do not retry terminal receiver initialization

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-05 00:01:45 -04:00
parent 6bea46f78c
commit ef58314dd5
4 changed files with 65 additions and 4 deletions

View File

@@ -318,8 +318,10 @@ failures and transient CLOSED states recover through a bounded receiver-owner
budget; each attempt uses a fresh live-only subscription and repeats
projection-only sync before admission. Terminal closure or exhausted recovery
stays in the scope-owned notification, whose deliberate retry resets the receiver
budget. Recovery must discard queued callbacks from the retired receiver, not
retry an operation, and must respect the relay rate-limit gate.
budget. A known latched-terminal relay session also reports immediately during
initialization without consuming that budget; unknown and transient failures
remain bounded retries. Recovery must discard queued callbacks from the retired
receiver, not retry an operation, and must respect the relay rate-limit gate.
A readiness timeout is unconfirmed delivery, not a failed initialization; late
EOSE clears that warning after successful projection. An explicit operation retry republishes the exact request;
the relay redelivers stored Stop duplicates without repeating relay side effects.

View File

@@ -6,9 +6,17 @@ type Stage =
| "projection"
| "reconciliation";
export type LifecycleReceiverFailureClassification = "retryable" | "terminal";
export class LifecycleReceiverError extends Error {
readonly recoveryClassification: LifecycleReceiverFailureClassification;
constructor(stage: Stage, error: unknown) {
const value = error instanceof Error ? error.message : error;
const recoveryClassification =
value === "Relay session is terminal; cannot reconnect."
? "terminal"
: "retryable";
const reason =
value === "closed"
? "subscription closed"
@@ -20,6 +28,7 @@ export class LifecycleReceiverError extends Error {
? "history timed out"
: "request failed";
super(`Desktop lifecycle receiver is unavailable (${stage}: ${reason}).`);
this.recoveryClassification = recoveryClassification;
}
}

View File

@@ -133,6 +133,50 @@ test("first subscribe failure recovers without a reconnect callback and syncs be
assert.equal(closed, 1);
});
test("terminal relay-session initialization failure does not consume the recovery budget", async () => {
const clock = timers();
let subscribeCalls = 0;
const relay = {
getSessionEpoch: () => 1,
getConnectionGeneration: () => 1,
subscribeLive: async () => {
subscribeCalls++;
throw new Error("Relay session is terminal; cannot reconnect.");
},
fetchEvents: async () => assert.fail("terminal session must not sync"),
publishEvent: async () => {},
};
const errors = [];
ownLifecycleReceiver(
scope,
(error) => errors.push(error),
() => {},
{
...clock,
waitForRateLimit: async () => {},
startReceiver: (receiverScope, active, onError, onReady, onClosed) =>
receiveLifecycle(
receiverScope,
active,
onError,
async () =>
assert.fail("terminal session must not invoke native IPC"),
relay,
onReady,
onClosed,
),
},
);
await flushUntil(() => errors.length === 1);
assert.equal(subscribeCalls, 1);
assert.equal(clock.pending.size, 0);
assert.equal(
errors[0],
"Desktop lifecycle receiver is unavailable (subscription: relay session requires reconnection).",
);
});
test("transient CLOSED before readiness retires and replaces the whole receiver", async () => {
const clock = timers();
let subscribeCalls = 0;

View File

@@ -2,7 +2,10 @@ import type { LiveSubscriptionClosedRecovery } from "@/shared/api/relayClientSha
import { waitForRateLimit } from "@/shared/api/relayRateLimitGate";
import type { DesktopScope } from "./desktopList";
import { receiveLifecycle } from "./desktopLifecycle";
import { receiverErrorMessage } from "./desktopLifecycleDiagnostics";
import {
LifecycleReceiverError,
receiverErrorMessage,
} from "./desktopLifecycleDiagnostics";
export const RECEIVER_RECOVERY_DELAYS_MS = [1_000, 2_000, 4_000] as const;
@@ -121,7 +124,10 @@ export function ownLifecycleReceiver(
.catch((error) => {
if (current(token))
recover(token, receiverErrorMessage(error), {
classification: "retryable",
classification:
error instanceof LifecycleReceiverError
? error.recoveryClassification
: "retryable",
retryAfterMs: 0,
});
});