fix(stalker): harden session compatibility

This commit is contained in:
4gray committed 2026-07-27 14:00:30 +02:00
1 parent 63ab2837a7
commit bdf353a3b8
31 files changed
+1067 -82

No files matched your search

@@ -15,7 +15,7 @@ import {
StalkerSessionConnectionOutcome,
StalkerSessionFullReadyOutcome,
} from '@iptvnator/shared/interfaces';
import { EMPTY, Subject, of, throwError } from 'rxjs';
import { Subject, of, throwError } from 'rxjs';
import { StalkerCredentialsDialogComponent } from './stalker-credentials-dialog.component';
import { StalkerConnectionFlowService } from './stalker-connection-flow.service';
import { StalkerOriginApprovalDialogComponent } from './stalker-origin-approval-dialog.component';
@@ -178,6 +178,14 @@ describe('StalkerConnectionFlowService', () => {
let dialog: {
open: jest.Mock;
};
let snackAction: Subject<void>;
let snackBar: {
open: jest.Mock;
};
let snackRef: {
dismiss: jest.Mock;
onAction: () => Subject<void>;
};
let queuedDialogResults: unknown[];
beforeEach(() => {
@@ -190,6 +198,14 @@ describe('StalkerConnectionFlowService', () => {
store = {
dispatch: jest.fn(),
};
snackAction = new Subject<void>();
snackRef = {
dismiss: jest.fn(),
onAction: () => snackAction,
};
snackBar = {
open: jest.fn(() => snackRef),
};
queuedDialogResults = [];
dialog = {
open: jest.fn(() => ({
@@ -219,11 +235,7 @@ describe('StalkerConnectionFlowService', () => {
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(() => ({
onAction: () => EMPTY,
})),
},
useValue: snackBar,
},
{
provide: TranslateService,
@@ -602,6 +614,138 @@ describe('StalkerConnectionFlowService', () => {
expect(playlists.persistStalkerConnection).not.toHaveBeenCalled();
});
it('offers an actionable retry with the stable terminal reason', async () => {
session.open.mockResolvedValueOnce({
kind: 'failure',
reason: 'incompatible-response',
requestId: 'request-terminal-failure',
retryable: false,
stage: 'resolving',
});
session.forceRedetect.mockResolvedValueOnce(READY);
await expect(
service.ensureConnected(LEGACY_PLAYLIST)
).resolves.toBeUndefined();
expect(snackBar.open).toHaveBeenCalledWith(
expect.stringContaining('incompatible-response'),
'HOME.STALKER_PORTAL.RETRY',
{ duration: 120_000 }
);
snackAction.next();
snackAction.next();
await flushPromises();
expect(session.forceRedetect).toHaveBeenCalledWith('lease-route-1');
expect(session.forceRedetect).toHaveBeenCalledTimes(1);
});
it('invalidates a terminal retry action when navigation cancels the flow', async () => {
session.open.mockResolvedValueOnce({
kind: 'failure',
reason: 'request-timeout',
requestId: 'request-terminal-cancel',
retryable: true,
stage: 'handshaking',
});
await service.ensureConnected(LEGACY_PLAYLIST);
await service.cancel();
snackAction.next();
await flushPromises();
expect(snackRef.dismiss).toHaveBeenCalled();
expect(session.forceRedetect).not.toHaveBeenCalled();
});
it('invalidates an in-flight typed run when a new run resolves through the stateless path', async () => {
const deferredOpen = createDeferred<StalkerSessionConnectionOutcome>();
session.open.mockReturnValueOnce(deferredOpen.promise);
const connecting = service.ensureConnected(LEGACY_PLAYLIST);
await flushPromises();
const statelessPlaylist = {
...LEGACY_PLAYLIST,
stalkerRecipeClassifierVersion: 1,
stalkerRequestRecipe: 'stateless-mac' as const,
};
await expect(
service.ensureConnected(statelessPlaylist)
).resolves.toBe(statelessPlaylist);
deferredOpen.resolve({
kind: 'failure',
reason: 'request-timeout',
requestId: 'request-stale-typed-failure',
retryable: true,
stage: 'handshaking',
});
await expect(connecting).resolves.toBeUndefined();
expect(snackBar.open).not.toHaveBeenCalled();
});
it('turns an asynchronous force-redetect continuation rejection into an actionable failure', async () => {
session.forceRedetect.mockResolvedValueOnce({
attemptNumber: 1,
attemptRef: 'attempt-redetect-rejection',
challengeRef: 'challenge-redetect-rejection',
kind: 'credentials-required',
requestId: 'request-redetect-rejection',
});
session.continue.mockRejectedValueOnce(
new Error('redetect-continue-failed')
);
queuedDialogResults.push({
password: 'password',
username: 'user',
});
await expect(
service.forceRedetect(LEGACY_PLAYLIST)
).resolves.toBeUndefined();
expect(snackBar.open).toHaveBeenLastCalledWith(
expect.stringContaining('connection-redetect-failed'),
'HOME.STALKER_PORTAL.RETRY',
{ duration: 120_000 }
);
});
it('turns an asynchronous recovery continuation rejection into an actionable failure', async () => {
session.open.mockResolvedValueOnce({
attemptNumber: 1,
attemptRef: 'attempt-recovery-rejection',
challengeRef: 'challenge-recovery-rejection',
kind: 'credentials-required',
requestId: 'request-recovery-rejection',
});
session.continue.mockRejectedValueOnce(
new Error('recovery-continue-failed')
);
queuedDialogResults.push({
password: 'password',
username: 'user',
});
const handler = session.recoveryHandler();
expect(handler).toBeDefined();
await expect(
handler?.({
playlist: LEGACY_PLAYLIST,
trigger: 'endpoint-shape',
})
).resolves.toBeUndefined();
expect(snackBar.open).toHaveBeenLastCalledWith(
expect.stringContaining('connection-recovery-failed'),
'HOME.STALKER_PORTAL.RETRY',
{ duration: 120_000 }
);
});
it('retains a ready attempt after a local write failure and retries the same draft', async () => {
session.open.mockResolvedValue(READY);
playlists.persistStalkerConnection.mockReturnValueOnce(
@@ -679,6 +823,44 @@ describe('StalkerConnectionFlowService', () => {
});
});
it('preserves the actionable terminal reason when promotion recovery returns a failure', async () => {
session.open.mockResolvedValueOnce(READY).mockResolvedValueOnce({
kind: 'failure',
reason: 'portal-unavailable',
requestId: 'request-promotion-reopen-failure',
retryable: true,
stage: 'handshaking',
});
session.commit.mockRejectedValueOnce(new Error('promotion-failed'));
await expect(
service.ensureConnected(LEGACY_PLAYLIST)
).resolves.toBeUndefined();
expect(snackBar.open).toHaveBeenLastCalledWith(
expect.stringContaining('portal-unavailable'),
'HOME.STALKER_PORTAL.RETRY',
{ duration: 120_000 }
);
});
it('offers an actionable retry when promotion recovery cannot reopen the persisted playlist', async () => {
session.open
.mockResolvedValueOnce(READY)
.mockRejectedValueOnce(new Error('reopen-failed'));
session.commit.mockRejectedValueOnce(new Error('promotion-failed'));
await expect(
service.ensureConnected(LEGACY_PLAYLIST)
).resolves.toBeUndefined();
expect(snackBar.open).toHaveBeenLastCalledWith(
expect.stringContaining('session-promotion-failed'),
'HOME.STALKER_PORTAL.RETRY',
{ duration: 120_000 }
);
});
it('discards a provisional ready outcome that arrives after cancellation of open', async () => {
const deferredOpen = createDeferred<StalkerSessionConnectionOutcome>();
session.open.mockReturnValueOnce(deferredOpen.promise);
@@ -47,6 +47,7 @@ export class StalkerConnectionFlowService {
private readonly translate = inject(TranslateService);
private readonly readySubject = new Subject<Playlist>();
private activeDialog: { close: () => void } | null = null;
private activeConnectionRetry: { dismiss: () => void } | null = null;
private activeAttemptRef: string | null = null;
private pendingPersistence: PendingPersistence | null = null;
private runId = 0;
@@ -65,10 +66,11 @@ export class StalkerConnectionFlowService {
}
async ensureConnected(playlist: Playlist): Promise<Playlist | undefined> {
const runId = ++this.runId;
this.dismissConnectionRetry();
if (!this.shouldUseTypedSession(playlist)) {
return playlist;
}
const runId = ++this.runId;
await this.discardPending();
const needsMigration = !this.hasVerifiedRecipe(playlist);
try {
@@ -83,33 +85,49 @@ export class StalkerConnectionFlowService {
);
return await this.handleOutcome(playlist, outcome, runId, false);
} catch {
this.offerConnectionRetry(
playlist,
'connection-open-failed',
runId
);
return undefined;
}
}
async forceRedetect(playlist: Playlist): Promise<Playlist | undefined> {
if (!this.session.supportsTypedSessions()) {
this.dismissConnectionRetry();
return playlist;
}
const leaseRef = this.session.getLeaseRef(playlist._id);
if (leaseRef === undefined) {
const runId = ++this.runId;
await this.discardPending();
try {
const runId = ++this.runId;
this.dismissConnectionRetry();
try {
if (leaseRef === undefined) {
await this.discardPending();
const outcome = await this.session.open(playlist, {
connectionMode: 'provisional',
provisionalReason: 'migration',
});
return this.handleOutcome(playlist, outcome, runId, true);
} catch {
return undefined;
return await this.handleOutcome(
playlist,
outcome,
runId,
true
);
}
const outcome = await this.session.forceRedetect(leaseRef);
return outcome.kind === 'success'
? playlist
: await this.handleOutcome(playlist, outcome, runId, true);
} catch {
this.offerConnectionRetry(
playlist,
'connection-redetect-failed',
runId
);
return undefined;
}
const runId = ++this.runId;
const outcome = await this.session.forceRedetect(leaseRef);
return outcome.kind === 'success'
? playlist
: this.handleOutcome(playlist, outcome, runId, true);
}
async retryPendingPersistence(): Promise<Playlist | undefined> {
@@ -125,6 +143,7 @@ export class StalkerConnectionFlowService {
async cancel(): Promise<void> {
this.runId += 1;
this.dismissConnectionRetry();
this.activeDialog?.close();
this.activeDialog = null;
await this.discardPending();
@@ -141,6 +160,7 @@ export class StalkerConnectionFlowService {
return undefined;
}
const runId = ++this.runId;
this.dismissConnectionRetry();
await this.discardPending();
try {
const outcome =
@@ -151,13 +171,18 @@ export class StalkerConnectionFlowService {
provisionalReason: 'migration',
})
: request.outcome;
return this.handleOutcome(
return await this.handleOutcome(
request.playlist,
outcome,
runId,
true
);
} catch {
this.offerConnectionRetry(
request.playlist,
'connection-recovery-failed',
runId
);
return undefined;
}
}
@@ -246,6 +271,9 @@ export class StalkerConnectionFlowService {
outcome.kind === 'ready' ? credentials : undefined;
continue;
}
if (outcome.kind === 'failure') {
this.offerConnectionRetry(playlist, outcome.reason, runId);
}
return undefined;
}
return undefined;
@@ -350,6 +378,7 @@ export class StalkerConnectionFlowService {
}
this.activeAttemptRef = null;
this.pendingPersistence = null;
this.dismissConnectionRetry();
if (pending.announceReady) {
this.readySubject.next(persisted);
}
@@ -371,23 +400,23 @@ export class StalkerConnectionFlowService {
try {
outcome = await this.session.open(persisted);
} catch {
this.notifyPromotionFailure();
this.offerConnectionRetry(
persisted,
'session-promotion-failed',
pending.runId
);
return undefined;
}
if (await this.discardOutcomeWhenStale(outcome, pending.runId)) {
return undefined;
}
if (outcome.kind !== 'ready') {
const recovered = await this.handleOutcome(
return this.handleOutcome(
persisted,
outcome,
pending.runId,
pending.announceReady
);
if (recovered === undefined && pending.runId === this.runId) {
this.notifyPromotionFailure();
}
return recovered;
}
if (pending.announceReady) {
@@ -442,6 +471,7 @@ export class StalkerConnectionFlowService {
}
private offerPersistenceRetry(): void {
this.dismissConnectionRetry();
const ref = this.snackBar.open(
this.translate.instant(
'HOME.STALKER_PORTAL.CONNECTION_FAILURE_GENERIC',
@@ -455,15 +485,50 @@ export class StalkerConnectionFlowService {
.subscribe(() => void this.retryPendingPersistence());
}
private notifyPromotionFailure(): void {
this.snackBar.open(
this.translate.instant(
'HOME.STALKER_PORTAL.CONNECTION_FAILURE_GENERIC',
{ reason: 'session-promotion-failed' }
),
undefined,
{ duration: 10_000 }
offerRetry(playlist: Playlist, reason: string): void {
this.offerConnectionRetry(playlist, reason, this.runId);
}
private offerConnectionRetry(
playlist: Playlist,
reason: string,
runId: number
): void {
if (runId !== this.runId) {
return;
}
this.dismissConnectionRetry();
const ref = this.snackBar.open(
this.connectionFailureMessage(reason),
this.translate.instant('HOME.STALKER_PORTAL.RETRY'),
{ duration: 120_000 }
);
this.activeConnectionRetry = ref;
ref.onAction()
.pipe(take(1))
.subscribe(() => {
if (
runId !== this.runId ||
this.activeConnectionRetry !== ref
) {
return;
}
this.activeConnectionRetry = null;
void this.forceRedetect(playlist);
});
}
private connectionFailureMessage(reason: string): string {
const message = this.translate.instant(
'HOME.STALKER_PORTAL.CONNECTION_FAILURE_GENERIC',
{ reason }
);
return message.includes(reason) ? message : `${message} ${reason}`;
}
private dismissConnectionRetry(): void {
this.activeConnectionRetry?.dismiss();
this.activeConnectionRetry = null;
}
private async discardPending(): Promise<void> {
@@ -25,6 +25,8 @@ const ACTIVE_PLAYLIST: PlaylistMeta = {
const FULL_STALKER_PLAYLIST: PlaylistMeta = {
...ACTIVE_PLAYLIST,
isFullStalkerPortal: true,
stalkerRecipeClassifierVersion: 1,
stalkerRequestRecipe: 'full-session',
stalkerSerialNumber: 'CUSTOMSN123',
stalkerDeviceId1: 'DEVICE-ID-1',
stalkerDeviceId2: 'DEVICE-ID-2',
@@ -89,6 +91,7 @@ describe('StalkerWorkspaceRouteSession', () => {
cancel: jest.fn().mockResolvedValue(undefined),
connectionReady$: connectionReady.asObservable(),
ensureConnected: jest.fn(async (playlist: PlaylistMeta) => playlist),
offerRetry: jest.fn(),
};
const session = {
@@ -142,12 +145,19 @@ describe('StalkerWorkspaceRouteSession', () => {
stalkerStore.setSelectedContentType.mockClear();
stalkerStore.setSearchPhrase.mockClear();
playlistsService.getPlaylistById.mockClear();
playlistsService.getPlaylistById.mockImplementation(() =>
of(ACTIVE_PLAYLIST)
);
connectionFlow.cancel.mockClear();
connectionFlow.ensureConnected.mockClear();
connectionFlow.offerRetry.mockClear();
session.activate.mockClear();
session.close.mockClear();
session.deactivate.mockClear();
session.getLeaseRef.mockClear();
session.getLeaseRef.mockImplementation(
(playlistId: string) => `lease-${playlistId}`
);
await TestBed.configureTestingModule({
providers: [
@@ -258,7 +268,7 @@ describe('StalkerWorkspaceRouteSession', () => {
);
});
it('uses active Stalker playlist metadata directly when the portal mode is explicit', async () => {
it('uses active Stalker playlist metadata directly when its recipe is current', async () => {
activePlaylist.set(FULL_STALKER_PLAYLIST);
TestBed.inject(StalkerWorkspaceRouteSession);
@@ -270,6 +280,30 @@ describe('StalkerWorkspaceRouteSession', () => {
);
});
it('does not let a legacy boolean on temporary route metadata shadow the full stored playlist', async () => {
activePlaylist.set({
...ACTIVE_PLAYLIST,
isFullStalkerPortal: false,
title: 'Untitled playlist',
});
playlistsService.getPlaylistById.mockReturnValue(
of(FULL_STALKER_PLAYLIST)
);
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(playlistsService.getPlaylistById).toHaveBeenCalledWith(
PLAYLIST_ID
);
expect(connectionFlow.ensureConnected).toHaveBeenCalledWith(
FULL_STALKER_PLAYLIST
);
expect(stalkerStore.setCurrentPlaylist).toHaveBeenCalledWith(
FULL_STALKER_PLAYLIST
);
});
it('does not expose a playlist to catalog resources when connection flow is cancelled', async () => {
connectionFlow.ensureConnected.mockResolvedValueOnce(undefined);
@@ -279,6 +313,92 @@ describe('StalkerWorkspaceRouteSession', () => {
expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled();
});
it('offers a retry instead of exposing a playlist when lease activation fails', async () => {
session.activate.mockResolvedValueOnce({
kind: 'failure',
reason: 'portal-unavailable',
requestId: 'activate-failure',
retryable: true,
stage: 'ready',
});
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(connectionFlow.offerRetry).toHaveBeenCalledWith(
expect.objectContaining({ _id: PLAYLIST_ID }),
'portal-unavailable'
);
expect(stalkerStore.setCurrentPlaylist).not.toHaveBeenCalled();
});
it('does not let a stale activation failure close or retry a newer lease for the same playlist', async () => {
const staleActivation = createDeferred<{
kind: 'failure';
reason: string;
requestId: string;
retryable: boolean;
stage: 'ready';
}>();
let currentLeaseRef = 'lease-stale';
session.getLeaseRef.mockImplementation(() => currentLeaseRef);
session.activate
.mockReturnValueOnce(staleActivation.promise)
.mockResolvedValueOnce({
action: 'activate',
kind: 'success',
requestId: 'activate-current',
});
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
expect(session.activate).toHaveBeenCalledWith('lease-stale');
router.url = '/workspace/m3u/playlist-2';
playlistContext.syncFromUrl.mockReturnValueOnce({
inWorkspace: true,
playlistId: 'playlist-2',
provider: 'm3u',
section: null,
});
routerEvents.next(
new NavigationEnd(
2,
`/workspace/stalker/${PLAYLIST_ID}/vod`,
router.url
)
);
await flushEffects();
currentLeaseRef = 'lease-current';
router.url = `/workspace/stalker/${PLAYLIST_ID}/vod`;
routerEvents.next(
new NavigationEnd(
3,
'/workspace/m3u/playlist-2',
router.url
)
);
await flushEffects();
expect(session.activate).toHaveBeenLastCalledWith('lease-current');
expect(stalkerStore.setCurrentPlaylist).toHaveBeenLastCalledWith(
expect.objectContaining({ _id: PLAYLIST_ID })
);
staleActivation.resolve({
kind: 'failure',
reason: 'stale-portal-unavailable',
requestId: 'activate-stale-failure',
retryable: true,
stage: 'ready',
});
await flushEffects();
expect(session.deactivate).not.toHaveBeenCalledWith('lease-current');
expect(session.close).not.toHaveBeenCalledWith('lease-current');
expect(connectionFlow.offerRetry).not.toHaveBeenCalled();
});
it('cancels a provisional route attempt before leaving the Stalker workspace', async () => {
TestBed.inject(StalkerWorkspaceRouteSession);
await flushEffects();
@@ -16,8 +16,13 @@ import {
StalkerSessionService,
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { STALKER_RECIPE_CLASSIFIER_VERSION } from '@iptvnator/portal/stalker/protocol';
import { PlaylistsService } from '@iptvnator/services';
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
Playlist,
PlaylistMeta,
type StalkerSessionLeaseRef,
} from '@iptvnator/shared/interfaces';
import { StalkerConnectionFlowService } from './stalker-connection-flow/stalker-connection-flow.service';
@Injectable()
@@ -146,7 +151,8 @@ export class StalkerWorkspaceRouteSession {
private async activateAndSetCurrentPlaylist(
playlist: Playlist
): Promise<void> {
if (playlist._id !== this.currentPlaylistId) {
const activationGeneration = this.syncGeneration;
if (!this.isCurrentActivation(playlist, activationGeneration)) {
return;
}
const leaseRef = this.session.getLeaseRef(playlist._id);
@@ -154,28 +160,96 @@ export class StalkerWorkspaceRouteSession {
try {
const activation = await this.session.activate(leaseRef);
if (activation.kind !== 'success') {
await this.releasePlaylistSession(playlist._id);
const retryEligible = this.isCurrentActivation(
playlist,
activationGeneration,
leaseRef
);
await this.releaseLeaseSession(leaseRef);
this.offerActivationRetry(
playlist,
activation.kind === 'failure'
? activation.reason
: `session-activation-${activation.kind}`,
activationGeneration,
leaseRef,
retryEligible
);
return;
}
} catch {
await this.releasePlaylistSession(playlist._id);
const retryEligible = this.isCurrentActivation(
playlist,
activationGeneration,
leaseRef
);
await this.releaseLeaseSession(leaseRef);
this.offerActivationRetry(
playlist,
'session-activation-failed',
activationGeneration,
leaseRef,
retryEligible
);
return;
}
}
if (playlist._id === this.currentPlaylistId) {
if (
this.isCurrentActivation(
playlist,
activationGeneration,
leaseRef
)
) {
await this.stalkerStore.setCurrentPlaylist(playlist);
}
}
private offerActivationRetry(
playlist: Playlist,
reason: string,
activationGeneration: number,
leaseRef: StalkerSessionLeaseRef,
retryEligible: boolean
): void {
const currentLeaseRef = this.session.getLeaseRef(playlist._id);
if (
retryEligible &&
this.isCurrentActivation(playlist, activationGeneration) &&
(currentLeaseRef === undefined || currentLeaseRef === leaseRef)
) {
this.connectionFlow.offerRetry(playlist, reason);
}
}
private async releasePlaylistSession(playlistId: string): Promise<void> {
const leaseRef = this.session.getLeaseRef(playlistId);
if (leaseRef === undefined) {
return;
}
await this.releaseLeaseSession(leaseRef);
}
private async releaseLeaseSession(
leaseRef: StalkerSessionLeaseRef
): Promise<void> {
await this.session.deactivate(leaseRef).catch(() => undefined);
await this.session.close(leaseRef).catch(() => undefined);
}
private isCurrentActivation(
playlist: Playlist,
activationGeneration: number,
leaseRef?: StalkerSessionLeaseRef
): boolean {
return (
activationGeneration === this.syncGeneration &&
playlist._id === this.currentPlaylistId &&
(leaseRef === undefined ||
this.session.getLeaseRef(playlist._id) === leaseRef)
);
}
private syncRouteState(section: PortalRailSection | null): void {
if (!section) {
return;
@@ -213,7 +287,7 @@ export class StalkerWorkspaceRouteSession {
): Promise<PlaylistMeta | undefined> {
const activePlaylist = this.playlistContext.activePlaylist();
if (this.hasExplicitStalkerPortalMode(playlistId, activePlaylist)) {
if (this.hasCurrentStalkerRecipe(playlistId, activePlaylist)) {
return activePlaylist;
}
@@ -225,13 +299,15 @@ export class StalkerWorkspaceRouteSession {
return storedPlaylist ?? activePlaylist ?? undefined;
}
private hasExplicitStalkerPortalMode(
private hasCurrentStalkerRecipe(
playlistId: string,
playlist: PlaylistMeta | null
): playlist is PlaylistMeta {
return (
playlist?._id === playlistId &&
playlist.isFullStalkerPortal !== undefined
playlist.stalkerRequestRecipe !== undefined &&
playlist.stalkerRecipeClassifierVersion ===
STALKER_RECIPE_CLASSIFIER_VERSION
);
}
}