fix(stalker): make edited connection persistence atomic

This commit is contained in:
4gray committed 2026-08-09 09:32:26 +02:00
1 parent 8d6fe1e02d
commit 3505eafdd1
17 files changed
+398 -87

No files matched your search

+5 -4
View File
@@ -4,7 +4,8 @@ area: stalker
issues: [850, 686, 755]
---
Stalker setup now accepts a host or `/c` address, finds the working API
endpoint and authentication mode, and rechecks changed connection details in
Edit. Misclassified portals from earlier versions still repair themselves on
first compatible failure without losing favorites or history.
Stalker portals are no longer classified by their URL shape: importing probes
the real API endpoint (`portal.php` vs `server/load.php`) and checks whether
the portal actually requires authentication. Canonical Ministra URLs finally
load content, `…/c` addresses resolve correctly, and misclassified existing
portals repair themselves on first failure — keeping favorites and history.
+8
View File
@@ -0,0 +1,8 @@
---
type: feature
area: stalker
---
Stalker setup now accepts a host or `/c` address, discovers the working API
endpoint and authentication mode, and rechecks connection details edited
later. Failed edits leave the saved playlist and active session unchanged.
+1 -1
View File
@@ -1167,7 +1167,7 @@ engine` (restart required) or
- Portal mode (full vs. simple) follows OBSERVED behavior, never a URL substring. The single predicate is `isFullStalkerPortalPlaylist()` / `isFullStalkerPortalUrl()` in `@iptvnator/shared/interfaces` (`stalker-portal-mode.util.ts`): the persisted `Playlist.isFullStalkerPortal` flag is authoritative and the URL shape is a fallback for legacy rows only. Three diverging copies of this rule used to exist and shipped broken configurations (#850/#686/#755) — never re-implement it. A token-enforcing `portal.php` panel is a full portal; a `server/load.php` endpoint that answers without a token is a simple one.
- Import requires an explicit HTTP(S) scheme but accepts a bare host, `/c`, or a concrete `.php` address. It probes candidates in order (a pasted `.php` endpoint first, then `<base>/portal.php` → `<base>/server/load.php` → `<base>/stalker_portal/server/load.php`) and classifies each by behavior — a token-less `itv/get_genres` returning data proves a token-free panel; the plain-text auth failure proves a full portal, confirmed by a real handshake + `get_profile`. `StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) persists and displays the proven endpoint and mode. An unreachable panel-style import remains allowed with a warning; a bare host falls back to `<base>/portal.php`, while canonical-shaped unreachable addresses still abort.
- The playlist-info Edit dialog preserves an unchanged connection byte-for-byte and skips discovery. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Failure writes nothing; success atomically replaces endpoint, mode, normalized identity and session metadata, then synchronizes the active `StalkerStore` snapshot and session/watchdog before another same-route request can use the old connection. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape.
- The playlist-info Edit dialog preserves an unchanged connection byte-for-byte and skips discovery. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Failure writes nothing; success uses one awaited write to atomically replace endpoint, mode, normalized identity and session metadata, then feeds its complete merged row into the state-only NgRx update and active `StalkerStore`/session/watchdog replacement before another same-route request can use the old connection. This preserves playback headers and other metadata absent from the form. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape.
- `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair _drives_, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed.
- Explicit Edit advances the repair generation before installing its resolved session. A lazy repair that started earlier is discarded even if it had already verified its row, so it cannot restore an older endpoint, mode or token after Edit.
- Both transports build the wire format from the same shared builders in `@iptvnator/shared/interfaces` — `buildStalkerRequestUrl()`, `buildStalkerIdentityRequestContext()`, `encodeStalkerCmdValue()` — so the Electron and PWA legs cannot drift. The mock's `/stalker` mirror shares the identity builder only — it dispatches in-process, so there is no portal URL to build and it mirrors the `JsHttpRequest` default by hand. Never fork any of them.
@@ -18,10 +18,13 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
};
const portalRepair = {
applyOverride: jest.fn((playlist: PlaylistMeta) => playlist),
retireForPlaylistEdit: jest.fn(),
fenceForPlaylistEdit: jest.fn(),
commitPlaylistEdit: jest.fn(),
};
const stalkerSession = {
replaceSessionAfterEdit: jest.fn().mockResolvedValue(undefined),
replaceSessionAfterEdit: jest.fn(
async (playlist: PlaylistMeta) => playlist
),
};
let activePlaylist: PlaylistMeta | undefined;
const stalkerStore = {
@@ -212,9 +215,10 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
await service.applyResolvedConnection(resolvedPlaylist);
expect(portalRepair.retireForPlaylistEdit).toHaveBeenCalledWith(
expect(portalRepair.fenceForPlaylistEdit).toHaveBeenCalledWith(
draft._id
);
expect(portalRepair.commitPlaylistEdit).toHaveBeenCalledWith(draft._id);
expect(stalkerSession.replaceSessionAfterEdit).toHaveBeenCalledWith(
expect.objectContaining({
portalUrl: 'https://new.example.com/server/load.php',
@@ -236,6 +240,42 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
);
});
it('replaces the active runtime playlist with the complete persisted row', async () => {
activePlaylist = {
...draft,
portalUrl: 'https://old.example.com/portal.php',
isFullStalkerPortal: false,
referrer: 'https://portal.example.com/c/',
origin: 'https://portal.example.com',
};
const resolvedPlaylist = {
...draft,
portalUrl: 'https://new.example.com/server/load.php',
isFullStalkerPortal: true,
stalkerSessionPatch: {
stalkerToken: 'NEW_TOKEN',
stalkerSessionIdentity: 'new-fingerprint',
},
};
stalkerSession.replaceSessionAfterEdit.mockImplementationOnce(
async (playlistWithSession: PlaylistMeta) => ({
...playlistWithSession,
referrer: 'https://portal.example.com/c/',
origin: 'https://portal.example.com',
})
);
await service.applyResolvedConnection(resolvedPlaylist);
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
portalUrl: 'https://new.example.com/server/load.php',
referrer: 'https://portal.example.com/c/',
origin: 'https://portal.example.com',
})
);
});
it('clears the session and stops full-portal runtime behavior after a resolved simple edit', async () => {
activePlaylist = {
...draft,
@@ -251,9 +291,10 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
await service.applyResolvedConnection(resolvedPlaylist);
expect(portalRepair.retireForPlaylistEdit).toHaveBeenCalledWith(
expect(portalRepair.fenceForPlaylistEdit).toHaveBeenCalledWith(
draft._id
);
expect(portalRepair.commitPlaylistEdit).toHaveBeenCalledWith(draft._id);
expect(stalkerSession.replaceSessionAfterEdit).toHaveBeenCalledWith(
expect.objectContaining({
portalUrl: 'https://new.example.com/portal.php',
@@ -272,29 +313,29 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
);
});
it('repoints the active snapshot before an old authentication finishes draining', async () => {
it('repoints the active snapshot only after old authentication finishes draining', async () => {
activePlaylist = {
...draft,
portalUrl: 'https://old.example.com/server/load.php',
isFullStalkerPortal: true,
};
let finishReplacement: () => void = () => undefined;
stalkerSession.replaceSessionAfterEdit.mockReturnValueOnce(
new Promise<void>((resolve) => {
finishReplacement = resolve;
})
);
const resolvedPlaylist = {
...draft,
portalUrl: 'https://new.example.com/portal.php',
isFullStalkerPortal: false,
stalkerSessionPatch: null,
};
let finishReplacement: () => void = () => undefined;
stalkerSession.replaceSessionAfterEdit.mockReturnValueOnce(
new Promise<PlaylistMeta>((resolve) => {
finishReplacement = () => resolve(resolvedPlaylist);
})
);
const applying = service.applyResolvedConnection(resolvedPlaylist);
expect(activePlaylist?.portalUrl).toBe(
'https://new.example.com/portal.php'
'https://old.example.com/server/load.php'
);
let settled = false;
void applying.then(() => (settled = true));
@@ -303,6 +344,39 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
finishReplacement();
await applying;
expect(activePlaylist?.portalUrl).toBe(
'https://new.example.com/portal.php'
);
});
it('keeps repair and active runtime state unchanged when persistence fails', async () => {
activePlaylist = {
...draft,
portalUrl: 'https://old.example.com/server/load.php',
isFullStalkerPortal: true,
};
const resolvedPlaylist = {
...draft,
portalUrl: 'https://new.example.com/portal.php',
isFullStalkerPortal: false,
stalkerSessionPatch: null,
};
stalkerSession.replaceSessionAfterEdit.mockRejectedValueOnce(
new Error('write failed')
);
await expect(
service.applyResolvedConnection(resolvedPlaylist)
).rejects.toThrow('write failed');
expect(portalRepair.fenceForPlaylistEdit).toHaveBeenCalledWith(
draft._id
);
expect(portalRepair.commitPlaylistEdit).not.toHaveBeenCalled();
expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled();
expect(activePlaylist?.portalUrl).toBe(
'https://old.example.com/server/load.php'
);
});
it.each([true, false])(
@@ -336,7 +410,10 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
await service.applyResolvedConnection(resolvedPlaylist);
expect(portalRepair.applyOverride).not.toHaveBeenCalled();
expect(portalRepair.retireForPlaylistEdit).toHaveBeenCalledWith(
expect(portalRepair.fenceForPlaylistEdit).toHaveBeenCalledWith(
draft._id
);
expect(portalRepair.commitPlaylistEdit).toHaveBeenCalledWith(
draft._id
);
expect(stalkerSession.replaceSessionAfterEdit).toHaveBeenCalledWith(
@@ -46,20 +46,17 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
// the resolved row could turn a credential-only A→A edit back into
// the repair's old A→B result.
const runtimePlaylist = this.toRuntimePlaylist(playlist);
this.portalRepair.retireForPlaylistEdit(runtimePlaylist._id);
const sessionReplacement =
this.stalkerSession.replaceSessionAfterEdit(runtimePlaylist);
this.portalRepair.fenceForPlaylistEdit(runtimePlaylist._id);
const persistedPlaylist =
await this.stalkerSession.replaceSessionAfterEdit(runtimePlaylist);
this.portalRepair.commitPlaylistEdit(runtimePlaylist._id);
let activePlaylistUpdate = Promise.resolve();
if (this.stalkerStore.currentPlaylist()?._id === runtimePlaylist._id) {
// `setCurrentPlaylist` patches the signal-store snapshot before
// its optional SQLite sync awaits, and re-points/stops the active
// watchdog synchronously. The next catalog/playback request can
// therefore no longer use the pre-edit endpoint or identity.
activePlaylistUpdate =
this.stalkerStore.setCurrentPlaylist(runtimePlaylist);
// The persistence result is the complete row merged by
// PlaylistsService, so backup-restored playback headers and other
// metadata absent from the form survive the active replacement.
await this.stalkerStore.setCurrentPlaylist(persistedPlaylist);
}
await Promise.all([sessionReplacement, activePlaylistUpdate]);
}
async resolveConnection(
+6 -3
View File
@@ -202,9 +202,12 @@ service validates the draft. Auth rejection or an unreachable portal leaves
the dialog open and writes nothing. Success atomically replaces the endpoint,
mode and normalized identity together with session metadata: simple mode
clears token/fingerprint/watchdog/account state, while full mode replaces it
with the confirmed authorization result. The app adapter also replaces the
active `StalkerStore` snapshot and session/watchdog state immediately, so the
next request in the same route cannot use the pre-edit connection.
with the confirmed authorization result. That awaited write returns the
complete merged playlist row before NgRx receives its state-only update and
before the app adapter replaces the active `StalkerStore` snapshot and
session/watchdog state, so persistence failure cannot expose a partial runtime
edit and metadata absent from the form (such as playback `Referer`/`Origin`)
survives the replacement.
`PlaylistMetaUpdate` carries the persisted part as a transient
`stalkerSessionPatch` (`undefined` preserves, `null` clears, an object fully
replaces); `PlaylistsService` projects it onto the existing flat playlist
+8 -1
View File
@@ -15,7 +15,14 @@ export const PlaylistActions = createActionGroup({
'Add Playlist': props<{ playlist: Playlist }>(),
'Add Many Playlists': props<{ playlists: Playlist[] }>(),
'Remove Playlist': props<{ playlistId: string }>(),
'Update Playlist Meta': props<{ playlist: PlaylistMetaUpdate }>(),
'Update Playlist Meta': props<{
playlist: PlaylistMetaUpdate;
/**
* False when an awaited owner already persisted this exact
* update and the action only synchronizes NgRx state.
*/
persist?: boolean;
}>(),
'Update Playlist': props<{
/**
* Instrumentation-only; stripped before the DB invoke.
+1
View File
@@ -376,6 +376,7 @@ export class PlaylistEffects {
() => {
return this.actions$.pipe(
ofType(PlaylistActions.updatePlaylistMeta),
filter((action) => action.persist !== false),
switchMap((action) =>
this.playlistsService
.updatePlaylistMeta(action.playlist)
@@ -0,0 +1,69 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { Router } from '@angular/router';
import { Actions } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistsService, SettingsStore } from '@iptvnator/services';
import type { PlaylistMetaUpdate } from '@iptvnator/shared/interfaces';
import { EMPTY, of, Subject } from 'rxjs';
import { PlaylistActions } from './actions';
import { PlaylistEffects } from './effects';
describe('PlaylistEffects updatePlaylistMeta$', () => {
const playlist = {
_id: 'stalker-1',
title: 'Portal',
count: 0,
importDate: '',
portalUrl: 'https://portal.example.com/server/load.php',
} as PlaylistMetaUpdate;
let actions$: Subject<unknown>;
let updatePlaylistMeta: jest.Mock;
let effects: PlaylistEffects;
beforeEach(() => {
actions$ = new Subject();
updatePlaylistMeta = jest.fn(() => of(undefined));
const permissiveParent = {
get: () => ({}),
} as unknown as Injector;
const injector = Injector.create({
parent: permissiveParent,
providers: [
{ provide: Actions, useValue: new Actions(actions$) },
{ provide: Store, useValue: { select: () => EMPTY } },
{
provide: PlaylistsService,
useValue: { updatePlaylistMeta },
},
{ provide: EpgService, useValue: { fetchEpg: jest.fn() } },
{ provide: Router, useValue: {} },
{
provide: SettingsStore,
useValue: { getSettings: () => ({ epgUrl: [] }) },
},
],
});
effects = runInInjectionContext(injector, () => new PlaylistEffects());
effects.updatePlaylistMeta$.subscribe();
});
it('skips persistence when an awaited owner already saved the update', () => {
actions$.next(
PlaylistActions.updatePlaylistMeta({
playlist,
persist: false,
})
);
expect(updatePlaylistMeta).not.toHaveBeenCalled();
});
it('persists ordinary metadata updates', () => {
actions$.next(PlaylistActions.updatePlaylistMeta({ playlist }));
expect(updatePlaylistMeta).toHaveBeenCalledWith(playlist);
});
});
@@ -654,6 +654,7 @@ describe('PlaylistInfoComponent', () => {
playlist: expect.objectContaining({
macAddress: '00:1A:79:AB:CD:EF',
}) as PlaylistMeta,
persist: false,
})
);
expect(
@@ -753,6 +754,7 @@ describe('PlaylistInfoComponent', () => {
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: resolvedPlaylist,
persist: false,
})
);
expect(
@@ -761,6 +763,48 @@ describe('PlaylistInfoComponent', () => {
expect(dialogRef.close).toHaveBeenCalledTimes(1);
});
it('does not update UI state or report success when resolved persistence fails', async () => {
const consoleError = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
createStalkerComponent();
const resolvedPlaylist = {
...component.playlistDetails.getRawValue(),
portalUrl: 'https://portal.example.com/server/load.php',
isFullStalkerPortal: true,
stalkerSessionPatch: {
stalkerToken: 'NEW_TOKEN',
stalkerSessionIdentity: 'new-fingerprint',
},
};
stalkerConnectionEditor.resolveConnection.mockResolvedValue({
status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.RESOLVED,
playlist: resolvedPlaylist,
});
stalkerConnectionEditor.applyResolvedConnection.mockRejectedValue(
new Error('write failed')
);
component.playlistDetails
.get('username')
?.setValue('subscriber');
await component.saveChanges(
component.playlistDetails.getRawValue() as PlaylistMeta
);
expect(store.dispatch).not.toHaveBeenCalled();
expect(dialogRef.close).not.toHaveBeenCalled();
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FAILED',
'CLOSE',
{ duration: 3000 }
);
} finally {
consoleError.mockRestore();
}
});
it.each([
STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED,
STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE,
@@ -412,18 +412,22 @@ export class PlaylistInfoComponent {
await this.updateXtreamPlaylist(normalizedPlaylist);
}
// Dispatch store action to update UI
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: normalizedPlaylist,
})
);
if (resolvedStalkerConnection) {
await this.stalkerConnectionEditor.applyResolvedConnection(
normalizedPlaylist
);
}
// Resolved Stalker edits cross an awaited, atomic persistence
// boundary above. Their action updates NgRx only; every other
// metadata edit keeps the effect-owned persistence path.
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: normalizedPlaylist,
...(resolvedStalkerConnection ? { persist: false } : {}),
})
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
@@ -38,7 +38,7 @@ export interface StalkerPlaylistConnectionEditor {
resolveConnection(
playlist: PlaylistMeta
): Promise<StalkerPlaylistConnectionResult>;
/** Synchronizes in-run Stalker state after the resolved update dispatches. */
/** Atomically persists a resolved edit, then synchronizes in-run state. */
applyResolvedConnection(playlist: PlaylistMetaUpdate): Promise<void>;
}
@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { of, throwError } from 'rxjs';
import { DataService, PlaylistsService } from '@iptvnator/services';
import type { Playlist } from '@iptvnator/shared/interfaces';
import { StalkerSessionService } from './stalker-session.service';
@@ -70,10 +70,15 @@ describe('Stalker edited-session coordination', () => {
await expect(oldAuthentication).rejects.toThrow(/stale/i);
await replacement;
expect(service.getCachedToken(oldPlaylist._id)).toBe('NEW_TOKEN');
expect(updateStalkerSession).toHaveBeenLastCalledWith(
oldPlaylist._id,
expect.objectContaining({ stalkerToken: 'NEW_TOKEN' })
expect(updatePlaylistMeta).toHaveBeenLastCalledWith(
expect.objectContaining({
portalUrl: 'https://new.example.com/server/load.php',
stalkerSessionPatch: expect.objectContaining({
stalkerToken: 'NEW_TOKEN',
}),
})
);
expect(updateStalkerSession).not.toHaveBeenCalled();
});
it('prevents late pre-edit auth from restoring a cleared simple session', async () => {
@@ -104,4 +109,60 @@ describe('Stalker edited-session coordination', () => {
})
);
});
it('persists a resolved full connection and session in one metadata write', async () => {
const editedPlaylist = {
...oldPlaylist,
portalUrl: 'https://new.example.com/server/load.php',
username: 'subscriber',
stalkerToken: 'NEW_TOKEN',
stalkerSessionIdentity: 'new-fingerprint',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 5,
};
await service.replaceSessionAfterEdit(editedPlaylist);
expect(updatePlaylistMeta).toHaveBeenCalledWith(
expect.objectContaining({
portalUrl: 'https://new.example.com/server/load.php',
username: 'subscriber',
stalkerSessionPatch: {
stalkerToken: 'NEW_TOKEN',
stalkerSessionIdentity: expect.any(String),
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 5,
stalkerAccountInfo: undefined,
},
})
);
expect(updateStalkerSession).not.toHaveBeenCalled();
});
it('does not adopt a resolved full session when its atomic write fails', async () => {
service.adoptDiscoveredSimplePortal(oldPlaylist);
updatePlaylistMeta.mockReturnValueOnce(
throwError(() => new Error('write failed'))
);
const editedPlaylist = {
...oldPlaylist,
portalUrl: 'https://new.example.com/server/load.php',
stalkerToken: 'NEW_TOKEN',
stalkerSessionIdentity: 'new-fingerprint',
};
await expect(
service.replaceSessionAfterEdit(editedPlaylist)
).rejects.toThrow('write failed');
expect(service.getCachedToken(oldPlaylist._id)).toBeNull();
// A failed edit must release its pending authority as well: the
// still-persisted connection remains usable without an app restart.
service.setCachedToken(oldPlaylist._id, 'OLD_TOKEN', oldPlaylist);
await expect(service.ensureToken(oldPlaylist)).resolves.toEqual({
token: 'OLD_TOKEN',
serialNumber: undefined,
});
});
});
@@ -12,7 +12,8 @@ import type { StalkerWatchdogController } from './stalker-watchdog.controller';
/** Serializes authoritative Edit results against pre-edit authentication. */
export class StalkerEditedSessionCoordinator {
private readonly authoritativeFingerprints = new Map<string, string>();
private readonly replacements = new Map<string, Promise<void>>();
private readonly pendingFingerprints = new Map<string, string>();
private readonly replacements = new Map<string, Promise<Playlist>>();
constructor(
private readonly tokens: StalkerTokenCache,
@@ -38,15 +39,22 @@ export class StalkerEditedSessionCoordinator {
}
assertCurrent(playlistId: string, fingerprint: string): void {
const authoritative = this.authoritativeFingerprints.get(playlistId);
const authoritative =
this.pendingFingerprints.get(playlistId) ??
this.authoritativeFingerprints.get(playlistId);
if (authoritative && authoritative !== fingerprint) {
throw new Error('Stale Stalker playlist configuration');
}
}
replace(playlist: Playlist): Promise<void> {
replace(playlist: Playlist): Promise<Playlist> {
const playlistId = playlist._id;
const fingerprint = this.markAuthoritative(playlist);
const fingerprint = stalkerSessionFingerprint(playlist);
// Fence the old connection immediately, but do not make the edit
// authoritative until its single persistence write succeeds. A
// failed write therefore releases this fence and leaves the prior
// runtime session usable.
this.pendingFingerprints.set(playlistId, fingerprint);
const previous = this.replacements.get(playlistId) ?? Promise.resolve();
const replacement = previous
.catch(() => undefined)
@@ -57,6 +65,9 @@ export class StalkerEditedSessionCoordinator {
if (this.replacements.get(playlistId) === replacement) {
this.replacements.delete(playlistId);
}
if (this.pendingFingerprints.get(playlistId) === fingerprint) {
this.pendingFingerprints.delete(playlistId);
}
})
.catch(() => undefined);
return replacement;
@@ -65,7 +76,7 @@ export class StalkerEditedSessionCoordinator {
private async commit(
playlist: Playlist,
fingerprint: string
): Promise<void> {
): Promise<Playlist> {
const playlistId = playlist._id;
this.assertCurrent(playlistId, fingerprint);
await this.tokens
@@ -74,35 +85,49 @@ export class StalkerEditedSessionCoordinator {
this.assertCurrent(playlistId, fingerprint);
const playlists = this.resolvePlaylistsService();
if (!isFullStalkerPortalPlaylist(playlist)) {
const isFullPortal = isFullStalkerPortalPlaylist(playlist);
let sessionPatch: PlaylistMetaUpdate['stalkerSessionPatch'] = null;
if (isFullPortal) {
const token = playlist.stalkerToken;
if (!token) {
throw new Error(
'Resolved full Stalker portal is missing a session token'
);
}
sessionPatch = {
stalkerToken: token,
stalkerSessionIdentity: fingerprint,
stalkerWatchdogTimeout: playlist.stalkerWatchdogTimeout,
stalkerTimeslot: playlist.stalkerTimeslot,
stalkerAccountInfo: playlist.stalkerAccountInfo,
};
}
const persistedPlaylist = await firstValueFrom(
playlists.updatePlaylistMeta({
...playlist,
stalkerSessionPatch: sessionPatch,
} as PlaylistMetaUpdate)
);
if (!persistedPlaylist) {
throw new Error('Resolved Stalker playlist could not be persisted');
}
this.assertCurrent(playlistId, fingerprint);
this.authoritativeFingerprints.set(playlistId, fingerprint);
if (this.pendingFingerprints.get(playlistId) === fingerprint) {
this.pendingFingerprints.delete(playlistId);
}
if (!sessionPatch) {
this.tokens.clear(playlistId);
await firstValueFrom(
playlists.updatePlaylistMeta({
...playlist,
stalkerSessionPatch: null,
} as PlaylistMetaUpdate)
);
return;
return persistedPlaylist;
}
if (!playlist.stalkerToken) {
throw new Error(
'Resolved full Stalker portal is missing a session token'
);
}
this.tokens.set(playlistId, playlist.stalkerToken, fingerprint);
this.tokens.set(playlistId, sessionPatch.stalkerToken, fingerprint);
this.watchdog.applyProfileTiming(playlistId, {
watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout,
timeslotSeconds: playlist.stalkerTimeslot,
});
await firstValueFrom(
playlists.updateStalkerSession(playlistId, {
stalkerToken: playlist.stalkerToken,
stalkerSessionIdentity: fingerprint,
stalkerWatchdogTimeout: playlist.stalkerWatchdogTimeout,
stalkerTimeslot: playlist.stalkerTimeslot,
})
);
return persistedPlaylist;
}
}
@@ -490,7 +490,7 @@ describe('StalkerPortalRepairService', () => {
);
});
it('retires a remembered endpoint override before explicit Edit installs its result', async () => {
it('fences an edit without changing runtime state until persistence commits', async () => {
const wrongEndpoint = {
...MISCLASSIFIED,
portalUrl: 'http://ministra.example/portal.php',
@@ -503,14 +503,19 @@ describe('StalkerPortalRepairService', () => {
token: 'TOKEN2',
});
await service.repairPortal(wrongEndpoint);
clearCachedToken.mockClear();
service.fenceForPlaylistEdit(wrongEndpoint._id);
expect(service.applyOverride(wrongEndpoint).portalUrl).toBe(
'http://ministra.example/server/load.php'
);
expect(clearCachedToken).not.toHaveBeenCalled();
service.retireForPlaylistEdit(wrongEndpoint._id);
service.commitPlaylistEdit(wrongEndpoint._id);
expect(service.applyOverride(wrongEndpoint)).toBe(wrongEndpoint);
expect(clearCachedToken).toHaveBeenLastCalledWith('portal-1');
expect(clearCachedToken).not.toHaveBeenCalled();
});
it('discards a repair verified before explicit Edit but completed after it', async () => {
@@ -538,7 +543,7 @@ describe('StalkerPortalRepairService', () => {
// The transform has verified A and computed B, but its async
// persistence has not completed. Explicit Edit C must fence all
// repair-side runtime/session effects that follow that await.
service.retireForPlaylistEdit(wrongEndpoint._id);
service.fenceForPlaylistEdit(wrongEndpoint._id);
writeCompletion.next(writtenRow);
await expect(repair).resolves.toBeNull();
@@ -173,16 +173,25 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
}
/**
* Retires a lazy-repair override before an explicit Edit result is
* installed. Discovery has just proved that exact endpoint and mode, so
* an override remembered for the pre-edit connection must not rewrite it.
* Invalidates every repair that started before an explicit Edit, without
* changing the working override or token yet. Persistence may still fail;
* in that case the previous runtime connection must remain untouched.
*/
retireForPlaylistEdit(playlistId: string): void {
fenceForPlaylistEdit(playlistId: string): void {
this.editGenerations.set(
playlistId,
(this.editGenerations.get(playlistId) ?? 0) + 1
);
this.dropOverride(playlistId);
}
/**
* Removes the pre-edit override after the resolved row and session have
* been committed. The session coordinator has already replaced or
* cleared the cached token, so clearing it here would discard the newly
* authoritative session.
*/
commitPlaylistEdit(playlistId: string): void {
this.overrides.delete(playlistId);
}
/**
@@ -232,13 +232,13 @@ export class StalkerSessionService {
/**
* Installs the session produced by explicit Edit discovery.
*
* The new fingerprint becomes authoritative synchronously, before an old
* authentication is awaited. That old request consequently fails its
* post-auth guard and cannot overwrite the resolved token (or restore a
* token after a full→simple edit). The final write is queued after it so
* the resolved session remains the last persisted session mutation.
* The new fingerprint fences old authentication synchronously, but only
* becomes authoritative after the atomic write succeeds. That old request
* consequently cannot overwrite the resolved token (or restore one after
* a full→simple edit), while a failed write releases the fence and keeps
* the previous runtime session usable.
*/
replaceSessionAfterEdit(playlist: Playlist): Promise<void> {
replaceSessionAfterEdit(playlist: Playlist): Promise<Playlist> {
return this.editedSessions.replace(playlist);
}