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) {