diff --git a/web/src/daemonLink.test.ts b/web/src/daemonLink.test.ts index 6387c08..10d2baf 100644 --- a/web/src/daemonLink.test.ts +++ b/web/src/daemonLink.test.ts @@ -130,6 +130,31 @@ describe("DaemonLink", () => { expect(link.status().state).toBe("online"); }); + it("rechecks immediately when the browser returns from the background", async () => { + const sched = manual(); + let reply: DaemonInfo | null = info("aaa", "i1"); + const link = new DaemonLink({ + fetchInfo: async () => { + if (!reply) throw new Error("down"); + return reply; + }, + scheduler: sched, + reload: () => {}, + }); + await link.start(); + + reply = null; + link.noteDisconnected(); + sched.run(); + await Promise.resolve(); + expect(link.status().state).toBe("down"); + + reply = info("aaa", "i1"); + link.recover(); + await Promise.resolve(); + expect(link.status().state).toBe("online"); + }); + it("reloads the page when the daemon comes back on a new commit", async () => { const sched = manual(); const reload = vi.fn(); diff --git a/web/src/daemonLink.ts b/web/src/daemonLink.ts index 7950b81..09c28cf 100644 --- a/web/src/daemonLink.ts +++ b/web/src/daemonLink.ts @@ -148,6 +148,17 @@ export class DaemonLink { void this.probe(); } + /** + * Recheck liveness after the browser resumes from the background. Mobile + * browsers may suspend both WebSocket events and timers while a PWA is + * hidden, leaving a stale `down` state after the daemon is already back. + */ + recover(): void { + if (this.state === "updating") return; + this.noteDisconnected(); + void this.probe(); + } + /** * One `/api/info` round-trip. Resolves `true` when the daemon answered. * diff --git a/web/src/events.test.ts b/web/src/events.test.ts index 724c8f4..0fe7ef4 100644 --- a/web/src/events.test.ts +++ b/web/src/events.test.ts @@ -132,6 +132,21 @@ describe("CatalogStore reducer", () => { }); describe("CatalogStore reconnect", () => { + it("replaces a suspended socket immediately", () => { + const first = new FakeSocket(); + const second = new FakeSocket(); + const { store } = storeWith([first, second]); + store.start(); + + store.reconnect(); + + expect(first.closed).toBe(true); + second.open(); + // A late close from the old socket is deliberately ignored. + first.drop(); + expect(store.backoff()).toBe(INITIAL_BACKOFF_MS); + }); + it("doubles the delay per attempt and caps it", () => { const sockets = Array.from({ length: 8 }, () => new FakeSocket()); const { store, scheduler } = storeWith(sockets); diff --git a/web/src/events.ts b/web/src/events.ts index c6b48c8..7170afa 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -59,6 +59,23 @@ export class CatalogStore { this.connect(); } + /** Replace a socket that may have gone stale while the browser was suspended. */ + reconnect(): void { + if (this.reconnectTimer !== null) { + this.scheduler.clearTimer(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + // A close event from the superseded socket must not schedule a second + // connection alongside the new one. + this.ws.onclose = null; + this.ws.onerror = null; + this.ws.close(); + this.ws = null; + } + this.connect(); + } + /** Current reconnect delay, for tests asserting the backoff schedule. */ backoff(): number { return this.backoffMs; diff --git a/web/src/main.ts b/web/src/main.ts index f8e5f74..6ac5f2d 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -107,6 +107,19 @@ void link.start(); store.start(); +// Installed mobile browsers suspend timers and WebSockets in the background. +// Replacing the socket on return avoids leaving the restart overlay up after a +// transient lost connection has already recovered. +function recoverFromBackground(): void { + if (document.visibilityState !== "visible") return; + link.recover(); + store.reconnect(); +} + +document.addEventListener("visibilitychange", recoverFromBackground); +window.addEventListener("pageshow", recoverFromBackground); +window.addEventListener("online", recoverFromBackground); + // Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed // without a running daemon. Stripped from production builds by Vite. if (import.meta.env.DEV && location.search.includes("mock")) {