From 9feec4326f88f45aff2113651bbe3b4ad38d5966 Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Tue, 2 Jun 2026 19:51:16 +0200 Subject: [PATCH 01/15] feat(graphql): refactor service --- .../graphql-reconnect-resilience.spec.ts | 138 ++++++++++++++++++ .../lib/services/graphql/graphql.service.ts | 37 ++++- 2 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts diff --git a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts new file mode 100644 index 00000000..c87f044f --- /dev/null +++ b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts @@ -0,0 +1,138 @@ +import { WebSocket } from 'mock-socket'; + +import { GraphQLSubscriptionsFixture } from '../__fixtures__/graphql-subscriptions-fixture'; +import * as backoff from '../../../util/randomized-exponential-backoff/randomized-exponential-backoff'; + +jest.mock('isomorphic-ws', () => WebSocket); +jest.mock('../../../util/sleep-ms/sleep-ms', () => ({ + sleepMs: () => new Promise((resolve) => setTimeout(resolve, 4)), +})); + +/** + * Regression tests for the per-device API-key storm: a single iPad minting + * tens of thousands of DEVICE_SIGN_IN keys/day. Each reconnect fetches a fresh + * temporary key, so excessive/uncontrolled reconnects translate directly into + * excessive key creation. + */ +describe('GraphQL reconnect resilience', () => { + let fixture: GraphQLSubscriptionsFixture; + let backoffSpy: jest.SpyInstance; + + beforeEach(() => { + backoffSpy = jest.spyOn( + backoff, + 'calculateRandomizedExponentialBackoffTime', + ); + fixture = new GraphQLSubscriptionsFixture(); + }); + + afterEach(async () => { + jest.useRealTimers(); + await fixture.cleanup(); + jest.restoreAllMocks(); + }); + + // Bug 4: connectionAttemptsCount resets to 0 on every ACK, so a flapping + // connection that briefly connects then drops never backs off — it stays + // pinned at the 5s floor forever, maximizing key minting. + it('escalates reconnect backoff across repeated unstable connections', async () => { + const sub = fixture.triggerSubscription(); + + for (let cycle = 0; cycle < 3; cycle++) { + await fixture.handleConnectionInit(); + await fixture.consumeSubscribeMessage(); + // connection drops almost immediately — never stable + await fixture.closeWithCode(1001); + fixture.openServer(); + } + + const counters = backoffSpy.mock.calls.map((call) => call[0]); + // An unstable connection must back off progressively, not reset each time. + expect(counters).toEqual([0, 1, 2]); + + // Settle the final reconnect into a clean, terminal state so the service + // does not keep reconnecting on the shared port into the next test. + await fixture.handleConnectionInit(); + sub.unsubscribe(); + }); + + // Bug 2: monitorWithPingPong re-arms setInterval without clearing the prior + // one. Each extra ACK leaks a ping timer that keeps firing forever, and every + // leaked timer can independently drive a reconnect (= a new key). + it('does not leak ping intervals when the connection monitor restarts', async () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + const sub = fixture.triggerSubscription(); + await fixture.handleConnectionInit(); // ACK #1 -> ping monitor armed + await fixture.consumeSubscribeMessage(); + + // A second ACK on the same connection restarts monitoring. + fixture.sendMessageToClient({ type: 'connection_ack' }); + await fixture.consumeSubscribeMessage(); // resubscribe from the 2nd ACK + + const armedIds = setIntervalSpy.mock.results.map((result) => result.value); + const clearedIds = clearIntervalSpy.mock.calls.map((call) => call[0]); + const liveIntervals = armedIds.filter((id) => !clearedIds.includes(id)); + // Exactly one ping interval may be alive, no matter how often we re-monitor. + expect(liveIntervals).toHaveLength(1); + + sub.unsubscribe(); + }); + + // Bug 1: handleConnectionDrop (pong timeout) reconnects immediately via its + // own path, bypassing the backoff used by the socket's onclose handler. A + // flapping connection then reconnects with no throttling -> key storm. + it('reconnects through backoff after a pong timeout, not immediately', async () => { + jest.useFakeTimers(); + const sub = fixture.triggerSubscription(); + await jest.runAllTimersAsync(); + + await fixture.handleConnectionInit(); + await jest.runOnlyPendingTimersAsync(); + await fixture.consumeSubscribeMessage(); + + await jest.advanceTimersToNextTimerAsync(); // ping sent, pong timeout armed + await fixture.consumePingMessage(); + + await jest.runOnlyPendingTimersAsync(); // pong timeout fires -> drop + + // The pong-timeout drop must use the same backoff path as a socket close. + expect(backoffSpy).toHaveBeenCalled(); + + jest.useRealTimers(); + sub.unsubscribe(); + }); + + // Observability: log how long the device was offline so the frequency and + // duration of network loss is visible per device in the logs. + it('logs how long the browser was offline when it comes back online', () => { + const service = fixture.graphqlService as any; + const warnSpy = jest.spyOn(service.logger, 'warn'); + + jest.useFakeTimers(); + service.handleBrowserOffline(); + jest.advanceTimersByTime(5000); // 5s offline + service.handleBrowserOnline(); + jest.useRealTimers(); + + expect(warnSpy).toHaveBeenCalledWith('Browser came back online', { + offlineDurationMs: 5000, + }); + }); + + // Bug 3: sendPing re-arms pongTimeout without clearing the previous one, so a + // stale pong timer can fire and drop a healthy connection. + it('clears the previous pong timeout before arming a new one', () => { + const service = fixture.graphqlService as any; + service.socket = { send: jest.fn() }; + + service.sendPing(); + const firstPongTimeout = service.pongTimeout; + + const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); + service.sendPing(); + + expect(clearTimeoutSpy).toHaveBeenCalledWith(firstPongTimeout); + }); +}); diff --git a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts index 48e343ec..68d711fd 100644 --- a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts +++ b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts @@ -184,6 +184,10 @@ export class GraphqlService { private pingPongInterval: any; private readonly sendPingWithThisBound = this.sendPing.bind(this); + private offlineSince: number | null = null; + private readonly handleBrowserOfflineBound = this.handleBrowserOffline.bind(this); + private readonly handleBrowserOnlineBound = this.handleBrowserOnline.bind(this); + private connectionAttemptsCount = 0; constructor() { @@ -488,8 +492,6 @@ export class GraphqlService { break; case MessageType.GQL_CONNECTION_ACK: { - this.connectionAttemptsCount = 0; - this.clearErroredSubscriptionsTimeouts(); this.retryableErroredSubscriptionsRetryCount = 0; this.retryableErroredSubscriptionsAction$.next({ type: 'clear' }); @@ -553,6 +555,7 @@ export class GraphqlService { case MessageType.GQL_PONG: clearTimeout(this.pongTimeout); + this.connectionAttemptsCount = 0; break; case MessageType.GQL_ERROR: { @@ -663,6 +666,7 @@ export class GraphqlService { } private monitorWithPingPong(): void { + clearInterval(this.pingPongInterval); this.pingPongInterval = setInterval(() => { this.sendPing(); }, PING_PONG_INTERVAL_IN_MS); @@ -672,10 +676,33 @@ export class GraphqlService { if (typeof window !== 'undefined') { window.removeEventListener('offline', this.sendPingWithThisBound); window.addEventListener('offline', this.sendPingWithThisBound); + + window.removeEventListener('offline', this.handleBrowserOfflineBound); + window.addEventListener('offline', this.handleBrowserOfflineBound); + + window.removeEventListener('online', this.handleBrowserOnlineBound); + window.addEventListener('online', this.handleBrowserOnlineBound); } } + private handleBrowserOffline(): void { + this.offlineSince = Date.now(); + this.logger.warn('Browser went offline'); + } + + private handleBrowserOnline(): void { + if (this.offlineSince === null) { + this.logger.warn('Browser came back online'); + return; + } + + const offlineDurationMs = Date.now() - this.offlineSince; + this.offlineSince = null; + this.logger.warn('Browser came back online', { offlineDurationMs }); + } + private sendPing(): void { + clearTimeout(this.pongTimeout); this.pongTimeout = setTimeout(() => { this.handleConnectionDrop().catch((error: Error) => { this.logger.error('Failed to handle pong connection drop: ', error); @@ -696,9 +723,13 @@ export class GraphqlService { this.logger.info(`Websocket connection dropped. We are offline.`); } + if (this.socket) { + this.socket.close(); + return; + } + this.setConnectionStatus(ConnectionStatus.DISCONNECTED); this.clearPingMonitoring(); - await this.openSocket(); } From 2f04e5407e83f276ba08213b84d11bb5ba35894f Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Tue, 2 Jun 2026 19:56:17 +0200 Subject: [PATCH 02/15] clean up --- .../graphql-reconnect-resilience.spec.ts | 36 +++---------------- 1 file changed, 5 insertions(+), 31 deletions(-) diff --git a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts index c87f044f..7c59e6d7 100644 --- a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts +++ b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts @@ -8,12 +8,6 @@ jest.mock('../../../util/sleep-ms/sleep-ms', () => ({ sleepMs: () => new Promise((resolve) => setTimeout(resolve, 4)), })); -/** - * Regression tests for the per-device API-key storm: a single iPad minting - * tens of thousands of DEVICE_SIGN_IN keys/day. Each reconnect fetches a fresh - * temporary key, so excessive/uncontrolled reconnects translate directly into - * excessive key creation. - */ describe('GraphQL reconnect resilience', () => { let fixture: GraphQLSubscriptionsFixture; let backoffSpy: jest.SpyInstance; @@ -32,57 +26,42 @@ describe('GraphQL reconnect resilience', () => { jest.restoreAllMocks(); }); - // Bug 4: connectionAttemptsCount resets to 0 on every ACK, so a flapping - // connection that briefly connects then drops never backs off — it stays - // pinned at the 5s floor forever, maximizing key minting. it('escalates reconnect backoff across repeated unstable connections', async () => { const sub = fixture.triggerSubscription(); for (let cycle = 0; cycle < 3; cycle++) { await fixture.handleConnectionInit(); await fixture.consumeSubscribeMessage(); - // connection drops almost immediately — never stable await fixture.closeWithCode(1001); fixture.openServer(); } const counters = backoffSpy.mock.calls.map((call) => call[0]); - // An unstable connection must back off progressively, not reset each time. expect(counters).toEqual([0, 1, 2]); - // Settle the final reconnect into a clean, terminal state so the service - // does not keep reconnecting on the shared port into the next test. await fixture.handleConnectionInit(); sub.unsubscribe(); }); - // Bug 2: monitorWithPingPong re-arms setInterval without clearing the prior - // one. Each extra ACK leaks a ping timer that keeps firing forever, and every - // leaked timer can independently drive a reconnect (= a new key). it('does not leak ping intervals when the connection monitor restarts', async () => { const setIntervalSpy = jest.spyOn(global, 'setInterval'); const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); const sub = fixture.triggerSubscription(); - await fixture.handleConnectionInit(); // ACK #1 -> ping monitor armed + await fixture.handleConnectionInit(); await fixture.consumeSubscribeMessage(); - // A second ACK on the same connection restarts monitoring. fixture.sendMessageToClient({ type: 'connection_ack' }); - await fixture.consumeSubscribeMessage(); // resubscribe from the 2nd ACK + await fixture.consumeSubscribeMessage(); const armedIds = setIntervalSpy.mock.results.map((result) => result.value); const clearedIds = clearIntervalSpy.mock.calls.map((call) => call[0]); const liveIntervals = armedIds.filter((id) => !clearedIds.includes(id)); - // Exactly one ping interval may be alive, no matter how often we re-monitor. expect(liveIntervals).toHaveLength(1); sub.unsubscribe(); }); - // Bug 1: handleConnectionDrop (pong timeout) reconnects immediately via its - // own path, bypassing the backoff used by the socket's onclose handler. A - // flapping connection then reconnects with no throttling -> key storm. it('reconnects through backoff after a pong timeout, not immediately', async () => { jest.useFakeTimers(); const sub = fixture.triggerSubscription(); @@ -92,27 +71,24 @@ describe('GraphQL reconnect resilience', () => { await jest.runOnlyPendingTimersAsync(); await fixture.consumeSubscribeMessage(); - await jest.advanceTimersToNextTimerAsync(); // ping sent, pong timeout armed + await jest.advanceTimersToNextTimerAsync(); await fixture.consumePingMessage(); - await jest.runOnlyPendingTimersAsync(); // pong timeout fires -> drop + await jest.runOnlyPendingTimersAsync(); - // The pong-timeout drop must use the same backoff path as a socket close. expect(backoffSpy).toHaveBeenCalled(); jest.useRealTimers(); sub.unsubscribe(); }); - // Observability: log how long the device was offline so the frequency and - // duration of network loss is visible per device in the logs. it('logs how long the browser was offline when it comes back online', () => { const service = fixture.graphqlService as any; const warnSpy = jest.spyOn(service.logger, 'warn'); jest.useFakeTimers(); service.handleBrowserOffline(); - jest.advanceTimersByTime(5000); // 5s offline + jest.advanceTimersByTime(5000); service.handleBrowserOnline(); jest.useRealTimers(); @@ -121,8 +97,6 @@ describe('GraphQL reconnect resilience', () => { }); }); - // Bug 3: sendPing re-arms pongTimeout without clearing the previous one, so a - // stale pong timer can fire and drop a healthy connection. it('clears the previous pong timeout before arming a new one', () => { const service = fixture.graphqlService as any; service.socket = { send: jest.fn() }; From 4e3714deea336c154a669e4c4e8c8c11e36de956 Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Tue, 2 Jun 2026 19:58:38 +0200 Subject: [PATCH 03/15] format --- .../src/lib/services/graphql/graphql.service.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts index 68d711fd..570db7c2 100644 --- a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts +++ b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts @@ -185,8 +185,10 @@ export class GraphqlService { private readonly sendPingWithThisBound = this.sendPing.bind(this); private offlineSince: number | null = null; - private readonly handleBrowserOfflineBound = this.handleBrowserOffline.bind(this); - private readonly handleBrowserOnlineBound = this.handleBrowserOnline.bind(this); + private readonly handleBrowserOfflineBound = + this.handleBrowserOffline.bind(this); + private readonly handleBrowserOnlineBound = + this.handleBrowserOnline.bind(this); private connectionAttemptsCount = 0; From c8c6a1d5b99c4d08be5a5f1483203230e1c6be47 Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Wed, 3 Jun 2026 12:55:36 +0200 Subject: [PATCH 04/15] polish: remove useRealTimers and restoreAllMocks --- .../graphql/__tests__/graphql-reconnect-resilience.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts index 7c59e6d7..f7fcc8ad 100644 --- a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts +++ b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts @@ -21,9 +21,7 @@ describe('GraphQL reconnect resilience', () => { }); afterEach(async () => { - jest.useRealTimers(); await fixture.cleanup(); - jest.restoreAllMocks(); }); it('escalates reconnect backoff across repeated unstable connections', async () => { From f8dca7ed9ac67c92a90ecfdefde2335249d020ef Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Wed, 3 Jun 2026 12:55:52 +0200 Subject: [PATCH 05/15] polish: test names --- .../__tests__/graphql-reconnect-resilience.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts index f7fcc8ad..6e35e0d3 100644 --- a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts +++ b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts @@ -24,7 +24,7 @@ describe('GraphQL reconnect resilience', () => { await fixture.cleanup(); }); - it('escalates reconnect backoff across repeated unstable connections', async () => { + it('should escalate reconnect backoff across repeated unstable connections', async () => { const sub = fixture.triggerSubscription(); for (let cycle = 0; cycle < 3; cycle++) { @@ -41,7 +41,7 @@ describe('GraphQL reconnect resilience', () => { sub.unsubscribe(); }); - it('does not leak ping intervals when the connection monitor restarts', async () => { + it('should not leak ping intervals when the connection monitor restarts', async () => { const setIntervalSpy = jest.spyOn(global, 'setInterval'); const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); @@ -60,7 +60,7 @@ describe('GraphQL reconnect resilience', () => { sub.unsubscribe(); }); - it('reconnects through backoff after a pong timeout, not immediately', async () => { + it('should reconnect through backoff after a pong timeout, not immediately', async () => { jest.useFakeTimers(); const sub = fixture.triggerSubscription(); await jest.runAllTimersAsync(); @@ -80,7 +80,7 @@ describe('GraphQL reconnect resilience', () => { sub.unsubscribe(); }); - it('logs how long the browser was offline when it comes back online', () => { + it('should log how long the browser was offline when it comes back online', () => { const service = fixture.graphqlService as any; const warnSpy = jest.spyOn(service.logger, 'warn'); @@ -95,7 +95,7 @@ describe('GraphQL reconnect resilience', () => { }); }); - it('clears the previous pong timeout before arming a new one', () => { + it('should clear the previous pong timeout before arming a new one', () => { const service = fixture.graphqlService as any; service.socket = { send: jest.fn() }; From 3f686a56cf7f785e6d24e6dfb772d7e40cf99844 Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Wed, 3 Jun 2026 12:56:38 +0200 Subject: [PATCH 06/15] polish: dispatchEvent --- .../graphql/__tests__/graphql-reconnect-resilience.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts index 6e35e0d3..906aac24 100644 --- a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts +++ b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts @@ -85,9 +85,9 @@ describe('GraphQL reconnect resilience', () => { const warnSpy = jest.spyOn(service.logger, 'warn'); jest.useFakeTimers(); - service.handleBrowserOffline(); + window.dispatchEvent(new Event('offline')); jest.advanceTimersByTime(5000); - service.handleBrowserOnline(); + window.dispatchEvent(new Event('online')); jest.useRealTimers(); expect(warnSpy).toHaveBeenCalledWith('Browser came back online', { From 2e8fe7e41b4fe918570d8cce67f89aa07e089f9a Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Wed, 3 Jun 2026 12:57:44 +0200 Subject: [PATCH 07/15] polish: setTimeoutSpy --- .../graphql/__tests__/graphql-reconnect-resilience.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts index 906aac24..876f93ec 100644 --- a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts +++ b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts @@ -83,6 +83,7 @@ describe('GraphQL reconnect resilience', () => { it('should log how long the browser was offline when it comes back online', () => { const service = fixture.graphqlService as any; const warnSpy = jest.spyOn(service.logger, 'warn'); + service.monitorWithOfflineEvent(); jest.useFakeTimers(); window.dispatchEvent(new Event('offline')); @@ -99,12 +100,15 @@ describe('GraphQL reconnect resilience', () => { const service = fixture.graphqlService as any; service.socket = { send: jest.fn() }; + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); service.sendPing(); const firstPongTimeout = service.pongTimeout; const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); service.sendPing(); - expect(clearTimeoutSpy).toHaveBeenCalledWith(firstPongTimeout); + expect(clearTimeoutSpy).toHaveBeenCalledWith( + setTimeoutSpy.mock.results[0].value, + ); }); }); From 65921153aba35b1e417596657f7285de104f0c1a Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Wed, 3 Jun 2026 13:01:37 +0200 Subject: [PATCH 08/15] polish: toHaveBeenCalledTimes --- .../__tests__/graphql-reconnect-resilience.spec.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts index 876f93ec..cbb8454a 100644 --- a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts +++ b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts @@ -42,8 +42,8 @@ describe('GraphQL reconnect resilience', () => { }); it('should not leak ping intervals when the connection monitor restarts', async () => { - const setIntervalSpy = jest.spyOn(global, 'setInterval'); - const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + const service = fixture.graphqlService as any; + const monitorWithPingPongSpy = jest.spyOn(service, 'monitorWithPingPong'); const sub = fixture.triggerSubscription(); await fixture.handleConnectionInit(); @@ -52,10 +52,7 @@ describe('GraphQL reconnect resilience', () => { fixture.sendMessageToClient({ type: 'connection_ack' }); await fixture.consumeSubscribeMessage(); - const armedIds = setIntervalSpy.mock.results.map((result) => result.value); - const clearedIds = clearIntervalSpy.mock.calls.map((call) => call[0]); - const liveIntervals = armedIds.filter((id) => !clearedIds.includes(id)); - expect(liveIntervals).toHaveLength(1); + expect(monitorWithPingPongSpy).toHaveBeenCalledTimes(2); sub.unsubscribe(); }); @@ -102,7 +99,6 @@ describe('GraphQL reconnect resilience', () => { const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); service.sendPing(); - const firstPongTimeout = service.pongTimeout; const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); service.sendPing(); From 699cd9ea1f7ea0586afb4f3a9754d3bb4814cf70 Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Wed, 3 Jun 2026 13:02:27 +0200 Subject: [PATCH 09/15] polish: handleBrowserOfflineWithThisBound --- .../lib/services/graphql/graphql.service.ts | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts index 570db7c2..78f6559c 100644 --- a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts +++ b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts @@ -185,9 +185,9 @@ export class GraphqlService { private readonly sendPingWithThisBound = this.sendPing.bind(this); private offlineSince: number | null = null; - private readonly handleBrowserOfflineBound = + private readonly handleBrowserOfflineWithThisBound = this.handleBrowserOffline.bind(this); - private readonly handleBrowserOnlineBound = + private readonly handleBrowserOnlineWithThisBound = this.handleBrowserOnline.bind(this); private connectionAttemptsCount = 0; @@ -679,11 +679,20 @@ export class GraphqlService { window.removeEventListener('offline', this.sendPingWithThisBound); window.addEventListener('offline', this.sendPingWithThisBound); - window.removeEventListener('offline', this.handleBrowserOfflineBound); - window.addEventListener('offline', this.handleBrowserOfflineBound); + window.removeEventListener( + 'offline', + this.handleBrowserOfflineWithThisBound, + ); + window.addEventListener( + 'offline', + this.handleBrowserOfflineWithThisBound, + ); - window.removeEventListener('online', this.handleBrowserOnlineBound); - window.addEventListener('online', this.handleBrowserOnlineBound); + window.removeEventListener( + 'online', + this.handleBrowserOnlineWithThisBound, + ); + window.addEventListener('online', this.handleBrowserOnlineWithThisBound); } } From 9168f4e67b1286bd015f1b7e65021834e60d1e82 Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Wed, 3 Jun 2026 13:03:17 +0200 Subject: [PATCH 10/15] use warn --- .../javascript-api/src/lib/services/graphql/graphql.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts index 78f6559c..11e6d740 100644 --- a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts +++ b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts @@ -703,7 +703,7 @@ export class GraphqlService { private handleBrowserOnline(): void { if (this.offlineSince === null) { - this.logger.warn('Browser came back online'); + this.logger.warn('Browser came online'); return; } From 2d3706e015bc3ef059a2b67292808f62fac28216 Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Wed, 3 Jun 2026 13:04:33 +0200 Subject: [PATCH 11/15] rename --- .../graphql/__tests__/graphql-reconnect-resilience.spec.ts | 2 +- .../src/lib/services/graphql/graphql.service.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts index cbb8454a..77e49cc2 100644 --- a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts +++ b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts @@ -80,7 +80,7 @@ describe('GraphQL reconnect resilience', () => { it('should log how long the browser was offline when it comes back online', () => { const service = fixture.graphqlService as any; const warnSpy = jest.spyOn(service.logger, 'warn'); - service.monitorWithOfflineEvent(); + service.monitorWithOnlineOfflineEvents(); jest.useFakeTimers(); window.dispatchEvent(new Event('offline')); diff --git a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts index 11e6d740..2db507e7 100644 --- a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts +++ b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts @@ -664,7 +664,7 @@ export class GraphqlService { private startConnectionMonitoring(): void { this.monitorWithPingPong(); - this.monitorWithOfflineEvent(); + this.monitorWithOnlineOfflineEvents(); } private monitorWithPingPong(): void { @@ -674,7 +674,7 @@ export class GraphqlService { }, PING_PONG_INTERVAL_IN_MS); } - private monitorWithOfflineEvent(): void { + private monitorWithOnlineOfflineEvents(): void { if (typeof window !== 'undefined') { window.removeEventListener('offline', this.sendPingWithThisBound); window.addEventListener('offline', this.sendPingWithThisBound); From 96cc7499bebd0b9e8c9d8cb575538c6c8c421515 Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Wed, 3 Jun 2026 13:05:41 +0200 Subject: [PATCH 12/15] add guard --- .../javascript-api/src/lib/services/graphql/graphql.service.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts index 2db507e7..5e7111ef 100644 --- a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts +++ b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts @@ -697,6 +697,9 @@ export class GraphqlService { } private handleBrowserOffline(): void { + if (this.offlineSince !== null) { + return; + } this.offlineSince = Date.now(); this.logger.warn('Browser went offline'); } From 8c5d8ca2f06447ce135758039d6d467042e4099d Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Wed, 3 Jun 2026 13:21:00 +0200 Subject: [PATCH 13/15] refactor tests --- .../__tests__/graphql-reconnect-resilience.spec.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts index 77e49cc2..a37e218e 100644 --- a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts +++ b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts @@ -80,17 +80,18 @@ describe('GraphQL reconnect resilience', () => { it('should log how long the browser was offline when it comes back online', () => { const service = fixture.graphqlService as any; const warnSpy = jest.spyOn(service.logger, 'warn'); - service.monitorWithOnlineOfflineEvents(); + let now = 1000; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => now); - jest.useFakeTimers(); - window.dispatchEvent(new Event('offline')); - jest.advanceTimersByTime(5000); - window.dispatchEvent(new Event('online')); - jest.useRealTimers(); + service.handleBrowserOffline(); + now = 6000; + service.handleBrowserOnline(); expect(warnSpy).toHaveBeenCalledWith('Browser came back online', { offlineDurationMs: 5000, }); + + nowSpy.mockRestore(); }); it('should clear the previous pong timeout before arming a new one', () => { From b8b31d5a8b78a110288daf325924de7efbd990ca Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Wed, 3 Jun 2026 15:46:54 +0200 Subject: [PATCH 14/15] roll back close --- .../src/lib/services/graphql/graphql.service.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts index 5e7111ef..a45c71e5 100644 --- a/packages/javascript-api/src/lib/services/graphql/graphql.service.ts +++ b/packages/javascript-api/src/lib/services/graphql/graphql.service.ts @@ -737,11 +737,6 @@ export class GraphqlService { this.logger.info(`Websocket connection dropped. We are offline.`); } - if (this.socket) { - this.socket.close(); - return; - } - this.setConnectionStatus(ConnectionStatus.DISCONNECTED); this.clearPingMonitoring(); await this.openSocket(); From 026bdaff67783b905605b05f7eb4ad0193be430a Mon Sep 17 00:00:00 2001 From: Stanislav Deviatykh Date: Thu, 4 Jun 2026 09:27:30 +0200 Subject: [PATCH 15/15] update tests --- .../__tests__/graphql-reconnect-resilience.spec.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts index a37e218e..69c5812a 100644 --- a/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts +++ b/packages/javascript-api/src/lib/services/graphql/__tests__/graphql-reconnect-resilience.spec.ts @@ -42,8 +42,8 @@ describe('GraphQL reconnect resilience', () => { }); it('should not leak ping intervals when the connection monitor restarts', async () => { - const service = fixture.graphqlService as any; - const monitorWithPingPongSpy = jest.spyOn(service, 'monitorWithPingPong'); + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); const sub = fixture.triggerSubscription(); await fixture.handleConnectionInit(); @@ -52,7 +52,10 @@ describe('GraphQL reconnect resilience', () => { fixture.sendMessageToClient({ type: 'connection_ack' }); await fixture.consumeSubscribeMessage(); - expect(monitorWithPingPongSpy).toHaveBeenCalledTimes(2); + const armedIds = setIntervalSpy.mock.results.map((result) => result.value); + const clearedIds = clearIntervalSpy.mock.calls.map((call) => call[0]); + const liveIntervals = armedIds.filter((id) => !clearedIds.includes(id)); + expect(liveIntervals).toHaveLength(1); sub.unsubscribe(); });