From 252ef9506fe85b8460c16957e7c2ce0f3367900f Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 14:40:32 +0200 Subject: [PATCH] fix(server-storage): the immediate offline check runs once per retry interval A response whose headers arrived but whose body was cut off reports success and then offline, which started the next immediate instance check at once: repeated cut-off answers kept requests going and the connection indicator flickering. The check on going offline now runs at most once per retry interval; later failures wait for the timed retry. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/pwa-self-hosted.md | 4 +-- .../server-storage-session.spec.ts | 25 +++++++++++++++++++ .../server-storage/server-storage-session.ts | 23 ++++++++++++----- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index bb360ee3e..c7073bcf6 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -342,8 +342,8 @@ settings and guards read them in every build. 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 the instance as soon as it goes offline, so a blip costs no retry - interval (only the instance: a read that keeps failing waits for the - retry). Sign-out always navigates to sign-in, also when a guard kept the + interval (only the instance, and once per retry interval: a read or a + check that keeps failing waits for the retry). Sign-out always navigates to sign-in, also when a guard kept the page the first time. Both turn the guard's 401 (`isServerAccessChallenge`) into `reportAccessDenied()`, as the storage API client does with any 401, so a token changed on the diff --git a/libs/services/src/lib/server-storage/server-storage-session.spec.ts b/libs/services/src/lib/server-storage/server-storage-session.spec.ts index dc9df4a16..79d6d219c 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.spec.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.spec.ts @@ -269,6 +269,31 @@ describe('ServerStorageSession', () => { ]); }); + it('checks at once only once per retry interval', async () => { + const { session, status } = setup('none'); + await session.start(); + // Headers arrive, the body is cut off: success, then offline. + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + headers: new Headers(), + json: async () => { + throw new TypeError('body cut off'); + }, + }); + fetchMock.mockClear(); + + status.reportOffline(); + for (let round = 0; round < 5; round += 1) { + TestBed.tick(); + for (let turn = 0; turn < 10; turn += 1) { + await Promise.resolve(); + } + } + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it('reloads when another instance answers at the address', async () => { const { session, status } = setup('none'); const reload = jest diff --git a/libs/services/src/lib/server-storage/server-storage-session.ts b/libs/services/src/lib/server-storage/server-storage-session.ts index 4bd8a273b..70eaa62d8 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.ts @@ -38,6 +38,7 @@ export class ServerStorageSession { private readonly translate = inject(TranslateService); private readonly router = inject(Router); private reloading = false; + private lastQuickCheck = 0; private lastNotice = { key: '', at: 0 }; readonly api = new ServerApiClient({ @@ -72,16 +73,11 @@ export class ServerStorageSession { } }); effect(() => { - // Gone offline, the token and writes wait for the instance - // check: after a blip it passes at once, not at the next retry. - // Only the check: a read failing on its own waits for the retry. if ( this.status.state() === 'offline' && !this.config.startedOffline ) { - untracked( - () => void this.ensureVerified().catch(() => undefined) - ); + untracked(() => this.checkSoon()); } }); } @@ -112,6 +108,21 @@ export class ServerStorageSession { ]); } + /** + * Gone offline, the token and writes wait for the instance check: + * after a blip it passes at once, not at the next retry. Only the + * check, and once per retry interval: a read or a check that keeps + * failing (a body cut off after its headers) waits for the retry. + */ + private checkSoon(): void { + const now = Date.now(); + if (now - this.lastQuickCheck < RETRY_INTERVAL_MS) { + return; + } + this.lastQuickCheck = now; + void this.ensureVerified().catch(() => undefined); + } + /** Re-reads synced user data; also the offline retry. */ async refresh(): Promise { if (this.config.startedOffline) {