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:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-10-10 11:28:59 +02:00
1 parent 50d45bc7c0
commit 0060330b5e
20 files changed
+1698 -91

No files matched your search

+1
View File
@@ -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';
}