mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
fix(desktop): do not retry terminal receiver initialization
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user