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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 14:40:32 +02:00
1 parent 769ed4b246
commit 252ef9506f
3 files changed
+44 -8

No files matched your search

+2 -2
View File
@@ -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
@@ -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
@@ -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<void> {
if (this.config.startedOffline) {