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
@@ -1,6 +1,7 @@
export * from './lib/catalog-title-match.service';
export * from './lib/cross-portal-similar.service';
export * from './lib/data.service';
export * from './lib/host-connectivity-reset';
export * from './lib/database-electron.service';
export * from './lib/downloads.service';
export * from './lib/playback-position-runtime-bridge.service';
@@ -0,0 +1,52 @@
import { CONNECTIVITY_GUARD_RESET } from '@iptvnator/shared/interfaces';
import { resetHostConnectivityGuard } from './host-connectivity-reset';
describe('resetHostConnectivityGuard', () => {
let sendIpcEvent: jest.Mock;
beforeEach(() => {
sendIpcEvent = jest.fn().mockResolvedValue({ success: true });
});
it('asks the main process to forget the failures recorded for the host', async () => {
await resetHostConnectivityGuard(
{ sendIpcEvent },
'http://portal.example:8080'
);
expect(sendIpcEvent).toHaveBeenCalledWith(CONNECTIVITY_GUARD_RESET, {
url: 'http://portal.example:8080',
});
});
it.each([undefined, null, ''])(
'sends nothing when there is no URL to reset (%p)',
async (url) => {
// A playlist can be missing its address entirely; there is no host
// to clear then, and an empty reset would be a pointless round-trip.
await resetHostConnectivityGuard({ sendIpcEvent }, url);
expect(sendIpcEvent).not.toHaveBeenCalled();
}
);
it('never propagates a transport failure to the caller', async () => {
// The guard only ever delays a request, so a failed reset must not
// block the retry, discovery run or status check that asked for it.
sendIpcEvent.mockRejectedValue(new Error('IPC unavailable'));
await expect(
resetHostConnectivityGuard({ sendIpcEvent }, 'http://x.example')
).resolves.toBeUndefined();
});
it('tolerates the PWA no-op, where the channel is unknown', async () => {
// PwaService.sendIpcEvent returns undefined synchronously for channels
// it does not implement; nothing there records per-host failures yet.
sendIpcEvent.mockReturnValue(undefined);
await expect(
resetHostConnectivityGuard({ sendIpcEvent }, 'http://x.example')
).resolves.toBeUndefined();
});
});
@@ -0,0 +1,33 @@
import { CONNECTIVITY_GUARD_RESET } from '@iptvnator/shared/interfaces';
import type { DataService } from './data.service';
/**
* Asks the main process to forget the connection failures it recorded for the
* host `url` points at, so the next request contacts it for real instead of
* being fast-failed by the connectivity guard.
*
* Send this whenever the user asked for a real attempt (portal retry, "test
* connection") or handed over an address that may now point somewhere else
* (import, edited connection, lazy repair).
*
* Always best effort: the guard only ever delays a request, so a failure here
* must never block the action that asked for it. In the PWA the channel is
* unknown and `sendIpcEvent` no-ops, which is correct — nothing there records
* per-host failures yet.
*/
export async function resetHostConnectivityGuard(
dataService: Pick<DataService, 'sendIpcEvent'>,
url: string | null | undefined
): Promise<void> {
if (!url) {
return;
}
try {
await Promise.resolve(
dataService.sendIpcEvent(CONNECTIVITY_GUARD_RESET, { url })
);
} catch {
// Deliberately silent: the caller's own request reports the real state.
}
}
@@ -4,6 +4,7 @@ import {
createEnvironmentInjector,
runInInjectionContext,
} from '@angular/core';
import { CONNECTIVITY_GUARD_RESET } from '@iptvnator/shared/interfaces';
import { DataService } from './data.service';
import { PortalStatusService } from './portal-status.service';
@@ -252,4 +253,56 @@ describe('PortalStatusService', () => {
service.checkPortalStatus('https://example.com', 'user', 'pass')
).resolves.toBe('active');
});
describe('host connectivity guard', () => {
beforeEach(() => {
dataService.sendIpcEvent.mockResolvedValue({
payload: { user_info: { auth: 1, status: 'Active' } },
});
});
it('clears the guard first when the user asked to test the connection', async () => {
// Otherwise "Test Connection" on a panel that just came back would
// report it unavailable without contacting it at all.
await service.checkPortalStatusDetails(
'http://example.com',
'user',
'pass',
{ skipCache: true }
);
expect(dataService.sendIpcEvent.mock.calls[0]).toEqual([
CONNECTIVITY_GUARD_RESET,
{ url: 'http://example.com' },
]);
});
it('leaves the guard alone for passive background checks', async () => {
await service.checkPortalStatusDetails(
'http://example.com',
'user',
'pass'
);
expect(dataService.sendIpcEvent).not.toHaveBeenCalledWith(
CONNECTIVITY_GUARD_RESET,
expect.anything()
);
});
it('still runs the check when clearing the guard fails', async () => {
dataService.sendIpcEvent.mockRejectedValueOnce(
new Error('IPC unavailable')
);
await expect(
service.checkPortalStatusDetails(
'http://example.com',
'user',
'pass',
{ skipCache: true }
)
).resolves.toMatchObject({ status: 'active' });
});
});
});
+10 -5
View File
@@ -6,13 +6,10 @@ import {
XtreamPortalStatusResponseLike,
} from '@iptvnator/shared/interfaces';
import { DataService } from './data.service';
import { resetHostConnectivityGuard } from './host-connectivity-reset';
export type PortalStatus =
| 'active'
| 'inactive'
| 'expired'
| 'unavailable'
| 'checking';
'active' | 'inactive' | 'expired' | 'unavailable' | 'checking';
/**
* Status plus the parsed account expiration from the same round-trip the
@@ -135,6 +132,14 @@ export class PortalStatusService {
if (pending) {
return pending;
}
} else {
// Skipping the cache means the user asked to test this portal
// right now, so the main process must forget any connection
// failures it recorded for the host and contact it for real.
await resetHostConnectivityGuard(
this.dataService,
connection.serverUrl
);
}
const request = this.fetchPortalStatus(