mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
fix(portals): resolve cancelled and 401/403 portal requests instead of rejecting through IPC (#1861)
* fix(portals): resolve cancelled and 401/403 portal requests instead of rejecting through IPC
Electron logs every rejected ipcMain.handle promise as
"Error occurred in handler for '<channel>'" with a stack trace, and
ipcRenderer.invoke keeps nothing of the rejection but its message. A
request the renderer cancelled and an HTTP 401/403 are routine outcomes,
not errors, so STALKER_REQUEST and XTREAM_REQUEST now resolve them as a
structured { portalRequestFailure } envelope. ElectronService, the only
reader of the raw bridge result, rethrows it as an AbortError or as an
"HTTP Error <code>" error that carries the numeric status, so a
cancellation is never read as an empty answer.
- cancelled requests: silent in the main process unless
IPTVNATOR_TRACE_IPC is on; the renderer logs at debug level, no snackbar
- 401/403: one credential-free console.warn (host and pathname only)
- 404, 5xx, network errors and the guard fast-fail keep rejecting with
their existing message contracts and error log
Regression coverage in both handler specs, the shared contract and
classifier specs, the renderer data-service spec, and an Electron E2E
that reads the real main-process output.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(portals): type the Xtream bridge result as a union with the failure envelope
A resolved cancellation or 401/403 carries neither `payload` nor
`action`, so `xtreamRequest` now promises
`ElectronBridgeXtreamResponse | PortalRequestFailureEnvelope` and
`ElectronService` narrows it with `isPortalRequestFailureEnvelope`
before reading success fields. Review finding from the local Greptile
pass.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(portals): reject malformed envelopes in the guard; harden the logging E2E
Greptile findings on the pushed head:
- `isPortalRequestFailureEnvelope` vouched for an `http` failure whose
`statusText` was not a string, so a malformed envelope would have
reached `statusText.trim()` as a TypeError; the reader and the guard
now reject it, with regression cases.
- The logging E2E inherited `IPTVNATOR_TRACE_IPC`/`IPTVNATOR_TRACE_STARTUP`
from a developer shell, which makes the handlers log cancellations on
purpose and fail the silence assertion; both flags are omitted at launch.
- The refusing portal's listener is now closed in an outer `finally`, so
a failed Electron launch or close no longer leaks it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
50d45bc7c0
commit
0060330b5e
20 files changed
+1698
-91
No files matched your search
@@ -109,5 +109,6 @@ export * from './lib/vod-details-item.interface';
|
||||
export * from './lib/catchup-download.interface';
|
||||
|
||||
export * from './lib/xtream-connection-test';
|
||||
export * from './lib/portal-request-failure.util';
|
||||
|
||||
export * from './lib/source-health';
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AppUpdateChannel } from './app-update-channel.util';
|
||||
import type { SourceProbeContext, SourceHealthResult } from './source-health';
|
||||
import type { PortalRequestFailureEnvelope } from './portal-request-failure.util';
|
||||
import type { XtreamConnectionFailure } from './xtream-connection-test';
|
||||
import type { ZoomLevelAction } from './zoom-level.util';
|
||||
import type {
|
||||
@@ -339,6 +340,17 @@ export interface ElectronBridgeXtreamResponse {
|
||||
action: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `xtreamRequest` resolves with: the provider answer, or — for a
|
||||
* cancelled request and an HTTP 401/403 — a `portalRequestFailure` envelope
|
||||
* carrying neither `payload` nor `action`. Resolved instead of rejected so
|
||||
* Electron does not log the handler as failed; `ElectronService` narrows it
|
||||
* with `isPortalRequestFailureEnvelope` and rethrows
|
||||
* (`portal-request-failure.util.ts`).
|
||||
*/
|
||||
export type ElectronBridgeXtreamResult =
|
||||
ElectronBridgeXtreamResponse | PortalRequestFailureEnvelope;
|
||||
|
||||
export interface ElectronBridgeXtreamCancelResult extends ElectronBridgeResult {
|
||||
cancelled: number;
|
||||
}
|
||||
@@ -912,9 +924,13 @@ export interface ElectronBridgeApi {
|
||||
getAiSettings: () => Promise<ElectronBridgeAiSettings>;
|
||||
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
|
||||
setVlcPlayerPath: (vlcPlayerPath: string) => Promise<void>;
|
||||
/**
|
||||
* The portal's JSON, the `{ stalkerAuthFailure }` marker, or a
|
||||
* `PortalRequestFailureEnvelope` (cancelled request, HTTP 401/403).
|
||||
*/
|
||||
stalkerRequest: (
|
||||
payload: ElectronBridgeStalkerRequestPayload
|
||||
) => Promise<Record<string, unknown>>;
|
||||
) => Promise<Record<string, unknown> | PortalRequestFailureEnvelope>;
|
||||
/**
|
||||
* Forgets the connection failures recorded for the host `url` points at, so
|
||||
* the next request contacts it for real instead of being fast-failed.
|
||||
@@ -922,7 +938,7 @@ export interface ElectronBridgeApi {
|
||||
resetHostConnectivityGuard: (url: string) => Promise<ElectronBridgeResult>;
|
||||
xtreamRequest: (
|
||||
payload: ElectronBridgeXtreamRequestPayload
|
||||
) => Promise<ElectronBridgeXtreamResponse>;
|
||||
) => Promise<ElectronBridgeXtreamResult>;
|
||||
xtreamCancelSession: (
|
||||
sessionId: string
|
||||
) => Promise<ElectronBridgeXtreamCancelResult>;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { isStalkerAuthFailureMessage } from './stalker-auth-failure.util';
|
||||
import {
|
||||
createPortalRequestError,
|
||||
createPortalRequestFailureEnvelope,
|
||||
formatPortalHttpErrorMessage,
|
||||
isExpectedPortalHttpStatus,
|
||||
isPortalRequestCancelledError,
|
||||
isPortalRequestFailureEnvelope,
|
||||
readPortalRequestFailure,
|
||||
} from './portal-request-failure.util';
|
||||
import { sourceHealthError } from './source-health';
|
||||
|
||||
describe('portal request failure envelope', () => {
|
||||
it('round-trips a cancellation', () => {
|
||||
const envelope = createPortalRequestFailureEnvelope({
|
||||
kind: 'cancelled',
|
||||
});
|
||||
|
||||
expect(readPortalRequestFailure(envelope)).toEqual({
|
||||
kind: 'cancelled',
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips an HTTP refusal with and without a status text', () => {
|
||||
expect(
|
||||
readPortalRequestFailure(
|
||||
createPortalRequestFailureEnvelope({
|
||||
kind: 'http',
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
})
|
||||
)
|
||||
).toEqual({ kind: 'http', status: 401, statusText: 'Unauthorized' });
|
||||
expect(
|
||||
readPortalRequestFailure(
|
||||
createPortalRequestFailureEnvelope({
|
||||
kind: 'http',
|
||||
status: 403,
|
||||
})
|
||||
)
|
||||
).toEqual({ kind: 'http', status: 403 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an ordinary payload', { js: { data: [] } }],
|
||||
[
|
||||
'a payload reusing the key with another shape',
|
||||
{
|
||||
portalRequestFailure: { kind: 'http' },
|
||||
},
|
||||
],
|
||||
['an unknown kind', { portalRequestFailure: { kind: 'timeout' } }],
|
||||
[
|
||||
'a non-string status text',
|
||||
{
|
||||
portalRequestFailure: {
|
||||
kind: 'http',
|
||||
status: 401,
|
||||
statusText: 123,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'a non-numeric status',
|
||||
{ portalRequestFailure: { kind: 'http', status: '401' } },
|
||||
],
|
||||
['a string', 'Authorization failed.'],
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
])('treats %s as a normal response', (_label, value) => {
|
||||
expect(readPortalRequestFailure(value)).toBeNull();
|
||||
});
|
||||
|
||||
it('narrows a bridge result to the envelope only for a valid failure', () => {
|
||||
const envelope = createPortalRequestFailureEnvelope({
|
||||
kind: 'http',
|
||||
status: 403,
|
||||
});
|
||||
const result: typeof envelope | { payload: unknown; action: string } =
|
||||
envelope;
|
||||
|
||||
expect(isPortalRequestFailureEnvelope(result)).toBe(true);
|
||||
if (isPortalRequestFailureEnvelope(result)) {
|
||||
// Narrowed: the success fields are no longer on the type.
|
||||
expect(result.portalRequestFailure.kind).toBe('http');
|
||||
}
|
||||
expect(
|
||||
isPortalRequestFailureEnvelope({ payload: [], action: 'get' })
|
||||
).toBe(false);
|
||||
expect(
|
||||
isPortalRequestFailureEnvelope({
|
||||
portalRequestFailure: { kind: 'http' },
|
||||
})
|
||||
).toBe(false);
|
||||
// The guard vouches for every field a caller may read off the
|
||||
// original value, so a malformed status text fails it too.
|
||||
expect(
|
||||
isPortalRequestFailureEnvelope({
|
||||
portalRequestFailure: {
|
||||
kind: 'http',
|
||||
status: 401,
|
||||
statusText: 123,
|
||||
},
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('expects only the HTTP auth refusals', () => {
|
||||
expect([401, 403].every(isExpectedPortalHttpStatus)).toBe(true);
|
||||
expect(
|
||||
[200, 400, 404, 429, 500, 502, undefined, '401'].some(
|
||||
isExpectedPortalHttpStatus
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPortalRequestError', () => {
|
||||
it('turns a cancellation into an AbortError, never an empty answer', () => {
|
||||
const error = createPortalRequestError(
|
||||
{ kind: 'cancelled' },
|
||||
'stalker'
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.name).toBe('AbortError');
|
||||
expect(error.message).toBe('Stalker request cancelled');
|
||||
expect(isPortalRequestCancelledError(error)).toBe(true);
|
||||
// The health probes read the reason from the message.
|
||||
expect(sourceHealthError(error).reason).toBe('cancelled');
|
||||
// A cancellation is not a timeout, an auth failure or an HTTP answer.
|
||||
expect(/timed out|timeout|HTTP Error/i.test(error.message)).toBe(false);
|
||||
expect(isStalkerAuthFailureMessage(error.message)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[401, 'Unauthorized', 'HTTP Error 401: Unauthorized'],
|
||||
[403, undefined, 'HTTP Error 403'],
|
||||
[403, ' ', 'HTTP Error 403'],
|
||||
])(
|
||||
'turns an HTTP %s refusal into the message the classifiers parse',
|
||||
(status, statusText, message) => {
|
||||
const error = createPortalRequestError(
|
||||
{ kind: 'http', status, statusText },
|
||||
'xtream'
|
||||
) as Error & { status?: number };
|
||||
|
||||
expect(error.message).toBe(message);
|
||||
expect(error.status).toBe(status);
|
||||
expect(isPortalRequestCancelledError(error)).toBe(false);
|
||||
expect(sourceHealthError(error)).toMatchObject({
|
||||
reason: 'auth',
|
||||
state: 'inactive',
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('formats the HTTP message once for both processes', () => {
|
||||
expect(formatPortalHttpErrorMessage(401, 'Unauthorized')).toBe(
|
||||
'HTTP Error 401: Unauthorized'
|
||||
);
|
||||
expect(formatPortalHttpErrorMessage(404)).toBe('HTTP Error 404');
|
||||
});
|
||||
|
||||
it('does not read a plain error as a cancellation', () => {
|
||||
expect(isPortalRequestCancelledError(new Error('canceled'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(isPortalRequestCancelledError({ name: 'AbortError' })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Expected portal-request failures, carried across the Electron IPC boundary
|
||||
* as a RESOLVED value.
|
||||
*
|
||||
* `ipcMain.handle` logs every rejected handler as
|
||||
* `Error occurred in handler for '<channel>'` with a stack trace, and
|
||||
* `ipcRenderer.invoke` keeps nothing of the rejection but its message. Two
|
||||
* outcomes of a portal request are routine rather than errors, so they must
|
||||
* not take that path:
|
||||
*
|
||||
* - a **cancelled** request — the renderer navigated away, superseded it, or
|
||||
* let its probe deadline pass; and
|
||||
* - an HTTP **401/403** — the portal answered and refused, which the renderer
|
||||
* already classifies structurally (`isStalkerAuthorizationFailure`,
|
||||
* `sourceHealthError`, the lazy portal repair).
|
||||
*
|
||||
* The main process resolves them as this envelope. The renderer's Electron
|
||||
* data service is the only consumer of the raw bridge result; it turns the
|
||||
* envelope back into the thrown error its callers already understand — an
|
||||
* `AbortError`, or an `HTTP Error <status>` message with a numeric `status` —
|
||||
* so a cancelled request can never be mistaken for a successful empty answer.
|
||||
*
|
||||
* This lives in `shared/interfaces` for the same reason as the Stalker
|
||||
* auth-failure marker: the main process cannot import renderer libraries, and
|
||||
* two copies would drift.
|
||||
*/
|
||||
|
||||
export const PORTAL_REQUEST_FAILURE_KEY = 'portalRequestFailure';
|
||||
|
||||
/** HTTP statuses that mean "the portal answered and refused", not "broken". */
|
||||
export const EXPECTED_PORTAL_HTTP_STATUSES: readonly number[] = [401, 403];
|
||||
|
||||
export type PortalRequestFailure =
|
||||
| { kind: 'cancelled' }
|
||||
| { kind: 'http'; status: number; statusText?: string };
|
||||
|
||||
export interface PortalRequestFailureEnvelope {
|
||||
[PORTAL_REQUEST_FAILURE_KEY]: PortalRequestFailure;
|
||||
}
|
||||
|
||||
export type PortalRequestProvider = 'stalker' | 'xtream';
|
||||
|
||||
const PROVIDER_LABEL: Record<PortalRequestProvider, string> = {
|
||||
stalker: 'Stalker',
|
||||
xtream: 'Xtream',
|
||||
};
|
||||
|
||||
export function isExpectedPortalHttpStatus(status: unknown): status is number {
|
||||
return (
|
||||
typeof status === 'number' &&
|
||||
EXPECTED_PORTAL_HTTP_STATUSES.includes(status)
|
||||
);
|
||||
}
|
||||
|
||||
export function createPortalRequestFailureEnvelope(
|
||||
failure: PortalRequestFailure
|
||||
): PortalRequestFailureEnvelope {
|
||||
return { [PORTAL_REQUEST_FAILURE_KEY]: failure };
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure a bridge result carries, or null for an ordinary response.
|
||||
* Strict on shape: a portal payload that happens to contain the key with
|
||||
* anything else in it is still a payload.
|
||||
*/
|
||||
export function readPortalRequestFailure(
|
||||
value: unknown
|
||||
): PortalRequestFailure | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failure = (value as Record<string, unknown>)[
|
||||
PORTAL_REQUEST_FAILURE_KEY
|
||||
];
|
||||
if (!failure || typeof failure !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { kind, status, statusText } = failure as Record<string, unknown>;
|
||||
if (kind === 'cancelled') {
|
||||
return { kind };
|
||||
}
|
||||
|
||||
if (kind !== 'http' || typeof status !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// `statusText` must be a string or absent: the guard below vouches for the
|
||||
// whole shape, and a caller that reads the original value must not meet a
|
||||
// number where the type promises a string.
|
||||
if (statusText === undefined) {
|
||||
return { kind, status };
|
||||
}
|
||||
|
||||
return typeof statusText === 'string' ? { kind, status, statusText } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for a bridge result: narrows the union the bridge promises
|
||||
* (`ElectronBridgeXtreamResult`) so success fields cannot be read off an
|
||||
* envelope without checking first.
|
||||
*/
|
||||
export function isPortalRequestFailureEnvelope(
|
||||
value: unknown
|
||||
): value is PortalRequestFailureEnvelope {
|
||||
return readPortalRequestFailure(value) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The message shape the renderer classifies HTTP failures from
|
||||
* (`getStalkerRequestErrorStatus`, `isStalkerAuthorizationFailure`,
|
||||
* `sourceHealthError`): `HTTP Error <status>`, with the status text when the
|
||||
* portal sent one.
|
||||
*/
|
||||
export function formatPortalHttpErrorMessage(
|
||||
status: number,
|
||||
statusText?: string
|
||||
): string {
|
||||
const text = statusText?.trim();
|
||||
return text ? `HTTP Error ${status}: ${text}` : `HTTP Error ${status}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the error the renderer expects from a resolved envelope.
|
||||
*
|
||||
* A cancellation is an `AbortError`, the convention the database and refresh
|
||||
* paths already use (`isDbAbortError`). An HTTP refusal carries the numeric
|
||||
* `status` — which the old rejection lost at the IPC boundary — and the
|
||||
* message the classifiers parse.
|
||||
*/
|
||||
export function createPortalRequestError(
|
||||
failure: PortalRequestFailure,
|
||||
provider: PortalRequestProvider
|
||||
): Error {
|
||||
if (failure.kind === 'cancelled') {
|
||||
const error = new Error(
|
||||
`${PROVIDER_LABEL[provider]} request cancelled`
|
||||
);
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
const error = new Error(
|
||||
formatPortalHttpErrorMessage(failure.status, failure.statusText)
|
||||
) as Error & { status: number };
|
||||
error.status = failure.status;
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Whether an error is a cancelled portal request (or any other abort). */
|
||||
export function isPortalRequestCancelledError(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError';
|
||||
}
|
||||
Reference in new issue
Block a user