From 752ef756d6b7a63352bf1067bd206ae9df252d19 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 22:52:04 +0200 Subject: [PATCH] fix(server-storage): page-hide keepalive writes report an outage too sendKeepalive() turned a failed fetch, a timeout or a gateway's 502/503/504 into a failed outcome without telling the status, so the instance stayed verified: when the hidden tab came back, its refresh skipped the /instance check and resent the queued writes, which could reach a replacement database behind the same address. Like requests, a keepalive write now reports these as offline, so the instance is checked before anything goes out again. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/pwa-self-hosted.md | 5 +++-- .../src/lib/server-storage/server-api-client.spec.ts | 11 +++++++++++ .../src/lib/server-storage/server-api-client.ts | 6 ++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 41c6ee002..c95264189 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -348,8 +348,9 @@ settings and guards read them in every build. trouble. A page started from memory first checks that the instance at the address is the remembered one (`ensureVerified`), and reloads if not, before sign-in sends the token. So does a page that went offline (any - backend call can tell: the storage API, `HttpClient` calls through the - interceptor and `PwaService`'s raw fetches report no answer, or a + backend call can tell: the storage API with its page-hide `keepalive` + writes, `HttpClient` calls through the interceptor and `PwaService`'s raw + fetches report no answer, or a gateway's 502/503/504 that is not the backend's own JSON, as offline) before its next read: until then it sends neither the token nor writes (a replaced volume at the same address is another database), and it checks diff --git a/libs/services/src/lib/server-storage/server-api-client.spec.ts b/libs/services/src/lib/server-storage/server-api-client.spec.ts index 976381b05..fceb84af6 100644 --- a/libs/services/src/lib/server-storage/server-api-client.spec.ts +++ b/libs/services/src/lib/server-storage/server-api-client.spec.ts @@ -106,6 +106,17 @@ describe('ServerApiClient', () => { await expect( slow.sendKeepalive('PUT', '/user-state/a/b', { value: 1 }) ).resolves.toEqual({ status: 'failed' }); + // An outage: the instance is checked before the next write. + expect(status.reportOffline).toHaveBeenCalledTimes(1); + }); + + it('reports a gateway answering for a down backend on a keepalive write', async () => { + const down = jest.fn().mockResolvedValue(response(502)); + + await expect( + client(down).sendKeepalive('PUT', '/user-state/a/b', { value: 1 }) + ).resolves.toEqual({ status: 'failed' }); + expect(status.reportOffline).toHaveBeenCalled(); }); it('reports how a keepalive write ended', async () => { diff --git a/libs/services/src/lib/server-storage/server-api-client.ts b/libs/services/src/lib/server-storage/server-api-client.ts index e30e093c1..3b309380b 100644 --- a/libs/services/src/lib/server-storage/server-api-client.ts +++ b/libs/services/src/lib/server-storage/server-api-client.ts @@ -208,6 +208,11 @@ export class ServerApiClient { )) as Partial; return { status: 'saved', tag: body?.tag }; } + if (GATEWAY_UNAVAILABLE.has(response.status)) { + // As for requests: the backend may come back as another + // instance, checked before the next write goes out. + this.options.status.reportOffline(); + } const { code, current } = response.status === 412 || response.status === 404 ? await readErrorBody(response) @@ -219,6 +224,7 @@ export class ServerApiClient { ? { status: 'conflict', current } : { status: 'failed' }; } catch { + this.options.status.reportOffline(); return { status: 'failed' }; } }