feat(stalker): coordinate route session recovery

This commit is contained in:
4gray committed 2026-07-27 11:09:08 +02:00
1 parent 7ee6e97ceb
commit 758c1cb77a
2 files changed
+273 -3

No files matched your search

@@ -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<StalkerSessionBridge> {
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<boolean>((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({
@@ -48,13 +48,23 @@ export const STALKER_SESSION_BRIDGE = new InjectionToken<
providedIn: 'root',
});
type StalkerSessionNonSuccessOutcome =
export type StalkerSessionNonSuccessOutcome =
| StalkerSessionConnectionOutcome
| Exclude<
StalkerSessionRequestOutcome<StalkerSessionApplicationOperation>,
{ kind: 'success' }
>;
export interface StalkerSessionRecoveryRequest {
readonly playlist: Playlist;
readonly outcome: StalkerSessionNonSuccessOutcome;
readonly trigger: 'open' | 'request';
}
export type StalkerSessionRecoveryHandler = (
request: StalkerSessionRecoveryRequest
) => Promise<boolean>;
export class StalkerSessionOutcomeError extends Error {
constructor(readonly outcome: StalkerSessionNonSuccessOutcome) {
super(`stalker-session-outcome:${outcome.kind}`);
@@ -97,6 +107,11 @@ export class StalkerSessionService {
string,
Promise<StalkerSessionConnectionOutcome>
>();
private readonly recoveryByPlaylistRef = new Map<
string,
Promise<boolean>
>();
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<T> {
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<unknown> {
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<boolean> {
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