mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
feat(stalker): coordinate route session recovery
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user