From 758c1cb77ab954ebe5c3b78e782adbf3178b1161 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 11:09:08 +0200 Subject: [PATCH] feat(stalker): coordinate route session recovery --- .../src/lib/stalker-session.service.spec.ts | 177 ++++++++++++++++++ .../src/lib/stalker-session.service.ts | 99 +++++++++- 2 files changed, 273 insertions(+), 3 deletions(-) diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index 9fd9b039d..c7702e001 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -59,6 +59,12 @@ const PROVISIONAL_READY: StalkerSessionConnectionOutcome = { requestId: 'request-provisional-1', }; +const RECONNECTED_READY: StalkerSessionConnectionOutcome = { + ...READY, + leaseRef: 'opaque-lease-2', + requestId: 'request-reconnected-1', +}; + function createBridge(): jest.Mocked { return { stalkerSessionContinue: jest.fn(), @@ -375,6 +381,177 @@ describe('StalkerSessionService', () => { expect(bridge.stalkerSessionRequest).not.toHaveBeenCalled(); }); + it('lets the route recover a principal transition and reissues the original operation exactly once', async () => { + const transition: StalkerSessionRequestOutcome = { + kind: 'failure', + reason: 'principal-transition-required', + requestId: 'request-transition-1', + retryable: false, + stage: 'refreshing', + }; + bridge.stalkerSessionOpen + .mockResolvedValueOnce(READY) + .mockResolvedValueOnce(RECONNECTED_READY); + bridge.stalkerSessionRequest + .mockResolvedValueOnce(transition) + .mockResolvedValueOnce({ + kind: 'success', + operation: + STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories, + payload: { + items: [{ id: '7', name: 'Movies' }], + }, + requestId: 'request-categories-retried', + }); + const recover = jest.fn(async () => { + await service.open(PLAYLIST, { + connectionMode: 'provisional', + provisionalReason: 'migration', + }); + return true; + }); + service.registerRecoveryHandler(recover); + + await expect( + service.makeAuthenticatedRequest(PLAYLIST, { + action: 'get_categories', + type: 'vod', + }) + ).resolves.toEqual({ + js: [{ id: '7', title: 'Movies' }], + }); + + expect(recover).toHaveBeenCalledWith({ + outcome: transition, + playlist: PLAYLIST, + trigger: 'request', + }); + expect(bridge.stalkerSessionRequest).toHaveBeenCalledTimes(2); + expect(bridge.stalkerSessionRequest.mock.calls[1][0]).toMatchObject({ + leaseRef: 'opaque-lease-2', + operation: + STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories, + }); + }); + + it('joins concurrent route recovery while preserving one retry per original operation', async () => { + const transition: StalkerSessionRequestOutcome = { + kind: 'failure', + reason: 'principal-transition-required', + requestId: 'request-transition-concurrent', + retryable: false, + stage: 'refreshing', + }; + bridge.stalkerSessionOpen.mockResolvedValue(READY); + bridge.stalkerSessionRequest + .mockResolvedValueOnce(transition) + .mockResolvedValueOnce(transition) + .mockResolvedValue({ + kind: 'success', + operation: + STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories, + payload: { items: [] }, + requestId: 'request-categories-retried', + }); + let resolveRecovery!: (value: boolean) => void; + const recovery = new Promise((resolve) => { + resolveRecovery = resolve; + }); + const recover = jest.fn(() => recovery); + service.registerRecoveryHandler(recover); + + const first = service.makeAuthenticatedRequest(PLAYLIST, { + action: 'get_categories', + type: 'vod', + }); + const second = service.makeAuthenticatedRequest(PLAYLIST, { + action: 'get_categories', + type: 'vod', + }); + await Promise.resolve(); + await Promise.resolve(); + resolveRecovery(true); + + await expect(Promise.all([first, second])).resolves.toEqual([ + { js: [] }, + { js: [] }, + ]); + expect(recover).toHaveBeenCalledTimes(1); + expect(bridge.stalkerSessionRequest).toHaveBeenCalledTimes(4); + }); + + it('lets the route continue a first-open credential challenge before the application request', async () => { + const challenge: StalkerSessionConnectionOutcome = { + attemptNumber: 1, + challengeRef: 'opaque-challenge-route-1', + kind: 'credentials-required', + requestId: 'request-open-challenge', + savedCredentialsRejected: true, + }; + bridge.stalkerSessionOpen.mockResolvedValue(challenge); + bridge.stalkerSessionContinue.mockResolvedValue(READY); + bridge.stalkerSessionRequest.mockResolvedValue({ + kind: 'success', + operation: + STALKER_SESSION_APPLICATION_OPERATIONS.CatalogCategories, + payload: { items: [] }, + requestId: 'request-after-route-recovery', + }); + const recover = jest.fn(async () => { + await service.continue(PLAYLIST._id, { + challengeRef: challenge.challengeRef, + response: { + kind: 'credentials', + password: 'entered-password', + username: 'entered-user', + }, + }); + return true; + }); + service.registerRecoveryHandler(recover); + + await expect( + service.makeAuthenticatedRequest(PLAYLIST, { + action: 'get_categories', + type: 'vod', + }) + ).resolves.toEqual({ js: [] }); + + expect(recover).toHaveBeenCalledWith({ + outcome: challenge, + playlist: PLAYLIST, + trigger: 'open', + }); + expect(bridge.stalkerSessionRequest).toHaveBeenCalledTimes(1); + }); + + it('unregisters only the matching route recovery handler', async () => { + const first = jest.fn(async () => true); + const second = jest.fn(async () => true); + const unregisterFirst = service.registerRecoveryHandler(first); + service.registerRecoveryHandler(second); + + unregisterFirst(); + + const failure: StalkerSessionConnectionOutcome = { + attemptNumber: 1, + challengeRef: 'opaque-challenge-active-handler', + kind: 'credentials-required', + requestId: 'request-active-handler', + savedCredentialsRejected: true, + }; + bridge.stalkerSessionOpen.mockResolvedValue(failure); + + await expect( + service.makeAuthenticatedRequest(PLAYLIST, { + action: 'get_categories', + type: 'vod', + }) + ).rejects.toEqual(new StalkerSessionOutcomeError(failure)); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + it('fails closed when the typed Electron bridge is unavailable', async () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index ab0923e93..8188b3a4b 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -48,13 +48,23 @@ export const STALKER_SESSION_BRIDGE = new InjectionToken< providedIn: 'root', }); -type StalkerSessionNonSuccessOutcome = +export type StalkerSessionNonSuccessOutcome = | StalkerSessionConnectionOutcome | Exclude< StalkerSessionRequestOutcome, { kind: 'success' } >; +export interface StalkerSessionRecoveryRequest { + readonly playlist: Playlist; + readonly outcome: StalkerSessionNonSuccessOutcome; + readonly trigger: 'open' | 'request'; +} + +export type StalkerSessionRecoveryHandler = ( + request: StalkerSessionRecoveryRequest +) => Promise; + export class StalkerSessionOutcomeError extends Error { constructor(readonly outcome: StalkerSessionNonSuccessOutcome) { super(`stalker-session-outcome:${outcome.kind}`); @@ -97,6 +107,11 @@ export class StalkerSessionService { string, Promise >(); + private readonly recoveryByPlaylistRef = new Map< + string, + Promise + >(); + private recoveryHandler: StalkerSessionRecoveryHandler | undefined; supportsTypedSessions(): boolean { return this.bridge !== undefined; @@ -112,6 +127,17 @@ export class StalkerSessionService { return this.leaseByPlaylistRef.get(playlistRef); } + registerRecoveryHandler( + handler: StalkerSessionRecoveryHandler + ): () => void { + this.recoveryHandler = handler; + return () => { + if (this.recoveryHandler === handler) { + this.recoveryHandler = undefined; + } + }; + } + async open( playlist: Playlist, options: LocalStalkerSessionDescriptorMappingOptions = {} @@ -268,7 +294,11 @@ export class StalkerSessionService { ): Promise { const adapted = adaptLegacyStalkerRequest(parameters); const leaseRef = await this.resolveLease(playlist); - const result = await this.executeAdaptedRequest(leaseRef, adapted); + const result = await this.executeAdaptedRequest( + playlist, + leaseRef, + adapted + ); return result as T; } @@ -282,6 +312,15 @@ export class StalkerSessionService { const outcome = await this.open(playlist); if (outcome.kind !== 'ready' || outcome.recipe !== 'full-session') { + if ( + this.isRecoverable(outcome) && + (await this.recover(playlist, outcome, 'open')) + ) { + const recoveredLease = this.getLeaseRef(playlist._id); + if (recoveredLease !== undefined) { + return recoveredLease; + } + } throw new StalkerSessionOutcomeError(outcome); } return outcome.leaseRef; @@ -311,14 +350,29 @@ export class StalkerSessionService { } private async executeAdaptedRequest( + playlist: Playlist, leaseRef: StalkerSessionLeaseRef, adaptedRequest: StalkerAdaptedRequest ): Promise { - const outcome = await this.request({ + let outcome = await this.request({ leaseRef, operation: adaptedRequest.operation, parameters: adaptedRequest.parameters, } as StalkerSessionRequest); + if ( + outcome.kind !== 'success' && + this.isRecoverable(outcome) && + (await this.recover(playlist, outcome, 'request')) + ) { + const recoveredLease = this.getLeaseRef(playlist._id); + if (recoveredLease !== undefined) { + outcome = await this.request({ + leaseRef: recoveredLease, + operation: adaptedRequest.operation, + parameters: adaptedRequest.parameters, + } as StalkerSessionRequest); + } + } if (outcome.kind !== 'success') { throw new StalkerSessionOutcomeError(outcome); } @@ -328,6 +382,45 @@ export class StalkerSessionService { ); } + private recover( + playlist: Playlist, + outcome: StalkerSessionNonSuccessOutcome, + trigger: StalkerSessionRecoveryRequest['trigger'] + ): Promise { + const active = this.recoveryByPlaylistRef.get(playlist._id); + if (active !== undefined) { + return active; + } + const handler = this.recoveryHandler; + if (handler === undefined) { + return Promise.resolve(false); + } + const recovery = Promise.resolve( + handler({ + outcome, + playlist, + trigger, + }) + ).finally(() => { + if (this.recoveryByPlaylistRef.get(playlist._id) === recovery) { + this.recoveryByPlaylistRef.delete(playlist._id); + } + }); + this.recoveryByPlaylistRef.set(playlist._id, recovery); + return recovery; + } + + private isRecoverable( + outcome: StalkerSessionNonSuccessOutcome + ): boolean { + return ( + outcome.kind === 'origin-approval-required' || + outcome.kind === 'credentials-required' || + (outcome.kind === 'failure' && + outcome.reason === 'principal-transition-required') + ); + } + private rememberConnectionOutcome( playlistRef: string, outcome: StalkerSessionConnectionOutcome