diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 82964447d7a..087200451f3 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -176,6 +176,7 @@ describe("retainThreadDetailSubscription", () => { }, })), }, + isHeartbeatFresh: vi.fn(() => true), orchestration: { subscribeThread: mockSubscribeThread, }, @@ -382,7 +383,7 @@ describe("retainThreadDetailSubscription", () => { await resetEnvironmentServiceForTests(); }); - it("reconnects environment streams when the browser resumes from the background", async () => { + it("keeps healthy environment streams connected when the browser resumes from the background", async () => { let visibilityState: DocumentVisibilityState = "visible"; const documentTarget = new EventTarget(); const windowTarget = new EventTarget(); @@ -408,6 +409,57 @@ describe("retainThreadDetailSubscription", () => { documentTarget.dispatchEvent(new Event("visibilitychange")); expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); + visibilityState = "visible"; + documentTarget.dispatchEvent(new Event("visibilitychange")); + expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); + + stop(); + await resetEnvironmentServiceForTests(); + }); + + it("reconnects stale environment streams when the browser resumes from the background", async () => { + let visibilityState: DocumentVisibilityState = "visible"; + const documentTarget = new EventTarget(); + const windowTarget = new EventTarget(); + vi.stubGlobal("document", { + addEventListener: documentTarget.addEventListener.bind(documentTarget), + removeEventListener: documentTarget.removeEventListener.bind(documentTarget), + get visibilityState() { + return visibilityState; + }, + }); + vi.stubGlobal("window", { + addEventListener: windowTarget.addEventListener.bind(windowTarget), + removeEventListener: windowTarget.removeEventListener.bind(windowTarget), + }); + mockCreateWsRpcClient.mockReturnValue({ + server: { + getConfig: vi.fn(async () => ({ + environment: { + environmentId: EnvironmentId.make("env-remote"), + label: "Remote env", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + })), + }, + isHeartbeatFresh: vi.fn(() => false), + orchestration: { + subscribeThread: mockSubscribeThread, + }, + }); + + const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } = + await import("./service"); + + const stop = startEnvironmentConnectionService(new QueryClient()); + expect(mockConnectionReconnects).toHaveLength(1); + + visibilityState = "hidden"; + documentTarget.dispatchEvent(new Event("visibilitychange")); + expect(mockConnectionReconnects[0]).not.toHaveBeenCalled(); + visibilityState = "visible"; documentTarget.dispatchEvent(new Event("visibilitychange")); expect(mockConnectionReconnects[0]).toHaveBeenCalledTimes(1); diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 60c05fc217c..21a4561beb4 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -1482,9 +1482,12 @@ function reconnectEnvironmentConnectionsAfterBrowserResume(reason: string): void if (now - lastBrowserResumeReconnectAt < BROWSER_RESUME_RECONNECT_COOLDOWN_MS) { return; } - lastBrowserResumeReconnectAt = now; for (const connection of environmentConnections.values()) { + if (connection.client.isHeartbeatFresh()) { + continue; + } + lastBrowserResumeReconnectAt = now; void connection.reconnect().catch((error) => { console.warn("Environment reconnect after browser resume failed", { environmentId: connection.environmentId, diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 2f1ca624d98..7128c909ab7 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -56,6 +56,7 @@ interface GitRunStackedActionOptions { export interface WsRpcClient { readonly dispose: () => Promise; readonly reconnect: () => Promise; + readonly isHeartbeatFresh: () => boolean; readonly terminal: { readonly open: RpcUnaryMethod; readonly write: RpcUnaryMethod; @@ -158,6 +159,7 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { resetWsReconnectBackoff(); await transport.reconnect(); }, + isHeartbeatFresh: () => transport.isHeartbeatFresh(), terminal: { open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)), write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)),