Skip to content

Commit 60bef44

Browse files
committed
fix(gateway): cap pre-hello normal-close suppression
1 parent 8bbc5b6 commit 60bef44

2 files changed

Lines changed: 66 additions & 7 deletions

File tree

src/gateway/client.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,48 @@ describe("GatewayClient reconnect behavior", () => {
595595
vi.useRealTimers();
596596
}
597597
});
598+
599+
it("emits a failure signal after repeated pre-hello 1000 closes", async () => {
600+
vi.useFakeTimers();
601+
const onClose = vi.fn();
602+
const onConnectError = vi.fn();
603+
const client = new GatewayClient({
604+
url: "ws://127.0.0.1:18789",
605+
onClose,
606+
onConnectError,
607+
});
608+
609+
try {
610+
(client as unknown as { backoffMs: number }).backoffMs = 1;
611+
client.start();
612+
613+
for (let attempt = 1; attempt <= 4; attempt += 1) {
614+
const ws = getLatestWs();
615+
ws.emitOpen();
616+
ws.emitMessage(
617+
JSON.stringify({
618+
type: "event",
619+
event: "connect.challenge",
620+
payload: { nonce: `nonce-${attempt}` },
621+
}),
622+
);
623+
ws.emitClose(1000, "");
624+
625+
// Allow the reconnect timer to schedule the next socket.
626+
await vi.advanceTimersByTimeAsync(30_000);
627+
}
628+
629+
for (let i = 0; i < 5; i += 1) {
630+
await Promise.resolve();
631+
}
632+
633+
expect(onConnectError).toHaveBeenCalled();
634+
expect(onClose).toHaveBeenCalledWith(1000, "");
635+
} finally {
636+
client.stop();
637+
vi.useRealTimers();
638+
}
639+
});
598640
});
599641

600642
describe("GatewayClient connect auth payload", () => {

src/gateway/client.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ export function resolveGatewayClientConnectChallengeTimeoutMs(
180180

181181
const FORCE_STOP_TERMINATE_GRACE_MS = 250;
182182
const STOP_AND_WAIT_TIMEOUT_MS = 1_000;
183+
const MAX_TRANSIENT_PREHELLO_NORMAL_CLOSES = 3;
183184

184185
type PendingStop = {
185186
ws: WebSocket;
@@ -202,7 +203,9 @@ export class GatewayClient {
202203
private deviceTokenRetryBudgetUsed = false;
203204
private pendingConnectErrorDetailCode: string | null = null;
204205
private helloOkReceived = false;
205-
private suppressNextConnectError = false;
206+
private connectAttempt = 0;
207+
private suppressConnectErrorAttempts = new Set<number>();
208+
private transientPrehelloNormalCloseCount = 0;
206209
// Track last tick to detect silent stalls.
207210
private lastTick: number | null = null;
208211
private tickIntervalMs = 30_000;
@@ -313,9 +316,19 @@ export class GatewayClient {
313316
ws.on("message", (data) => this.handleMessage(rawDataToString(data)));
314317
ws.on("close", (code, reason) => {
315318
const reasonText = rawDataToString(reason);
316-
const isTransientPrehelloNormalClose =
319+
const isPrehelloNormalCloseCandidate =
317320
!this.closed && !this.helloOkReceived && code === 1000 && reasonText.trim().length === 0;
318-
this.suppressNextConnectError = isTransientPrehelloNormalClose;
321+
if (isPrehelloNormalCloseCandidate) {
322+
this.transientPrehelloNormalCloseCount += 1;
323+
} else {
324+
this.transientPrehelloNormalCloseCount = 0;
325+
}
326+
const isTransientPrehelloNormalClose =
327+
isPrehelloNormalCloseCandidate &&
328+
this.transientPrehelloNormalCloseCount <= MAX_TRANSIENT_PREHELLO_NORMAL_CLOSES;
329+
if (isTransientPrehelloNormalClose && this.connectSent) {
330+
this.suppressConnectErrorAttempts.add(this.connectAttempt);
331+
}
319332

320333
const connectErrorDetailCode = this.pendingConnectErrorDetailCode;
321334
this.pendingConnectErrorDetailCode = null;
@@ -405,6 +418,8 @@ export class GatewayClient {
405418

406419
private beginStop(): Promise<void> | null {
407420
this.closed = true;
421+
this.suppressConnectErrorAttempts.clear();
422+
this.transientPrehelloNormalCloseCount = 0;
408423
this.pendingDeviceTokenRetry = false;
409424
this.deviceTokenRetryBudgetUsed = false;
410425
this.pendingConnectErrorDetailCode = null;
@@ -550,10 +565,13 @@ export class GatewayClient {
550565
device,
551566
};
552567

568+
const connectAttempt = this.connectAttempt;
569+
553570
void this.request<HelloOk>("connect", params)
554571
.then((helloOk) => {
555572
this.helloOkReceived = true;
556-
this.suppressNextConnectError = false;
573+
this.suppressConnectErrorAttempts.clear();
574+
this.transientPrehelloNormalCloseCount = 0;
557575
this.pendingDeviceTokenRetry = false;
558576
this.deviceTokenRetryBudgetUsed = false;
559577
this.pendingConnectErrorDetailCode = null;
@@ -576,8 +594,7 @@ export class GatewayClient {
576594
this.opts.onHelloOk?.(helloOk);
577595
})
578596
.catch((err) => {
579-
if (this.suppressNextConnectError) {
580-
this.suppressNextConnectError = false;
597+
if (this.suppressConnectErrorAttempts.delete(connectAttempt)) {
581598
return;
582599
}
583600
this.pendingConnectErrorDetailCode =
@@ -830,8 +847,8 @@ export class GatewayClient {
830847
}
831848

832849
private beginPreauthHandshake() {
850+
this.connectAttempt += 1;
833851
this.helloOkReceived = false;
834-
this.suppressNextConnectError = false;
835852
if (this.connectSent) {
836853
return;
837854
}

0 commit comments

Comments
 (0)