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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 22:52:04 +02:00
1 parent da9f99f3f2
commit 752ef756d6
3 files changed
+20 -2

No files matched your search

+3 -2
View File
@@ -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
@@ -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 () => {
@@ -208,6 +208,11 @@ export class ServerApiClient {
)) as Partial<ServerUserStateWriteResponse>;
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' };
}
}