perf(portals): fast-fail requests to portal hosts that stopped answering (#1421)

This commit is contained in:
4gray authored and GitHub committed 2026-08-13 07:31:23 +02:00
1 parent 2d7811eb5f
commit e3f72f7dce
50 files changed
+3073 -174

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ export * from './lib/epg-program.model';
export * from './lib/external-player-arguments.utils';
export * from './lib/external-player-session.interface';
export * from './lib/global-search-result.interface';
export * from './lib/host-connectivity.util';
export * from './lib/indexed-db.config';
export * from './lib/ipc-command.class';
export * from './lib/ipc-commands';
@@ -283,6 +283,13 @@ export interface ElectronBridgeStalkerRequestPayload {
token?: string;
serialNumber?: string;
requestId?: string;
/**
* Endpoint-discovery probes only: exempt this request from the main
* process' per-host connectivity guard. Discovery walks several candidate
* paths on one host and expects most of them to fail, so its failures must
* neither count towards the guard nor be fast-failed by it.
*/
skipConnectionGuard?: boolean;
}
export interface ElectronBridgeXtreamRequestPayload {
@@ -774,6 +781,11 @@ export interface ElectronBridgeApi {
stalkerRequest: (
payload: ElectronBridgeStalkerRequestPayload
) => Promise<Record<string, unknown>>;
/**
* Forgets the connection failures recorded for the host `url` points at, so
* the next request contacts it for real instead of being fast-failed.
*/
resetHostConnectivityGuard: (url: string) => Promise<ElectronBridgeResult>;
xtreamRequest: (
payload: ElectronBridgeXtreamRequestPayload
) => Promise<ElectronBridgeXtreamResponse>;
@@ -0,0 +1,49 @@
/**
* Cross-process contract for the main-process host connectivity guard.
*
* The guard fast-fails requests to a portal host that just failed to answer
* several times in a row, instead of hanging the full axios timeout again.
*
* Only the MESSAGE of a rejected IPC handler survives the trip to the
* renderer — `ipcRenderer.invoke` strips custom properties and re-wraps the
* value — and the Stalker renderer classifies transport failures purely from
* that text. The wording below is therefore a contract, not a label:
*
* - no `HTTP Error <code>`: `getStalkerRequestErrorStatus` would report a
* status, so endpoint discovery would read "this endpoint is absent, probe
* the next candidate", and 404/401/403 would additionally fire lazy portal
* repair against a host we just declared dead;
* - no timeout wording: `isStalkerProbeTimeout` would keep discovery walking
* every candidate instead of aborting on the first one;
* - none of the auth phrases `isStalkerAuthFailureMessage` accepts (a bare
* `authorization` is one of them) — that would also trigger lazy repair.
*
* What is left is the "connection-level failure" slot the renderer already
* has for ECONNREFUSED/ENOTFOUND: discovery stops probing and reports the
* host unreachable, which is exactly what a tripped guard means. The message
* also reaches error snackbars verbatim, so it has to read like a sentence.
*
* `endpoint` is the request origin (scheme, host and port), not a bare host: a
* user with the same panel imported over both HTTP and HTTPS needs to see which
* of the two was skipped. A scheme is safe here — none of the forbidden
* substrings above can appear in one.
*/
export function buildHostConnectivityFastFailMessage(endpoint: string): string {
return `Portal ${endpoint} is not responding; skipped after repeated connection failures`;
}
const FAST_FAIL_MESSAGE_PATTERN =
/is not responding; skipped after repeated connection failures/;
/**
* Whether a message came from the guard, bare or in the form Electron wraps
* it into (`Error invoking remote method 'STALKER_REQUEST': <message>`).
*
* Exported so tests and any future renderer-side handling share one
* definition of the wording instead of re-typing the sentence.
*/
export function isHostConnectivityFastFailMessage(message: unknown): boolean {
return (
typeof message === 'string' && FAST_FAIL_MESSAGE_PATTERN.test(message)
);
}
@@ -100,6 +100,14 @@ export const STALKER_REQUEST = 'STALKER_REQUEST';
export const STALKER_RESPONSE = 'STALKER_RESPONSE';
export const PORTAL_DEBUG_EVENT = 'PORTAL_DEBUG_EVENT';
/**
* Forgets the main process' recorded connection failures for a portal host, so
* the next request contacts it for real. Sent whenever the user asks for a
* fresh attempt (portal retry, "test connection") or hands over a possibly
* different portal (endpoint discovery on import, edit, or lazy repair).
*/
export const CONNECTIVITY_GUARD_RESET = 'CONNECTIVITY_GUARD_RESET';
// Settings
export const SETTINGS_UPDATE = 'SETTINGS_UPDATE';
export const DELETE_ALL_PLAYLISTS = 'DELETE_ALL_PLAYLISTS';
@@ -21,6 +21,8 @@
* URL builders were centralised here. Two copies would drift.
*/
import { isHostConnectivityFastFailMessage } from './host-connectivity.util';
/** The exact plain-text bodies the stock middleware emits. */
export const STALKER_AUTH_FAILURE_BODIES = [
'Authorization failed.',
@@ -122,9 +124,19 @@ function isStalkerJsonAuthFailurePhrase(value: string): boolean {
* arbitrary portal body, where the same breadth would false-positive.
*/
export function isStalkerAuthFailureMessage(message: unknown): boolean {
return (
typeof message === 'string' && isStalkerJsonAuthFailurePhrase(message)
);
if (typeof message !== 'string') {
return false;
}
// The connectivity guard's refusal names the portal endpoint, and a
// hostname is user data: `https://authorization.example` would otherwise
// match the broad phrase set below and send an unreachable host into lazy
// portal repair. An explicit marker outranks a heuristic.
if (isHostConnectivityFastFailMessage(message)) {
return false;
}
return isStalkerJsonAuthFailurePhrase(message);
}
/**