fix(server-storage): offline recheck checks only the instance; sign-out always navigates

The recheck on going offline ran a full refresh: when /instance answered
but /user-state kept failing, each failed read set the page offline
again and started the next one at once, bypassing the retry interval.
It now checks the instance only; reads wait for the retry.

Sign-out navigates to sign-in itself, also when the page is signed out
already: a guard (unsaved Settings) may have cancelled the redirect the
first time, and the state change that drives it does not repeat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-14 09:46:16 +02:00
1 parent cfda131c41
commit 52d733e1c7
3 files changed
+55 -4

No files matched your search

+4 -1
View File
@@ -336,7 +336,10 @@ settings and guards read them in every build.
before sign-in sends the token. So does a page that went 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
as soon as it goes offline, so a blip costs no retry interval. Both turn
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
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
server sends an open page back to sign-in; a late 401 for a token
@@ -1,6 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { provideRouter } from '@angular/router';
import { provideRouter, Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistStorageBackend } from '../playlist-storage-backend';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
@@ -187,6 +187,21 @@ describe('ServerStorageSession', () => {
delete (document as { visibilityState?: unknown }).visibilityState;
});
it('goes to sign-in on every sign-out, also when already signed out', async () => {
const { session } = setup('none');
await session.start();
const navigate = jest
.spyOn(TestBed.inject(Router), 'navigateByUrl')
.mockResolvedValue(false);
session.signOut();
// A guard kept the page (unsaved Settings); the user signs out again.
session.signOut();
expect(navigate).toHaveBeenCalledTimes(2);
expect(navigate).toHaveBeenLastCalledWith('/server-sign-in');
});
describe('after losing the server', () => {
const answer = (body: unknown) => ({
status: 200,
@@ -226,6 +241,30 @@ describe('ServerStorageSession', () => {
);
});
it('checks only the instance at once when it goes offline', async () => {
const { session, status } = setup('none');
await session.start();
// The instance answers, its user data does not.
fetchMock.mockImplementation(async (url: string) =>
url.endsWith('/instance')
? answer({ instanceId: 'i-1' })
: { status: 503, ok: false, headers: new Headers() }
);
fetchMock.mockClear();
status.reportOffline();
TestBed.tick();
for (let turn = 0; turn < 10; turn += 1) {
await Promise.resolve();
}
expect(status.isVerified()).toBe(true);
// A failing read waits for the retry interval, not a loop.
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'/api/v1/instance',
]);
});
it('reloads when another instance answers at the address', async () => {
const { session, status } = setup('none');
const reload = jest
@@ -74,8 +74,14 @@ 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.
if (this.status.state() === 'offline') {
untracked(() => void this.refresh());
// 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)
);
}
});
}
@@ -164,6 +170,9 @@ export class ServerStorageSession {
signOut(): void {
clearServerAccessToken();
this.status.reportUnauthorized();
// Also when signed out already: a guard (unsaved Settings) may have
// kept the page here the first time.
void this.router.navigateByUrl(SERVER_SIGN_IN_PATH);
}
private listen(): void {