fix(stalker): serialize edit discovery

This commit is contained in:
4gray committed 2026-08-09 09:59:30 +02:00
1 parent 3505eafdd1
commit b387fe2a55
9 files changed
+370 -54

No files matched your search

+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 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.
- 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`. Before discovery it reserves the playlist, fences new authentication/repair work and drains existing work; failure releases the reservation without changing the saved or runtime connection. 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. Runtime session authority may rebase a changed fingerprint only when the persisted row proves that it owns the same playlist ID, so delete/restore and backup merge remain usable. 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,16 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
};
const portalRepair = {
applyOverride: jest.fn((playlist: PlaylistMeta) => playlist),
fenceForPlaylistEdit: jest.fn(),
fenceForPlaylistEdit: jest.fn(() => Promise.resolve()),
releasePlaylistEdit: jest.fn(),
commitPlaylistEdit: jest.fn(),
};
const stalkerSession = {
beginEditDiscovery: jest.fn(async (playlist: PlaylistMeta) => ({
playlistId: playlist._id,
owner: Symbol('edit-fence'),
})),
cancelEditDiscovery: jest.fn(),
replaceSessionAfterEdit: jest.fn(
async (playlist: PlaylistMeta) => playlist
),
@@ -193,6 +199,50 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE,
message: 'HOME.STALKER_PORTAL.EDIT_UNREACHABLE',
});
expect(stalkerSession.cancelEditDiscovery).toHaveBeenCalled();
expect(portalRepair.releasePlaylistEdit).toHaveBeenCalledWith(
draft._id
);
});
it('does not start discovery until old authentication and repair work drain', async () => {
let finishSessionFence: (fence: {
playlistId: string;
owner: symbol;
}) => void = () => undefined;
let finishRepairFence: () => void = () => undefined;
stalkerSession.beginEditDiscovery.mockReturnValueOnce(
new Promise((resolve) => {
finishSessionFence = resolve;
})
);
portalRepair.fenceForPlaylistEdit.mockReturnValueOnce(
new Promise((resolve) => {
finishRepairFence = resolve;
})
);
discovery.discover.mockResolvedValue({
status: 'resolved',
portalUrl: 'https://portal.example.com/portal.php',
isFullStalkerPortal: false,
});
const resolving = service.resolveConnection(draft);
await Promise.resolve();
expect(discovery.discover).not.toHaveBeenCalled();
finishSessionFence({
playlistId: draft._id,
owner: Symbol('edit-fence'),
});
await Promise.resolve();
expect(discovery.discover).not.toHaveBeenCalled();
finishRepairFence();
await resolving;
expect(discovery.discover).toHaveBeenCalledTimes(1);
});
it('replaces the active runtime playlist and session after a resolved full-portal edit', async () => {
@@ -227,7 +277,8 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
stalkerSessionIdentity: 'new-fingerprint',
stalkerWatchdogTimeout: 90,
stalkerTimeslot: 5,
})
}),
expect.objectContaining({ playlistId: draft._id })
);
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
@@ -301,7 +352,8 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
isFullStalkerPortal: false,
stalkerToken: undefined,
stalkerSessionIdentity: undefined,
})
}),
expect.objectContaining({ playlistId: draft._id })
);
expect(activePlaylist).toEqual(
expect.objectContaining({
@@ -373,6 +425,9 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
draft._id
);
expect(portalRepair.commitPlaylistEdit).not.toHaveBeenCalled();
expect(portalRepair.releasePlaylistEdit).toHaveBeenCalledWith(
draft._id
);
expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled();
expect(activePlaylist?.portalUrl).toBe(
'https://old.example.com/server/load.php'
@@ -421,7 +476,8 @@ describe('AppStalkerPlaylistConnectionEditorService', () => {
portalUrl: 'https://portal.example.com/server/load.php',
username: 'new-user',
stalkerToken: 'NEW_TOKEN',
})
}),
expect.objectContaining({ playlistId: draft._id })
);
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledTimes(
isActive ? 1 : 0
@@ -32,6 +32,10 @@ const STALKER_EDIT_ERROR_KEY_BY_KIND: Readonly<
'auth-failed': 'HOME.STALKER_PORTAL.AUTH_FAILED',
};
type StalkerEditFence = Awaited<
ReturnType<StalkerSessionService['beginEditDiscovery']>
>;
@Injectable({ providedIn: 'root' })
export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylistConnectionEditor {
private readonly discovery = inject(StalkerPortalDiscoveryService);
@@ -39,6 +43,7 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
private readonly stalkerSession = inject(StalkerSessionService);
private readonly stalkerStore = inject(StalkerStore);
private readonly translate = inject(TranslateService);
private readonly editFences = new Map<string, StalkerEditFence>();
async applyResolvedConnection(playlist: PlaylistMetaUpdate): Promise<void> {
// Edit discovery is newer and more authoritative than a lazy repair
@@ -46,16 +51,29 @@ 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.fenceForPlaylistEdit(runtimePlaylist._id);
const persistedPlaylist =
await this.stalkerSession.replaceSessionAfterEdit(runtimePlaylist);
this.portalRepair.commitPlaylistEdit(runtimePlaylist._id);
const fence =
this.editFences.get(runtimePlaylist._id) ??
(await this.beginEditFence(runtimePlaylist));
try {
const persistedPlaylist =
await this.stalkerSession.replaceSessionAfterEdit(
runtimePlaylist,
fence
);
this.portalRepair.commitPlaylistEdit(runtimePlaylist._id);
this.editFences.delete(runtimePlaylist._id);
if (this.stalkerStore.currentPlaylist()?._id === runtimePlaylist._id) {
// 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);
if (
this.stalkerStore.currentPlaylist()?._id === runtimePlaylist._id
) {
// The persistence result is the complete row merged by
// PlaylistsService, so backup-restored playback headers and
// other metadata absent from the form survive replacement.
await this.stalkerStore.setCurrentPlaylist(persistedPlaylist);
}
} catch (error) {
this.releaseEditFence(runtimePlaylist._id, fence);
throw error;
}
}
@@ -77,19 +95,31 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
stalkerSignature1: identity.signature1 ?? '',
stalkerSignature2: identity.signature2 ?? '',
};
const outcome = await this.discovery.discover(
playlist.portalUrl ?? '',
playlist.macAddress ?? '',
identity,
{
credentials: {
username: playlist.username ?? '',
password: playlist.password ?? '',
},
}
const fence = await this.beginEditFence(
this.toRuntimePlaylist(normalizedPlaylist)
);
let outcome: Awaited<
ReturnType<StalkerPortalDiscoveryService['discover']>
>;
try {
outcome = await this.discovery.discover(
playlist.portalUrl ?? '',
playlist.macAddress ?? '',
identity,
{
credentials: {
username: playlist.username ?? '',
password: playlist.password ?? '',
},
}
);
} catch (error) {
this.releaseEditFence(playlist._id, fence);
throw error;
}
if (outcome.status === 'unreachable') {
this.releaseEditFence(playlist._id, fence);
return {
status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.UNREACHABLE,
message: this.translate.instant(
@@ -99,6 +129,7 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
}
if (outcome.status === 'auth-rejected') {
this.releaseEditFence(playlist._id, fence);
return {
status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED,
message: this.buildAuthErrorMessage(outcome.error),
@@ -122,6 +153,7 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
}
if (!outcome.token) {
this.releaseEditFence(playlist._id, fence);
return {
status: STALKER_PLAYLIST_CONNECTION_EDITOR_STATUS.AUTH_REJECTED,
message: this.translate.instant(
@@ -156,6 +188,41 @@ export class AppStalkerPlaylistConnectionEditorService implements StalkerPlaylis
};
}
private async beginEditFence(
playlist: Playlist
): Promise<StalkerEditFence> {
// Both fences are installed synchronously before either drain is
// awaited. No new authentication or lazy repair can start while the
// work that predates this Edit is settling.
const repairDrain = this.portalRepair.fenceForPlaylistEdit(
playlist._id
);
let fence: StalkerEditFence | undefined;
try {
fence = await this.stalkerSession.beginEditDiscovery(playlist);
await repairDrain;
this.editFences.set(playlist._id, fence);
return fence;
} catch (error) {
if (fence) {
this.stalkerSession.cancelEditDiscovery(fence);
}
this.portalRepair.releasePlaylistEdit(playlist._id);
throw error;
}
}
private releaseEditFence(
playlistId: string,
fence: StalkerEditFence
): void {
if (this.editFences.get(playlistId) === fence) {
this.editFences.delete(playlistId);
}
this.stalkerSession.cancelEditDiscovery(fence);
this.portalRepair.releasePlaylistEdit(playlistId);
}
private buildAuthErrorMessage(error: unknown): string {
const portalError = asStalkerPortalError(error);
const headline = this.translate.instant(
+9 -2
View File
@@ -199,8 +199,11 @@ device IDs and signatures as one connection identity. A metadata-only edit
does not run discovery and preserves the stored connection byte-for-byte.
Changing any connection field disables the form while the same discovery
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
the dialog open and writes nothing. Before discovery starts, Edit reserves the
playlist, fences new authentication/repair work and drains any work already in
flight; failure releases that reservation with the previous runtime untouched.
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. That awaited write returns the
complete merged playlist row before NgRx receives its state-only update and
@@ -208,6 +211,10 @@ 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.
Runtime session authority normally rejects stale playlist objects, but a
different fingerprint may rebase only after the current persisted row proves
that it owns the same playlist ID; this keeps delete/restore and backup merge
flows usable without letting an in-flight stale request overrule Edit.
`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
@@ -81,6 +81,33 @@ describe('Stalker edited-session coordination', () => {
expect(updateStalkerSession).not.toHaveBeenCalled();
});
it('fences and drains pre-edit authentication before discovery may start', async () => {
const oldAuthentication = service.ensureToken(oldPlaylist);
while (authenticate.mock.calls.length === 0) {
await Promise.resolve();
}
const editedPlaylist = {
...oldPlaylist,
portalUrl: 'https://new.example.com/server/load.php',
};
let fenceSettled = false;
const fencePromise = service
.beginEditDiscovery(editedPlaylist)
.then((fence) => {
fenceSettled = true;
return fence;
});
await Promise.resolve();
expect(fenceSettled).toBe(false);
resolveOldAuthentication({ token: 'OLD_TOKEN' });
await expect(oldAuthentication).rejects.toThrow(/stale/i);
const fence = await fencePromise;
service.cancelEditDiscovery(fence);
});
it('prevents late pre-edit auth from restoring a cleared simple session', async () => {
const oldAuthentication = service.ensureToken(oldPlaylist);
while (authenticate.mock.calls.length === 0) {
@@ -165,4 +192,21 @@ describe('Stalker edited-session coordination', () => {
serialNumber: undefined,
});
});
it('rebases stale authority when a restored persisted row owns the playlist id', async () => {
const editedPlaylist = {
...oldPlaylist,
portalUrl: 'https://new.example.com/server/load.php',
stalkerToken: 'NEW_TOKEN',
};
await service.replaceSessionAfterEdit(editedPlaylist);
// Simulate delete + backup restore of the original row and ID.
service.setCachedToken(oldPlaylist._id, 'RESTORED_TOKEN', oldPlaylist);
await expect(service.ensureToken(oldPlaylist)).resolves.toEqual({
token: 'RESTORED_TOKEN',
serialNumber: undefined,
});
});
});
@@ -9,10 +9,21 @@ import { stalkerSessionFingerprint } from './stalker-session-store';
import type { StalkerTokenCache } from './stalker-token-cache';
import type { StalkerWatchdogController } from './stalker-watchdog.controller';
/** Opaque ownership proof for one discovery-to-persistence Edit attempt. */
export interface StalkerEditFence {
readonly playlistId: string;
readonly owner: symbol;
}
interface PendingEdit {
readonly owner: symbol;
readonly fingerprint: string;
}
/** Serializes authoritative Edit results against pre-edit authentication. */
export class StalkerEditedSessionCoordinator {
private readonly authoritativeFingerprints = new Map<string, string>();
private readonly pendingFingerprints = new Map<string, string>();
private readonly pendingEdits = new Map<string, PendingEdit>();
private readonly replacements = new Map<string, Promise<Playlist>>();
constructor(
@@ -29,7 +40,14 @@ export class StalkerEditedSessionCoordinator {
async guard(playlist: Playlist): Promise<string> {
const fingerprint = stalkerSessionFingerprint(playlist);
this.assertCurrent(playlist._id, fingerprint);
const pending = this.pendingEdits.get(playlist._id);
if (pending && pending.fingerprint !== fingerprint) {
throw new Error('Stale Stalker playlist configuration');
}
const authoritative = this.authoritativeFingerprints.get(playlist._id);
if (!pending && authoritative && authoritative !== fingerprint) {
await this.rebaseFromPersistedRow(playlist, fingerprint);
}
const replacement = this.replacements.get(playlist._id);
if (replacement) {
await replacement;
@@ -40,33 +58,70 @@ export class StalkerEditedSessionCoordinator {
assertCurrent(playlistId: string, fingerprint: string): void {
const authoritative =
this.pendingFingerprints.get(playlistId) ??
this.pendingEdits.get(playlistId)?.fingerprint ??
this.authoritativeFingerprints.get(playlistId);
if (authoritative && authoritative !== fingerprint) {
throw new Error('Stale Stalker playlist configuration');
}
}
replace(playlist: Playlist): Promise<Playlist> {
async beginEdit(playlist: Playlist): Promise<StalkerEditFence> {
const playlistId = playlist._id;
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 fence = { playlistId, owner: Symbol('stalker-edit') };
this.pendingEdits.set(playlistId, {
owner: fence.owner,
fingerprint,
});
try {
await this.replacements.get(playlistId)?.catch(() => undefined);
this.assertFenceCurrent(fence, fingerprint);
await this.tokens
.getPending(playlistId)
?.promise.catch(() => undefined);
this.assertFenceCurrent(fence, fingerprint);
return fence;
} catch (error) {
this.cancelEdit(fence);
throw error;
}
}
cancelEdit(fence: StalkerEditFence): void {
if (this.pendingEdits.get(fence.playlistId)?.owner === fence.owner) {
this.pendingEdits.delete(fence.playlistId);
}
}
replace(playlist: Playlist, fence?: StalkerEditFence): Promise<Playlist> {
const playlistId = playlist._id;
const fingerprint = stalkerSessionFingerprint(playlist);
const owner = fence?.owner ?? Symbol('stalker-edit');
if (
fence &&
(fence.playlistId !== playlistId ||
this.pendingEdits.get(playlistId)?.owner !== fence.owner)
) {
return Promise.reject(
new Error('Stale Stalker playlist configuration')
);
}
// Keep the same owner while discovery replaces its input-shaped
// fingerprint with the resolved endpoint/mode fingerprint.
this.pendingEdits.set(playlistId, { owner, fingerprint });
const previous = this.replacements.get(playlistId) ?? Promise.resolve();
const replacement = previous
.catch(() => undefined)
.then(() => this.commit(playlist, fingerprint));
.then(() => this.commit(playlist, fingerprint, owner));
this.replacements.set(playlistId, replacement);
void replacement
.finally(() => {
if (this.replacements.get(playlistId) === replacement) {
this.replacements.delete(playlistId);
}
if (this.pendingFingerprints.get(playlistId) === fingerprint) {
this.pendingFingerprints.delete(playlistId);
if (this.pendingEdits.get(playlistId)?.owner === owner) {
this.pendingEdits.delete(playlistId);
}
})
.catch(() => undefined);
@@ -75,14 +130,15 @@ export class StalkerEditedSessionCoordinator {
private async commit(
playlist: Playlist,
fingerprint: string
fingerprint: string,
owner: symbol
): Promise<Playlist> {
const playlistId = playlist._id;
this.assertCurrent(playlistId, fingerprint);
this.assertFenceCurrent({ playlistId, owner }, fingerprint);
await this.tokens
.getPending(playlistId)
?.promise.catch(() => undefined);
this.assertCurrent(playlistId, fingerprint);
this.assertFenceCurrent({ playlistId, owner }, fingerprint);
const playlists = this.resolvePlaylistsService();
const isFullPortal = isFullStalkerPortalPlaylist(playlist);
@@ -112,10 +168,10 @@ export class StalkerEditedSessionCoordinator {
if (!persistedPlaylist) {
throw new Error('Resolved Stalker playlist could not be persisted');
}
this.assertCurrent(playlistId, fingerprint);
this.assertFenceCurrent({ playlistId, owner }, fingerprint);
this.authoritativeFingerprints.set(playlistId, fingerprint);
if (this.pendingFingerprints.get(playlistId) === fingerprint) {
this.pendingFingerprints.delete(playlistId);
if (this.pendingEdits.get(playlistId)?.owner === owner) {
this.pendingEdits.delete(playlistId);
}
if (!sessionPatch) {
@@ -130,4 +186,40 @@ export class StalkerEditedSessionCoordinator {
});
return persistedPlaylist;
}
private assertFenceCurrent(
fence: StalkerEditFence,
fingerprint: string
): void {
const pending = this.pendingEdits.get(fence.playlistId);
if (
!pending ||
pending.owner !== fence.owner ||
pending.fingerprint !== fingerprint
) {
throw new Error('Stale Stalker playlist configuration');
}
}
private async rebaseFromPersistedRow(
playlist: Playlist,
fingerprint: string
): Promise<void> {
try {
const persisted = await firstValueFrom(
this.resolvePlaylistsService().getPlaylistById(playlist._id)
);
if (
persisted &&
stalkerSessionFingerprint(persisted) === fingerprint
) {
this.authoritativeFingerprints.set(playlist._id, fingerprint);
return;
}
} catch {
// Preserve the stable stale-configuration error below. A failed
// row read cannot prove that an external replacement owns the ID.
}
throw new Error('Stale Stalker playlist configuration');
}
}
@@ -518,6 +518,19 @@ describe('StalkerPortalRepairService', () => {
expect(clearCachedToken).not.toHaveBeenCalled();
});
it('does not start another repair while explicit Edit discovery owns the playlist', async () => {
const fence = service.fenceForPlaylistEdit(MISCLASSIFIED._id);
discover.mockClear();
await expect(
service.repairPortal(MISCLASSIFIED)
).resolves.toBeNull();
expect(discover).not.toHaveBeenCalled();
service.releasePlaylistEdit(MISCLASSIFIED._id);
await fence;
});
it('discards a repair verified before explicit Edit but completed after it', async () => {
const wrongEndpoint = {
...MISCLASSIFIED,
@@ -99,6 +99,7 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
>();
/** Explicit Edit invalidates every repair that started before it. */
private readonly editGenerations = new Map<string, number>();
private readonly editFenceCounts = new Map<string, number>();
constructor() {
// The watchdog resolves its playlist from the persisted row; while a
@@ -177,11 +178,29 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
* changing the working override or token yet. Persistence may still fail;
* in that case the previous runtime connection must remain untouched.
*/
fenceForPlaylistEdit(playlistId: string): void {
fenceForPlaylistEdit(playlistId: string): Promise<void> {
this.editFenceCounts.set(
playlistId,
(this.editFenceCounts.get(playlistId) ?? 0) + 1
);
this.editGenerations.set(
playlistId,
(this.editGenerations.get(playlistId) ?? 0) + 1
);
return (
this.pendingRepairs.get(playlistId)?.catch(() => null) ??
Promise.resolve()
).then(() => undefined);
}
/** Releases the repair fence when discovery or persistence fails. */
releasePlaylistEdit(playlistId: string): void {
const remaining = (this.editFenceCounts.get(playlistId) ?? 1) - 1;
if (remaining > 0) {
this.editFenceCounts.set(playlistId, remaining);
} else {
this.editFenceCounts.delete(playlistId);
}
}
/**
@@ -192,6 +211,7 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
*/
commitPlaylistEdit(playlistId: string): void {
this.overrides.delete(playlistId);
this.releasePlaylistEdit(playlistId);
}
/**
@@ -267,6 +287,9 @@ export class StalkerPortalRepairService implements StalkerPortalRepairApi {
*/
async repairPortal(playlist: PlaylistMeta): Promise<PlaylistMeta | null> {
const playlistId = playlist._id;
if ((this.editFenceCounts.get(playlistId) ?? 0) > 0) {
return null;
}
const pending = this.pendingRepairs.get(playlistId);
if (pending) {
@@ -24,6 +24,7 @@ import {
} from './stalker-auth.api';
import { StalkerAuthenticatedRequestClient } from './stalker-authenticated-request-client';
import { StalkerEditedSessionCoordinator } from './stalker-edited-session-coordinator';
import type { StalkerEditFence } from './stalker-edited-session-coordinator';
import {
stalkerSessionFingerprint,
StalkerSessionStore,
@@ -38,6 +39,7 @@ export {
stalkerIdentityFingerprint,
};
export type { StalkerPortalIdentity };
export type { StalkerEditFence };
export type {
StalkerAuthenticateOptions,
StalkerAuthenticationResult,
@@ -230,16 +232,28 @@ export class StalkerSessionService {
}
/**
* Installs the session produced by explicit Edit discovery.
*
* 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.
* Reserves the playlist for Edit and drains authentication that began
* before discovery. The opaque fence keeps new authentication out until
* the result is either cancelled or atomically persisted.
*/
replaceSessionAfterEdit(playlist: Playlist): Promise<Playlist> {
return this.editedSessions.replace(playlist);
beginEditDiscovery(playlist: Playlist): Promise<StalkerEditFence> {
return this.editedSessions.beginEdit(playlist);
}
/** Releases a discovery reservation whose result will not be saved. */
cancelEditDiscovery(fence: StalkerEditFence): void {
this.editedSessions.cancelEdit(fence);
}
/**
* Installs the session produced by explicit Edit discovery. The new
* fingerprint becomes authoritative only after the atomic row write.
*/
replaceSessionAfterEdit(
playlist: Playlist,
fence?: StalkerEditFence
): Promise<Playlist> {
return this.editedSessions.replace(playlist, fence);
}
/**