Skip to content

Commit 885806d

Browse files
committed
fix(gateway): stop stale device token reconnect loops
1 parent 205d8d4 commit 885806d

5 files changed

Lines changed: 90 additions & 1 deletion

File tree

src/gateway/client.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,36 @@ describe("GatewayClient connect auth payload", () => {
890890
});
891891
});
892892

893+
it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => {
894+
loadDeviceAuthTokenMock.mockReturnValue({
895+
token: "stored-device-token",
896+
scopes: ["operator.read"],
897+
});
898+
const onReconnectPaused = vi.fn();
899+
const client = new GatewayClient({
900+
url: "ws://127.0.0.1:18789",
901+
onReconnectPaused,
902+
});
903+
904+
const { ws: ws1, connect: firstConnect } = startClientAndConnect({ client });
905+
expect(firstConnect.params?.auth?.token).toBe("stored-device-token");
906+
await expectNoReconnectAfterConnectFailure({
907+
client,
908+
firstWs: ws1,
909+
connectId: firstConnect.id,
910+
failureDetails: { code: "AUTH_DEVICE_TOKEN_MISMATCH" },
911+
});
912+
expect(clearDeviceAuthTokenMock).toHaveBeenCalledWith({
913+
deviceId: expect.any(String),
914+
role: "operator",
915+
});
916+
expect(onReconnectPaused).toHaveBeenCalledWith({
917+
code: 1008,
918+
reason: "connect failed",
919+
detailCode: "AUTH_DEVICE_TOKEN_MISMATCH",
920+
});
921+
});
922+
893923
it("does not auto-reconnect on token mismatch when retry is not trusted", async () => {
894924
loadDeviceAuthTokenMock.mockReturnValue({ token: "stored-device-token" });
895925
const client = new GatewayClient({

src/gateway/client.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,23 @@ export class GatewayClient {
594594
resolvedDeviceToken,
595595
storedToken: storedToken ?? undefined,
596596
});
597+
if (
598+
this.opts.deviceIdentity &&
599+
usingStoredDeviceToken &&
600+
err instanceof GatewayClientRequestError &&
601+
readConnectErrorDetailCode(err.details) ===
602+
ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH
603+
) {
604+
const deviceId = this.opts.deviceIdentity.deviceId;
605+
try {
606+
clearDeviceAuthToken({ deviceId, role });
607+
logDebug(`cleared stale device-auth token for device ${deviceId}`);
608+
} catch (clearErr) {
609+
logDebug(
610+
`failed clearing stale device-auth token for device ${deviceId}: ${String(clearErr)}`,
611+
);
612+
}
613+
}
597614
if (shouldRetryWithDeviceToken) {
598615
this.pendingDeviceTokenRetry = true;
599616
this.deviceTokenRetryBudgetUsed = true;
@@ -653,6 +670,7 @@ export class GatewayClient {
653670
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
654671
detailCode === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
655672
detailCode === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
673+
detailCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH ||
656674
detailCode === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
657675
detailCode === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
658676
detailCode === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED

src/gateway/reconnect-gating.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ describe("isNonRecoverableAuthError", () => {
4545
);
4646
});
4747

48+
it("blocks reconnect for AUTH_DEVICE_TOKEN_MISMATCH", () => {
49+
expect(
50+
isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH)),
51+
).toBe(true);
52+
});
53+
4854
it("blocks reconnect for PAIRING_REQUIRED", () => {
4955
expect(isNonRecoverableAuthError(makeError(ConnectErrorDetailCodes.PAIRING_REQUIRED))).toBe(
5056
true,

ui/src/ui/gateway.node.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,36 @@ describe("GatewayBrowserClient", () => {
515515

516516
vi.useRealTimers();
517517
});
518+
519+
it("clears stale stored device tokens and does not reconnect on AUTH_DEVICE_TOKEN_MISMATCH", async () => {
520+
vi.useFakeTimers();
521+
522+
const client = new GatewayBrowserClient({
523+
url: "ws://127.0.0.1:18789",
524+
});
525+
526+
const { ws, connectFrame } = await startConnect(client);
527+
expect(connectFrame.params?.auth?.token).toBe("stored-device-token");
528+
529+
ws.emitMessage({
530+
type: "res",
531+
id: connectFrame.id,
532+
ok: false,
533+
error: {
534+
code: "INVALID_REQUEST",
535+
message: "unauthorized",
536+
details: { code: "AUTH_DEVICE_TOKEN_MISMATCH" },
537+
},
538+
});
539+
await expectSocketClosed(ws);
540+
ws.emitClose(4008, "connect failed");
541+
542+
expect(loadDeviceAuthToken({ deviceId: "device-1", role: "operator" })).toBeNull();
543+
await vi.advanceTimersByTimeAsync(30_000);
544+
expect(wsInstances).toHaveLength(1);
545+
546+
vi.useRealTimers();
547+
});
518548
});
519549

520550
describe("shouldRetryWithDeviceToken", () => {

ui/src/ui/gateway.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export function isNonRecoverableAuthError(error: GatewayErrorInfo | undefined):
8686
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISSING ||
8787
code === ConnectErrorDetailCodes.AUTH_PASSWORD_MISMATCH ||
8888
code === ConnectErrorDetailCodes.AUTH_RATE_LIMITED ||
89+
code === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH ||
8990
code === ConnectErrorDetailCodes.PAIRING_REQUIRED ||
9091
code === ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED ||
9192
code === ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED
@@ -519,8 +520,12 @@ export class GatewayBrowserClient {
519520
} else {
520521
this.pendingConnectError = undefined;
521522
}
523+
const usedStoredDeviceToken =
524+
Boolean(plan.selectedAuth.storedToken) &&
525+
(plan.selectedAuth.resolvedDeviceToken === plan.selectedAuth.storedToken ||
526+
plan.selectedAuth.authDeviceToken === plan.selectedAuth.storedToken);
522527
if (
523-
plan.selectedAuth.canFallbackToShared &&
528+
usedStoredDeviceToken &&
524529
plan.deviceIdentity &&
525530
connectErrorCode === ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH
526531
) {

0 commit comments

Comments
 (0)