authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-05-13 22:29:36+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-05-13 22:29:36+02:00
log90eea047a967ea2edae543e966627ec0a394ef81
treed5bb69ce2d3f08077a1c855fdc818eeb56a58000
parentf92e1e1b06ec5ba78ce66849cf288a3a45e54bb0
signature Signed by PGP key B5690EEEBB952194

Skip healthy env reconnects after browser resume (#2677)


3 files changed, 59 insertions(+), 2 deletions(-)

apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+53-1
...@@ -176,6 +176,7 @@ describe("retainThreadDetailSubscription", () => {...@@ -176,6 +176,7 @@ describe("retainThreadDetailSubscription", () => {
176 },176 },
177 })),177 })),
178 },178 },
179 isHeartbeatFresh: vi.fn(() => true),
179 orchestration: {180 orchestration: {
180 subscribeThread: mockSubscribeThread,181 subscribeThread: mockSubscribeThread,
181 },182 },
...@@ -382,7 +383,7 @@ describe("retainThreadDetailSubscription", () => {...@@ -382,7 +383,7 @@ describe("retainThreadDetailSubscription", () => {
382 await resetEnvironmentServiceForTests();383 await resetEnvironmentServiceForTests();
383 });384 });
384385
385 it("reconnects environment streams when the browser resumes from the background", async () => {386 it("keeps healthy environment streams connected when the browser resumes from the background", async () => {
386 let visibilityState: DocumentVisibilityState = "visible";387 let visibilityState: DocumentVisibilityState = "visible";
387 const documentTarget = new EventTarget();388 const documentTarget = new EventTarget();
388 const windowTarget = new EventTarget();389 const windowTarget = new EventTarget();
...@@ -408,6 +409,57 @@ describe("retainThreadDetailSubscription", () => {...@@ -408,6 +409,57 @@ describe("retainThreadDetailSubscription", () => {
408 documentTarget.dispatchEvent(new Event("visibilitychange"));409 documentTarget.dispatchEvent(new Event("visibilitychange"));
409 expect(mockConnectionReconnects[0]).not.toHaveBeenCalled();410 expect(mockConnectionReconnects[0]).not.toHaveBeenCalled();
410411
412 visibilityState = "visible";
413 documentTarget.dispatchEvent(new Event("visibilitychange"));
414 expect(mockConnectionReconnects[0]).not.toHaveBeenCalled();
415
416 stop();
417 await resetEnvironmentServiceForTests();
418 });
419
420 it("reconnects stale environment streams when the browser resumes from the background", async () => {
421 let visibilityState: DocumentVisibilityState = "visible";
422 const documentTarget = new EventTarget();
423 const windowTarget = new EventTarget();
424 vi.stubGlobal("document", {
425 addEventListener: documentTarget.addEventListener.bind(documentTarget),
426 removeEventListener: documentTarget.removeEventListener.bind(documentTarget),
427 get visibilityState() {
428 return visibilityState;
429 },
430 });
431 vi.stubGlobal("window", {
432 addEventListener: windowTarget.addEventListener.bind(windowTarget),
433 removeEventListener: windowTarget.removeEventListener.bind(windowTarget),
434 });
435 mockCreateWsRpcClient.mockReturnValue({
436 server: {
437 getConfig: vi.fn(async () => ({
438 environment: {
439 environmentId: EnvironmentId.make("env-remote"),
440 label: "Remote env",
441 platform: { os: "darwin", arch: "arm64" },
442 serverVersion: "0.0.0-test",
443 capabilities: { repositoryIdentity: true },
444 },
445 })),
446 },
447 isHeartbeatFresh: vi.fn(() => false),
448 orchestration: {
449 subscribeThread: mockSubscribeThread,
450 },
451 });
452
453 const { resetEnvironmentServiceForTests, startEnvironmentConnectionService } =
454 await import("./service");
455
456 const stop = startEnvironmentConnectionService(new QueryClient());
457 expect(mockConnectionReconnects).toHaveLength(1);
458
459 visibilityState = "hidden";
460 documentTarget.dispatchEvent(new Event("visibilitychange"));
461 expect(mockConnectionReconnects[0]).not.toHaveBeenCalled();
462
411 visibilityState = "visible";463 visibilityState = "visible";
412 documentTarget.dispatchEvent(new Event("visibilitychange"));464 documentTarget.dispatchEvent(new Event("visibilitychange"));
413 expect(mockConnectionReconnects[0]).toHaveBeenCalledTimes(1);465 expect(mockConnectionReconnects[0]).toHaveBeenCalledTimes(1);
apps/web/src/environments/runtime/service.ts+4-1
...@@ -1482,9 +1482,12 @@ function reconnectEnvironmentConnectionsAfterBrowserResume(reason: string): void...@@ -1482,9 +1482,12 @@ function reconnectEnvironmentConnectionsAfterBrowserResume(reason: string): void
1482 if (now - lastBrowserResumeReconnectAt < BROWSER_RESUME_RECONNECT_COOLDOWN_MS) {1482 if (now - lastBrowserResumeReconnectAt < BROWSER_RESUME_RECONNECT_COOLDOWN_MS) {
1483 return;1483 return;
1484 }1484 }
1485 lastBrowserResumeReconnectAt = now;
14861485
1487 for (const connection of environmentConnections.values()) {1486 for (const connection of environmentConnections.values()) {
1487 if (connection.client.isHeartbeatFresh()) {
1488 continue;
1489 }
1490 lastBrowserResumeReconnectAt = now;
1488 void connection.reconnect().catch((error) => {1491 void connection.reconnect().catch((error) => {
1489 console.warn("Environment reconnect after browser resume failed", {1492 console.warn("Environment reconnect after browser resume failed", {
1490 environmentId: connection.environmentId,1493 environmentId: connection.environmentId,
apps/web/src/rpc/wsRpcClient.ts+2
...@@ -56,6 +56,7 @@ interface GitRunStackedActionOptions {...@@ -56,6 +56,7 @@ interface GitRunStackedActionOptions {
56export interface WsRpcClient {56export interface WsRpcClient {
57 readonly dispose: () => Promise<void>;57 readonly dispose: () => Promise<void>;
58 readonly reconnect: () => Promise<void>;58 readonly reconnect: () => Promise<void>;
59 readonly isHeartbeatFresh: () => boolean;
59 readonly terminal: {60 readonly terminal: {
60 readonly open: RpcUnaryMethod<typeof WS_METHODS.terminalOpen>;61 readonly open: RpcUnaryMethod<typeof WS_METHODS.terminalOpen>;
61 readonly write: RpcUnaryMethod<typeof WS_METHODS.terminalWrite>;62 readonly write: RpcUnaryMethod<typeof WS_METHODS.terminalWrite>;
...@@ -158,6 +159,7 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {...@@ -158,6 +159,7 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient {
158 resetWsReconnectBackoff();159 resetWsReconnectBackoff();
159 await transport.reconnect();160 await transport.reconnect();
160 },161 },
162 isHeartbeatFresh: () => transport.isHeartbeatFresh(),
161 terminal: {163 terminal: {
162 open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)),164 open: (input) => transport.request((client) => client[WS_METHODS.terminalOpen](input)),
163 write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)),165 write: (input) => transport.request((client) => client[WS_METHODS.terminalWrite](input)),