From 26331676f945379667aebaebe1b4f75610f10ad8 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:43:52 +0200 Subject: [PATCH 01/12] fix(epg): await mapping queries so lookups fail soft (#1279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four EPG mapping handlers wrap their DB call in try/catch but return the promise instead of awaiting it. An async function *adopts* a returned promise rather than awaiting it, so the catch block only ever fired when getDatabase() itself threw — a rejection from the underlying query escaped to the IPC caller instead of returning the intended null / {success:false} / [] fallback. Add the missing await to handleGetEpgMapping, handleSetEpgMapping, handleDeleteEpgMapping and handleSearchEpgChannels, matching handleGetEpgMappingsBatch and resolveChannelIds in the same module, which already awaited and so already failed soft for both cases. This restores the contract stated in the module's own doc comment: a mapping lookup must never take down an EPG request. The behavior predates the max-lines split in #1278, which carried it over verbatim from epg.events.ts. The new spec drives the real IPC handlers captured from ipcMain.handle, so it asserts the contract that matters: the caller gets a fallback, not a rejected promise. Reverting the four awaits fails exactly those four handlers and leaves the already-correct batch handler green. Co-authored-by: Claude Opus 5 --- .changes/epg-mapping-lookup-fail-soft.md | 9 + .../src/app/events/epg-mapping.events.spec.ts | 174 ++++++++++++++++++ .../src/app/events/epg-mapping.service.ts | 8 +- 3 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 .changes/epg-mapping-lookup-fail-soft.md create mode 100644 apps/electron-backend/src/app/events/epg-mapping.events.spec.ts diff --git a/.changes/epg-mapping-lookup-fail-soft.md b/.changes/epg-mapping-lookup-fail-soft.md new file mode 100644 index 000000000..dc35aebbe --- /dev/null +++ b/.changes/epg-mapping-lookup-fail-soft.md @@ -0,0 +1,9 @@ +--- +type: fix +area: epg +--- + +A database error while reading or saving a manual EPG channel mapping no longer +surfaces as a failed request. Looking up, saving, deleting, and searching +mappings now fall back quietly, so a transient storage hiccup can no longer take +down the EPG panel or the "Map EPG channel" dialog. diff --git a/apps/electron-backend/src/app/events/epg-mapping.events.spec.ts b/apps/electron-backend/src/app/events/epg-mapping.events.spec.ts new file mode 100644 index 000000000..0af8125fe --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-mapping.events.spec.ts @@ -0,0 +1,174 @@ +import type EpgEventsType from './epg.events'; + +const getDatabase = jest.fn(); +const getEpgMapping = jest.fn(); +const getEpgMappingsBatch = jest.fn(); +const setEpgMapping = jest.fn(); +const deleteEpgMapping = jest.fn(); +const searchEpgChannels = jest.fn(); +const ipcHandlers = new Map< + string, + (event: unknown, args: unknown) => Promise +>(); + +jest.mock('electron', () => ({ + app: { + isPackaged: false, + getAppPath: () => '/mock/app.asar', + }, + BrowserWindow: { + getAllWindows: () => [], + }, + ipcMain: { + handle: jest.fn(), + }, +})); + +jest.mock('../database/connection', () => ({ + getDatabase: (...args: unknown[]) => getDatabase(...args), +})); + +jest.mock('../database/operations/epg-mapping.operations', () => ({ + getEpgMapping: (...args: unknown[]) => getEpgMapping(...args), + getEpgMappingsBatch: (...args: unknown[]) => getEpgMappingsBatch(...args), + setEpgMapping: (...args: unknown[]) => setEpgMapping(...args), + deleteEpgMapping: (...args: unknown[]) => deleteEpgMapping(...args), + searchEpgChannels: (...args: unknown[]) => searchEpgChannels(...args), +})); + +/** + * The EPG mapping IPC handlers must fail soft: a mapping lookup is a side + * concern of an EPG request and must never take that request down. The + * handlers wrap the DB call in try/catch, but a bare `return promise` inside + * an async function is *adopted*, not awaited — so the catch block only ever + * saw a synchronous `getDatabase()` failure while a rejection from the query + * itself escaped to the IPC caller. These tests pin both failure modes. + */ +describe('EPG mapping IPC handlers', () => { + let EpgEvents: typeof EpgEventsType; + + beforeEach(async () => { + jest.resetModules(); + ipcHandlers.clear(); + [ + getDatabase, + getEpgMapping, + getEpgMappingsBatch, + setEpgMapping, + deleteEpgMapping, + searchEpgChannels, + ].forEach((mock) => mock.mockReset()); + + getDatabase.mockResolvedValue({}); + + ({ default: EpgEvents } = await import('./epg.events')); + + const { ipcMain } = jest.requireMock('electron'); + (ipcMain.handle as jest.Mock).mockReset(); + (ipcMain.handle as jest.Mock).mockImplementation( + ( + channel: string, + handler: (event: unknown, args: unknown) => Promise + ) => { + ipcHandlers.set(channel, handler); + } + ); + + EpgEvents.bootstrapEpgEvents(); + }); + + function invoke(channel: string, args: unknown): Promise { + const handler = ipcHandlers.get(channel); + if (!handler) { + throw new Error(`No IPC handler registered for "${channel}"`); + } + return handler({}, args); + } + + describe.each([ + { + channel: 'EPG_MAPPING_GET', + args: { channelKey: 'bbc.one.uk' }, + operation: () => getEpgMapping, + resolved: { + id: 1, + channelKey: 'bbc.one.uk', + epgChannelId: 'BBC.ONE.UK', + playlistId: null, + }, + fallback: null, + }, + { + channel: 'EPG_MAPPING_SET', + args: { channelKey: 'bbc.one.uk', epgChannelId: 'BBC.ONE.UK' }, + operation: () => setEpgMapping, + resolved: { success: true }, + fallback: { success: false }, + }, + { + channel: 'EPG_MAPPING_DELETE', + args: { channelKey: 'bbc.one.uk' }, + operation: () => deleteEpgMapping, + resolved: { success: true }, + fallback: { success: false }, + }, + { + channel: 'EPG_CHANNEL_SEARCH', + args: { searchTerm: 'bbc' }, + operation: () => searchEpgChannels, + resolved: [ + { id: 'BBC.ONE.UK', displayName: 'BBC One', iconUrl: null }, + ], + fallback: [], + }, + { + channel: 'EPG_MAPPING_GET_BATCH', + args: { channelKeys: ['bbc.one.uk'] }, + operation: () => getEpgMappingsBatch, + resolved: new Map([['bbc.one.uk', 'BBC.ONE.UK']]), + fallback: {}, + }, + ])('$channel', ({ channel, args, operation, resolved, fallback }) => { + it('returns the fallback when the underlying query rejects', async () => { + operation().mockRejectedValue(new Error('database is locked')); + + await expect(invoke(channel, args)).resolves.toEqual(fallback); + }); + + it('returns the fallback when opening the database rejects', async () => { + getDatabase.mockRejectedValue(new Error('no such file')); + + await expect(invoke(channel, args)).resolves.toEqual(fallback); + expect(operation()).not.toHaveBeenCalled(); + }); + + it('passes the resolved value through on success', async () => { + operation().mockResolvedValue(resolved); + + const result = await invoke(channel, args); + + expect(result).toEqual( + // The batch handler is the one that reshapes its result. + resolved instanceof Map + ? Object.fromEntries(resolved) + : resolved + ); + }); + }); + + it('short-circuits a blank search term without touching the database', async () => { + await expect( + invoke('EPG_CHANNEL_SEARCH', { searchTerm: ' ' }) + ).resolves.toEqual([]); + expect(getDatabase).not.toHaveBeenCalled(); + expect(searchEpgChannels).not.toHaveBeenCalled(); + }); + + it('short-circuits an empty batch without touching the database', async () => { + await expect( + invoke('EPG_MAPPING_GET_BATCH', { channelKeys: [] }) + ).resolves.toEqual({}); + expect(getDatabase).not.toHaveBeenCalled(); + expect(getEpgMappingsBatch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/events/epg-mapping.service.ts b/apps/electron-backend/src/app/events/epg-mapping.service.ts index 58a09e0ac..7b767ce0a 100644 --- a/apps/electron-backend/src/app/events/epg-mapping.service.ts +++ b/apps/electron-backend/src/app/events/epg-mapping.service.ts @@ -67,7 +67,7 @@ export async function handleGetEpgMapping( ): Promise { try { const db = await getDatabase(); - return getEpgMapping(db, channelKey); + return await getEpgMapping(db, channelKey); } catch { return null; } @@ -96,7 +96,7 @@ export async function handleSetEpgMapping( ): Promise<{ success: boolean }> { try { const db = await getDatabase(); - return setEpgMapping(db, channelKey, epgChannelId, playlistId); + return await setEpgMapping(db, channelKey, epgChannelId, playlistId); } catch { return { success: false }; } @@ -107,7 +107,7 @@ export async function handleDeleteEpgMapping( ): Promise<{ success: boolean }> { try { const db = await getDatabase(); - return deleteEpgMapping(db, channelKey); + return await deleteEpgMapping(db, channelKey); } catch { return { success: false }; } @@ -123,7 +123,7 @@ export async function handleSearchEpgChannels( try { const db = await getDatabase(); - return searchEpgChannels(db, searchTerm, limit); + return await searchEpgChannels(db, searchTerm, limit); } catch { return []; } From 24f0dee6f033613148aeab4b699e9db667041927 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:34:22 +0200 Subject: [PATCH 02/12] test(performance): add formal M3U import benchmark (#1287) * test(performance): add formal M3U import benchmark * test(performance): harden formal capture validity * test(performance): address benchmark review feedback --- AGENTS.md | 4 +- CLAUDE.md | 4 +- apps/electron-backend-e2e/project.json | 10 + .../src/m3u-import.performance.ts | 8 + .../database-request-identity-capture.spec.ts | 130 +++++ .../database-request-identity-capture.ts | 182 +++++-- ...atabase-worker-peak-memory-capture.spec.ts | 98 ++++ ...tabase-worker-peak-memory-validity.spec.ts | 121 +++++ .../database-worker-peak-memory-validity.ts | 114 +++++ ...se-worker-request-metrics-validity.spec.ts | 127 +++++ ...atabase-worker-request-metrics-validity.ts | 197 ++++++++ ...-diagnostic-artifact-content-validation.ts | 185 +++++++ ...import-diagnostic-artifact-content.spec.ts | 234 +++++++++ ...nitial-import-diagnostic-artifacts.spec.ts | 394 +++++++++++++++ .../initial-import-diagnostic-artifacts.ts | 262 ++++++++++ ...initial-import-diagnostic-test-fixtures.ts | 19 + .../m3u-import-benchmark-contract.spec.ts | 115 +++++ .../m3u-import-benchmark-contract.ts | 118 +++++ .../m3u-import-benchmark-lifecycle.spec.ts | 184 +++++++ .../m3u-import-benchmark-support.ts | 300 ++++++++++++ .../performance/m3u-import-ipc-attribution.ts | 250 ++++++++++ .../m3u-import-renderer-artifacts.ts | 181 +++++++ ...-import-renderer-capture-lifecycle.spec.ts | 313 ++++++++++++ .../m3u-import-renderer-capture.spec.ts | 389 +++++++++++++++ .../m3u-import-renderer-capture.ts | 366 ++++++++++++++ .../performance/m3u-import-renderer-probe.ts | 378 +++++++++++++++ .../src/performance/m3u-import-report.spec.ts | 338 +++++++++++++ .../m3u-import-report.test-helpers.ts | 458 ++++++++++++++++++ .../src/performance/m3u-import-report.ts | 367 ++++++++++++++ .../m3u-import-summary-contract.ts | 131 +++++ .../performance/m3u-import-summary.spec.ts | 247 ++++++++++ .../src/performance/m3u-import-summary.ts | 382 +++++++++++++++ .../src/performance/m3u-import.benchmark.ts | 341 +++++++++++++ .../m3u-refresh-cancellation-contract.ts | 53 +- .../m3u-refresh-cancellation-report.ts | 46 +- .../performance/m3u-refresh-main-capture.ts | 161 +++++- .../performance-build-config.spec.ts | 31 ++ .../performance-phase-events.spec.ts | 158 ++++++ .../performance/performance-phase-events.ts | 166 +++++++ .../renderer-performance-phase-events.spec.ts | 133 +++++ .../renderer-performance-phase-events.ts | 163 +++++++ .../performance/renderer-rss-validity.spec.ts | 172 +++++++ .../src/performance/renderer-rss-validity.ts | 72 +++ .../performance/synthetic-m3u-server.spec.ts | 49 ++ .../src/performance/synthetic-m3u-server.ts | 114 +++++ .../src/performance/synthetic-m3u.spec.ts | 68 +++ .../src/performance/synthetic-m3u.ts | 35 +- .../worker-performance-phase-events.ts | 149 ++++++ ...orker-request-performance-envelope.spec.ts | 159 +++++- .../worker-request-performance.spec.ts | 17 +- .../performance/worker-request-performance.ts | 48 +- .../playlist-get-performance.spec.ts | 123 +++++ .../playlist-upsert-performance.spec.ts | 101 ++++ .../operations/playlist.operations.ts | 123 ++++- .../app/events/playlist-import-performance.ts | 215 ++++++++ .../playlist-source.performance.spec.ts | 312 ++++++++++++ .../src/app/events/playlist-source.ts | 116 ++++- .../src/app/events/playlist.events.spec.ts | 105 ++++ .../src/app/events/playlist.events.ts | 6 + .../src/app/workers/database.worker.ts | 80 ++- .../app/workers/playlist-refresh.worker.ts | 11 +- .../worker-performance-cancellation.spec.ts | 3 + .../worker-performance-capture.metrics.ts | 14 + .../worker-performance-capture.model.ts | 9 + .../worker-performance-capture.spec.ts | 29 ++ .../app/workers/worker-performance-capture.ts | 24 + .../worker-performance-integration.spec.ts | 34 ++ .../workers/worker-performance-phase.spec.ts | 245 ++++++++++ .../app/workers/worker-performance-phase.ts | 172 +++++++ .../electron.service.performance.spec.ts | 123 +++++ apps/web/src/app/services/electron.service.ts | 18 +- docs/architecture/m3u-playlist-module.md | 89 ++++ docs/architecture/sqlite-db-worker.md | 42 ++ ...orkspace-route-session.performance.spec.ts | 123 +++++ .../m3u-workspace-route-session.service.ts | 18 +- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/performance-phase.interface.ts | 53 ++ libs/shared/logging/src/index.ts | 13 + .../lib/renderer-performance-phase.spec.ts | 199 ++++++++ .../src/lib/renderer-performance-phase.ts | 181 +++++++ 80 files changed, 11131 insertions(+), 192 deletions(-) create mode 100644 apps/electron-backend-e2e/src/m3u-import.performance.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-peak-memory-capture.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.ts create mode 100644 apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts create mode 100644 apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.ts create mode 100644 apps/electron-backend-e2e/src/performance/initial-import-diagnostic-test-fixtures.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-benchmark-lifecycle.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-ipc-attribution.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-renderer-artifacts.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture-lifecycle.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-renderer-probe.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-report.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-report.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-summary-contract.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-summary.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import-summary.ts create mode 100644 apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts create mode 100644 apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/performance-phase-events.ts create mode 100644 apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.ts create mode 100644 apps/electron-backend-e2e/src/performance/renderer-rss-validity.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/renderer-rss-validity.ts create mode 100644 apps/electron-backend-e2e/src/performance/synthetic-m3u-server.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/synthetic-m3u-server.ts create mode 100644 apps/electron-backend-e2e/src/performance/synthetic-m3u.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts create mode 100644 apps/electron-backend/src/app/database/operations/playlist-get-performance.spec.ts create mode 100644 apps/electron-backend/src/app/database/operations/playlist-upsert-performance.spec.ts create mode 100644 apps/electron-backend/src/app/events/playlist-import-performance.ts create mode 100644 apps/electron-backend/src/app/events/playlist-source.performance.spec.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-phase.spec.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-phase.ts create mode 100644 apps/web/src/app/services/electron.service.performance.spec.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.performance.spec.ts create mode 100644 libs/shared/interfaces/src/lib/performance-phase.interface.ts create mode 100644 libs/shared/logging/src/lib/renderer-performance-phase.spec.ts create mode 100644 libs/shared/logging/src/lib/renderer-performance-phase.ts diff --git a/AGENTS.md b/AGENTS.md index 51d6fc03b..cdf03a35e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,8 +85,8 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal - - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset - - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses, plus the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset + - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC request/completion markers plus count-only M3U acquire/parse/normalize and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset + - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization phase events, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset - Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching diff --git a/CLAUDE.md b/CLAUDE.md index 49a9446cf..3bc0945f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,8 +141,8 @@ Useful narrower flags: - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal -- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC phase markers for refresh/DB benchmark correlation; benchmark tooling sets it explicitly, and production launches must leave it unset -- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker timestamps, thread CPU, event-loop utilization, and event-loop delay metrics in database and playlist-refresh responses, plus the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset +- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC request/completion markers plus count-only M3U acquire/parse/normalize and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset +- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization phase events, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 9c1f45538..ee68cd410 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -29,6 +29,16 @@ "command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts" } }, + "benchmark-m3u-import": { + "dependsOn": ["electron-backend:build-performance"], + "executor": "nx:run-commands", + "cache": false, + "parallelism": false, + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts" + } + }, "packaged-frame-copy-smoke": { "dependsOn": ["test-packaged-frame-copy-fixtures"], "executor": "nx:run-commands", diff --git a/apps/electron-backend-e2e/src/m3u-import.performance.ts b/apps/electron-backend-e2e/src/m3u-import.performance.ts new file mode 100644 index 000000000..d1ba60ab4 --- /dev/null +++ b/apps/electron-backend-e2e/src/m3u-import.performance.ts @@ -0,0 +1,8 @@ +import { test } from '@playwright/test'; + +import { runM3uImportBenchmark } from './performance/m3u-import.benchmark'; + +// eslint-disable-next-line playwright/expect-expect -- Assertions run inside the shared benchmark lifecycle. +test('profiles initial M3U imports at 10k, 50k, and 100k', async () => { + await runM3uImportBenchmark(); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts index 2e9672718..4c3eac434 100644 --- a/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts +++ b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts @@ -52,11 +52,125 @@ test('correlates the real preload marker to a DB request whose payload omits ope }); assert.deepEqual(identity, { + ipcCallId: 2, operationId: 'refresh-operation-1', operationIdUnavailableReason: null, + sourceEpochMs: 100, }); }); +test('retains the initial-import upsert marker when its operationId is intentionally uncorrelated', () => { + const { api, ipcMain } = createCapture(); + ipcMain.emit( + CHANNEL, + {}, + marker({ + correlationState: 'uncorrelated', + ipcCallId: 7, + method: 'dbUpsertAppPlaylist', + operationId: null, + sourceEpochMs: 1_077.25, + }) + ); + + const identity = api.matchDatabaseRequest({ + operation: 'DB_UPSERT_APP_PLAYLIST', + payload: { _id: 'playlist-1' }, + requestId: 'initial-import-upsert', + type: 'request', + }); + + assert.deepEqual(identity, { + ipcCallId: 7, + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-uncorrelated', + sourceEpochMs: 1_077.25, + }); +}); + +test('captures exact successful preload completion markers for DB return-clone attribution', () => { + const { api, ipcMain } = createCapture(); + ipcMain.emit( + CHANNEL, + {}, + marker({ + correlationState: 'uncorrelated', + ipcCallId: 7, + method: 'dbUpsertAppPlaylist', + operationId: null, + phase: 'success', + sourceEpochMs: 140.25, + }) + ); + ipcMain.emit( + CHANNEL, + {}, + marker({ + correlationState: 'uncorrelated', + ipcCallId: 8, + operationId: null, + phase: 'success', + sourceEpochMs: 175.5, + }) + ); + + assert.equal(api.successMarkerCount(), 2); + assert.deepEqual(api.takeSuccessMarkers(), [ + { + ipcCallId: 7, + operation: 'DB_UPSERT_APP_PLAYLIST', + operationId: null, + playlistId: 'playlist-1', + sourceEpochMs: 140.25, + }, + { + ipcCallId: 8, + operation: 'DB_GET_APP_PLAYLIST', + operationId: null, + playlistId: 'playlist-1', + sourceEpochMs: 175.5, + }, + ]); + assert.equal(api.successMarkerCount(), 0); + assert.deepEqual(api.takeSuccessMarkers(), []); +}); + +test('fails closed for malformed or invalid successful preload markers and resets completions', () => { + const { api, ipcMain } = createCapture(); + for (const invalid of [ + { invalidReason: 'out-of-order' }, + { correlationState: 'invalid' }, + { ipcCallId: 0 }, + { playlistId: '' }, + { sourceEpochMs: Number.NaN }, + { method: 'refreshPlaylist' }, + { correlationState: 'correlated', operationId: null }, + ]) { + ipcMain.emit( + CHANNEL, + {}, + marker({ + phase: 'success', + ...invalid, + }) + ); + } + assert.equal(api.successMarkerCount(), 0); + assert.deepEqual(api.takeSuccessMarkers(), []); + + ipcMain.emit( + CHANNEL, + {}, + marker({ + phase: 'success', + }) + ); + api.stop(); + api.start(); + assert.equal(api.successMarkerCount(), 0); + assert.deepEqual(api.takeSuccessMarkers(), []); +}); + test('resolves a response-retained request identity when the marker arrives later and resets between captures', () => { const { api, ipcMain } = createCapture(); const identity = api.matchDatabaseRequest({ @@ -73,8 +187,10 @@ test('resolves a response-retained request identity when the marker arrives late ipcMain.emit(CHANNEL, {}, marker()); assert.deepEqual(responseOutcome.identity, { + ipcCallId: 2, operationId: 'refresh-operation-1', operationIdUnavailableReason: null, + sourceEpochMs: 100, }); api.stop(); @@ -87,8 +203,10 @@ test('resolves a response-retained request identity when the marker arrives late type: 'request', }); assert.deepEqual(afterReset, { + ipcCallId: null, operationId: null, operationIdUnavailableReason: 'preload-performance-marker-missing', + sourceEpochMs: null, }); }); @@ -101,8 +219,10 @@ test('fails closed for missing and ambiguous preload markers', () => { type: 'request', }); assert.deepEqual(missing, { + ipcCallId: null, operationId: null, operationIdUnavailableReason: 'preload-performance-marker-missing', + sourceEpochMs: null, }); const ambiguousCapture = createCapture(); @@ -122,8 +242,10 @@ test('fails closed for missing and ambiguous preload markers', () => { type: 'request', }); assert.deepEqual(ambiguous, { + ipcCallId: null, operationId: null, operationIdUnavailableReason: 'preload-performance-marker-ambiguous', + sourceEpochMs: null, }); }); @@ -191,19 +313,25 @@ test('quarantines a key after response-before-marker ambiguity', () => { }); const ambiguousIdentity = { + ipcCallId: null, operationId: null, operationIdUnavailableReason: 'preload-performance-marker-ambiguous', + sourceEpochMs: null, }; assert.deepEqual(requestA, ambiguousIdentity); assert.deepEqual(requestB, ambiguousIdentity); assert.deepEqual(requestC, ambiguousIdentity); assert.deepEqual(otherPlaylist, { + ipcCallId: 4, operationId: 'other-playlist-operation', operationIdUnavailableReason: null, + sourceEpochMs: 100, }); assert.deepEqual(otherOperation, { + ipcCallId: 5, operationId: 'other-db-operation', operationIdUnavailableReason: null, + sourceEpochMs: 100, }); api.stop(); @@ -223,7 +351,9 @@ test('quarantines a key after response-before-marker ambiguity', () => { type: 'request', }); assert.deepEqual(freshCaptureIdentity, { + ipcCallId: 6, operationId: 'fresh-capture-operation', operationIdUnavailableReason: null, + sourceEpochMs: 100, }); }); diff --git a/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts index a0912d3e3..2de76e7e3 100644 --- a/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts +++ b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts @@ -4,14 +4,26 @@ export const DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshDatabaseRequestIdentityCapture'; export interface DatabaseRequestIdentity { + ipcCallId: number | null; operationId: string | null; operationIdUnavailableReason: string | null; + sourceEpochMs: number | null; +} + +export interface PreloadDatabaseSuccessMarker { + ipcCallId: number; + operation: 'DB_GET_APP_PLAYLIST' | 'DB_UPSERT_APP_PLAYLIST'; + operationId: string | null; + playlistId: string; + sourceEpochMs: number; } export interface DatabaseRequestIdentityCaptureApi { matchDatabaseRequest(message: unknown): DatabaseRequestIdentity; start(): void; stop(): void; + successMarkerCount(): number; + takeSuccessMarkers(): readonly PreloadDatabaseSuccessMarker[]; } interface DatabaseRequestIdentityElectron { @@ -31,8 +43,10 @@ export function installDatabaseRequestIdentityCaptureInMain( interface PendingMarker { readonly ipcCallId: number; readonly operation: string; - readonly operationId: string; + readonly operationId: string | null; + readonly operationIdUnavailableReason: string | null; readonly playlistId: string; + readonly sourceEpochMs: number; } interface PendingRequest { readonly identity: DatabaseRequestIdentity; @@ -58,6 +72,7 @@ export function installDatabaseRequestIdentityCaptureInMain( let pendingMarkers: PendingMarker[] = []; let pendingRequests: PendingRequest[] = []; let quarantinedKeys: QuarantinedKey[] = []; + let successMarkers: PreloadDatabaseSuccessMarker[] = []; const readRecord = (value: unknown): JsonRecord | null => typeof value === 'object' && value !== null @@ -88,11 +103,37 @@ export function installDatabaseRequestIdentityCaptureInMain( candidate.playlistId === playlistId; const markAmbiguous = (requests: PendingRequest[]): void => { for (const request of requests) { + request.identity.ipcCallId = null; request.identity.operationId = null; request.identity.operationIdUnavailableReason = 'preload-performance-marker-ambiguous'; + request.identity.sourceEpochMs = null; } }; + const unavailableIdentity = (reason: string): DatabaseRequestIdentity => ({ + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: reason, + sourceEpochMs: null, + }); + const identityFromMarker = ( + marker: PendingMarker + ): DatabaseRequestIdentity => ({ + ipcCallId: marker.ipcCallId, + operationId: marker.operationId, + operationIdUnavailableReason: marker.operationIdUnavailableReason, + sourceEpochMs: marker.sourceEpochMs, + }); + const applyMarker = ( + identity: DatabaseRequestIdentity, + marker: PendingMarker + ): void => { + identity.ipcCallId = marker.ipcCallId; + identity.operationId = marker.operationId; + identity.operationIdUnavailableReason = + marker.operationIdUnavailableReason; + identity.sourceEpochMs = marker.sourceEpochMs; + }; const isQuarantined = (operation: string, playlistId: string): boolean => quarantinedKeys.some((key) => matches(key, operation, playlistId)); const quarantine = (operation: string, playlistId: string): void => { @@ -116,32 +157,86 @@ export function installDatabaseRequestIdentityCaptureInMain( return; } const marker = readRecord(input); + const correlationState = marker?.['correlationState']; + const operation = + typeof marker?.['method'] === 'string' + ? markerMethodToOperation[marker['method']] + : undefined; + const operationId = marker?.['operationId']; + const playlistId = marker?.['playlistId']; + const ipcCallId = marker?.['ipcCallId']; + const sourceEpochMs = marker?.['sourceEpochMs']; + const validMarkerIdentity = + operation !== undefined && + typeof playlistId === 'string' && + playlistId.length > 0 && + Number.isSafeInteger(ipcCallId) && + Number(ipcCallId) >= 1 && + typeof sourceEpochMs === 'number' && + Number.isFinite(sourceEpochMs) && + sourceEpochMs >= 0; + if ( + marker?.['phase'] === 'success' && + (correlationState === 'correlated' || + correlationState === 'uncorrelated') && + marker['invalidReason'] === null && + validMarkerIdentity && + (correlationState !== 'correlated' || + (typeof operationId === 'string' && + operationId.length > 0)) + ) { + successMarkers.push({ + ipcCallId: Number(ipcCallId), + operation: operation as PreloadDatabaseSuccessMarker['operation'], + operationId: + correlationState === 'correlated' + ? String(operationId) + : null, + playlistId, + sourceEpochMs, + }); + return; + } if ( !marker || marker['phase'] !== 'start' || - marker['correlationState'] !== 'correlated' || + (correlationState !== 'correlated' && + correlationState !== 'uncorrelated') || marker['invalidReason'] !== null ) { return; } - const operation = - typeof marker['method'] === 'string' - ? markerMethodToOperation[marker['method']] - : undefined; - const operationId = marker['operationId']; - const playlistId = marker['playlistId']; - const ipcCallId = marker['ipcCallId']; + const correlatedOperationId = + correlationState === 'correlated' && + typeof operationId === 'string' && + operationId.length > 0 + ? operationId + : null; if ( operation === undefined || - typeof operationId !== 'string' || - operationId.length === 0 || + (correlationState === 'correlated' && + correlatedOperationId === null) || typeof playlistId !== 'string' || playlistId.length === 0 || !Number.isSafeInteger(ipcCallId) || - Number(ipcCallId) < 1 + Number(ipcCallId) < 1 || + typeof sourceEpochMs !== 'number' || + !Number.isFinite(sourceEpochMs) || + sourceEpochMs < 0 ) { return; } + const pendingMarker: PendingMarker = { + ipcCallId: Number(ipcCallId), + operation, + operationId: correlatedOperationId, + operationIdUnavailableReason: + correlationState === 'correlated' + ? null + : 'preload-performance-marker-uncorrelated', + playlistId, + sourceEpochMs, + }; if (isQuarantined(operation, playlistId)) { return; @@ -154,8 +249,7 @@ export function installDatabaseRequestIdentityCaptureInMain( if (!request) { return; } - request.identity.operationId = operationId; - request.identity.operationIdUnavailableReason = null; + applyMarker(request.identity, pendingMarker); pendingRequests = pendingRequests.filter( (candidate) => candidate !== request ); @@ -166,12 +260,7 @@ export function installDatabaseRequestIdentityCaptureInMain( return; } - pendingMarkers.push({ - ipcCallId: Number(ipcCallId), - operation, - operationId, - playlistId, - }); + pendingMarkers.push(pendingMarker); pendingMarkers.sort((left, right) => left.ipcCallId - right.ipcCallId); }); @@ -189,19 +278,15 @@ export function installDatabaseRequestIdentityCaptureInMain( operation !== 'DB_UPSERT_APP_PLAYLIST') || playlistId === null ) { - return { - operationId: null, - operationIdUnavailableReason: - 'preload-performance-marker-not-applicable', - }; + return unavailableIdentity( + 'preload-performance-marker-not-applicable' + ); } if (isQuarantined(operation, playlistId)) { - return { - operationId: null, - operationIdUnavailableReason: - 'preload-performance-marker-ambiguous', - }; + return unavailableIdentity( + 'preload-performance-marker-ambiguous' + ); } const markerCandidates = pendingMarkers.filter((marker) => matches(marker, operation, playlistId) @@ -209,34 +294,25 @@ export function installDatabaseRequestIdentityCaptureInMain( if (markerCandidates.length === 1) { const marker = markerCandidates[0]; if (!marker) { - return { - operationId: null, - operationIdUnavailableReason: - 'preload-performance-marker-missing', - }; + return unavailableIdentity( + 'preload-performance-marker-missing' + ); } pendingMarkers = pendingMarkers.filter( (candidate) => candidate !== marker ); - return { - operationId: marker.operationId, - operationIdUnavailableReason: null, - }; + return identityFromMarker(marker); } if (markerCandidates.length > 1) { quarantine(operation, playlistId); - return { - operationId: null, - operationIdUnavailableReason: - 'preload-performance-marker-ambiguous', - }; + return unavailableIdentity( + 'preload-performance-marker-ambiguous' + ); } - const identity: DatabaseRequestIdentity = { - operationId: null, - operationIdUnavailableReason: - 'preload-performance-marker-missing', - }; + const identity = unavailableIdentity( + 'preload-performance-marker-missing' + ); pendingRequests.push({ identity, operation, @@ -248,6 +324,7 @@ export function installDatabaseRequestIdentityCaptureInMain( pendingMarkers = []; pendingRequests = []; quarantinedKeys = []; + successMarkers = []; active = true; }, stop: () => { @@ -255,6 +332,13 @@ export function installDatabaseRequestIdentityCaptureInMain( pendingMarkers = []; pendingRequests = []; quarantinedKeys = []; + successMarkers = []; + }, + successMarkerCount: () => successMarkers.length, + takeSuccessMarkers: () => { + const captured = Object.freeze([...successMarkers]); + successMarkers = []; + return captured; }, }; target[stateKey] = api; diff --git a/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-capture.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-capture.spec.ts new file mode 100644 index 000000000..37b19a714 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-capture.spec.ts @@ -0,0 +1,98 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' +); + +function section(startMarker: string, endMarker: string): string { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start); + assert.ok(start >= 0, `missing start marker: ${startMarker}`); + assert.ok(end > start, `missing end marker: ${endMarker}`); + return source.slice(start, end); +} + +test('counts only finite nonnegative worker heap and external-memory samples per capture generation', () => { + const workerRecord = section( + 'interface WorkerRecord', + 'interface TimelineRecord' + ); + const createRecord = section( + 'const record: WorkerRecord = {', + 'nextWorkerOrdinal += 1' + ); + const sampleWorker = section( + 'const sampleWorker =', + 'const resetWorkerForCapture' + ); + const resetWorker = section( + 'const resetWorkerForCapture', + 'const startWorker' + ); + + assert.match(workerRecord, /externalMemorySampleCount: number/); + assert.match(workerRecord, /heapUsedSampleCount: number/); + assert.match(createRecord, /externalMemorySampleCount: 0/); + assert.match(createRecord, /heapUsedSampleCount: 0/); + assert.match(resetWorker, /record\.externalMemorySampleCount = 0/); + assert.match(resetWorker, /record\.heapUsedSampleCount = 0/); + + assert.match(sampleWorker, /const heapUsedSample = stats\.used_heap_size/); + assert.match( + sampleWorker, + /Number\.isFinite\(heapUsedSample\)[\s\S]*heapUsedSample >= 0/ + ); + assert.match(sampleWorker, /record\.heapUsedSampleCount \+= 1/); + assert.match( + sampleWorker, + /const externalMemorySample = stats\.external_memory/ + ); + assert.match( + sampleWorker, + /Number\.isFinite\(externalMemorySample\)[\s\S]*externalMemorySample >= 0/ + ); + assert.match(sampleWorker, /record\.externalMemorySampleCount \+= 1/); + assert.doesNotMatch(sampleWorker, /used_heap_size \?\? 0/); + assert.doesNotMatch(sampleWorker, /external_memory \?\? 0/); +}); + +test('raw worker metrics distinguish missing samples from measured zero-byte peaks', () => { + const transport = section( + 'const workers = currentWorkerRecords', + 'const transport: MainCaptureGenerationTransport' + ); + + assert.match( + transport, + /externalMemorySampleCount:\s*record\.externalMemorySampleCount/ + ); + assert.match( + transport, + /heapUsedSampleCount:\s*record\.heapUsedSampleCount/ + ); + assert.match( + transport, + /peakExternalBytes:\s*record\.externalMemorySampleCount > 0[\s\S]*\?\s*record\.externalPeak[\s\S]*:\s*null/ + ); + assert.match( + transport, + /peakHeapUsedBytes:\s*record\.heapUsedSampleCount > 0[\s\S]*\?\s*record\.heapPeak[\s\S]*:\s*null/ + ); + assert.match( + transport, + /peakExternalUnavailableReason:\s*record\.externalMemorySampleCount > 0[\s\S]*\?\s*null[\s\S]*:\s*'worker-external-memory-samples-missing'/ + ); + assert.match( + transport, + /peakHeapUsedUnavailableReason:\s*record\.heapUsedSampleCount > 0[\s\S]*\?\s*null[\s\S]*:\s*'worker-heap-used-samples-missing'/ + ); + assert.doesNotMatch( + transport, + /peakExternalBytes: record\.externalPeak[,}]/ + ); + assert.doesNotMatch(transport, /peakHeapUsedBytes: record\.heapPeak[,}]/); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.spec.ts new file mode 100644 index 000000000..ffe7d9fb2 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.spec.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + assessDatabaseWorkerPeakMemoryValidity, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON, +} from './database-worker-peak-memory-validity'; + +describe('database worker peak-memory validity', () => { + it('requires successful heap and external-memory samples in every measured run', () => { + const validity = assessDatabaseWorkerPeakMemoryValidity([ + iteration('warmup', 'warmup', worker(0, 0)), + iteration('run-01', 'measured', worker(3, 4)), + iteration('diagnostic', 'diagnostic', worker(0, 0)), + ]); + + assert.deepEqual(validity, { + invalidMeasuredRuns: [], + measuredRunCount: 1, + validForComparison: true, + validMeasuredRunCount: 1, + }); + }); + + it('fails closed for absent samples instead of accepting initialized zero peaks', () => { + const missingCounts = assessDatabaseWorkerPeakMemoryValidity([ + iteration('run-01', 'measured', { + kind: 'database.worker', + peakExternalBytes: 0, + peakHeapUsedBytes: 0, + }), + iteration('run-02', 'measured', worker(0, 1)), + iteration('run-03', 'measured', worker(1, 0)), + ]); + + assert.deepEqual(missingCounts.invalidMeasuredRuns, [ + { + reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING, + runId: 'run-01', + }, + { + reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING, + runId: 'run-02', + }, + { + reason: DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING, + runId: 'run-03', + }, + ]); + assert.equal(missingCounts.validForComparison, false); + assert.equal(missingCounts.validMeasuredRunCount, 0); + }); + + it('reports stable worker-cardinality and malformed-peak reasons', () => { + const validity = assessDatabaseWorkerPeakMemoryValidity([ + { kind: 'measured', main: { workers: [] }, runId: 'missing' }, + { + kind: 'measured', + main: { workers: [worker(1, 1), worker(1, 1)] }, + runId: 'multiple', + }, + iteration('heap-invalid', 'measured', { + ...worker(1, 1), + peakHeapUsedBytes: Number.NaN, + }), + iteration('external-invalid', 'measured', { + ...worker(1, 1), + peakExternalBytes: -1, + }), + iteration('heap-reason-incoherent', 'measured', { + ...worker(1, 1), + peakHeapUsedUnavailableReason: + 'worker-heap-used-samples-missing', + }), + iteration('external-reason-incoherent', 'measured', { + ...worker(1, 1), + peakExternalUnavailableReason: + 'worker-external-memory-samples-missing', + }), + ]); + + assert.deepEqual( + validity.invalidMeasuredRuns.map(({ reason }) => reason), + [ + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.MULTIPLE_DATABASE_WORKERS, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID, + DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID, + ] + ); + }); +}); + +function iteration( + runId: string, + kind: string, + databaseWorker: Record +) { + return { + kind, + main: { workers: [databaseWorker] }, + runId, + }; +} + +function worker( + heapUsedSampleCount: number, + externalMemorySampleCount: number +) { + return { + externalMemorySampleCount, + heapUsedSampleCount, + kind: 'database.worker', + peakExternalBytes: 600, + peakExternalUnavailableReason: null, + peakHeapUsedBytes: 1_500, + peakHeapUsedUnavailableReason: null, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.ts b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.ts new file mode 100644 index 000000000..09f9bf36a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-peak-memory-validity.ts @@ -0,0 +1,114 @@ +export const DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON = { + EXTERNAL_MEMORY_PEAK_INVALID: + 'database-worker-external-memory-peak-invalid', + EXTERNAL_MEMORY_SAMPLES_MISSING: + 'database-worker-external-memory-samples-missing', + HEAP_PEAK_INVALID: 'database-worker-heap-peak-invalid', + HEAP_SAMPLES_MISSING: 'database-worker-heap-samples-missing', + MULTIPLE_DATABASE_WORKERS: 'multiple-database-workers', + DATABASE_WORKER_MISSING: 'database-worker-missing', +} as const; + +export type DatabaseWorkerPeakMemoryInvalidReason = + (typeof DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON)[keyof typeof DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON]; + +export interface DatabaseWorkerPeakMemoryValidity { + readonly invalidMeasuredRuns: readonly { + readonly reason: DatabaseWorkerPeakMemoryInvalidReason; + readonly runId: string; + }[]; + readonly measuredRunCount: number; + readonly validForComparison: boolean; + readonly validMeasuredRunCount: number; +} + +interface PeakMemoryWorker { + readonly externalMemorySampleCount?: unknown; + readonly heapUsedSampleCount?: unknown; + readonly kind: string; + readonly peakExternalBytes: unknown; + readonly peakExternalUnavailableReason?: unknown; + readonly peakHeapUsedBytes: unknown; + readonly peakHeapUsedUnavailableReason?: unknown; +} + +interface PeakMemoryIteration { + readonly kind: string; + readonly main: { + readonly workers: readonly PeakMemoryWorker[]; + }; + readonly runId: string; +} + +export function assessDatabaseWorkerPeakMemoryValidity( + iterations: readonly PeakMemoryIteration[] +): DatabaseWorkerPeakMemoryValidity { + const measured = iterations.filter( + (iteration) => iteration.kind === 'measured' + ); + const invalidMeasuredRuns: { + reason: DatabaseWorkerPeakMemoryInvalidReason; + runId: string; + }[] = []; + + for (const iteration of measured) { + const workers = iteration.main.workers.filter( + (worker) => worker.kind === 'database.worker' + ); + const reason = assessWorkers(workers); + if (reason !== null) { + invalidMeasuredRuns.push({ reason, runId: iteration.runId }); + } + } + + return Object.freeze({ + invalidMeasuredRuns: Object.freeze(invalidMeasuredRuns), + measuredRunCount: measured.length, + validForComparison: + measured.length > 0 && invalidMeasuredRuns.length === 0, + validMeasuredRunCount: measured.length - invalidMeasuredRuns.length, + }); +} + +function assessWorkers( + workers: readonly PeakMemoryWorker[] +): DatabaseWorkerPeakMemoryInvalidReason | null { + if (workers.length === 0) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING; + } + if (workers.length !== 1) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.MULTIPLE_DATABASE_WORKERS; + } + + const worker = workers[0]; + if (!worker) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.DATABASE_WORKER_MISSING; + } + if (!isPositiveSampleCount(worker.heapUsedSampleCount)) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_SAMPLES_MISSING; + } + if ( + !isPeak(worker.peakHeapUsedBytes) || + worker.peakHeapUsedUnavailableReason !== null + ) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.HEAP_PEAK_INVALID; + } + if (!isPositiveSampleCount(worker.externalMemorySampleCount)) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING; + } + if ( + !isPeak(worker.peakExternalBytes) || + worker.peakExternalUnavailableReason !== null + ) { + return DATABASE_WORKER_PEAK_MEMORY_INVALID_REASON.EXTERNAL_MEMORY_PEAK_INVALID; + } + return null; +} + +function isPositiveSampleCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +function isPeak(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.spec.ts new file mode 100644 index 000000000..0b88917c6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.spec.ts @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + assessDatabaseWorkerRequestMetricsValidity, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON, +} from './database-worker-request-metrics-validity'; +import { createCompleteTestMainCapture } from './m3u-import-report.test-helpers'; +import type { + WorkerCaptureMetrics, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; + +describe('database worker request-metrics validity', () => { + it('requires exactly two complete request samples in every measured run', () => { + const worker = completeWorker(); + const validity = assessDatabaseWorkerRequestMetricsValidity([ + iteration('warmup', 'warmup', [worker]), + iteration('run-01', 'measured', [worker]), + iteration('run-02', 'measured', [worker]), + iteration('diagnostic', 'diagnostic', [worker]), + ]); + + assert.deepEqual(validity, { + expectedRequestCount: 4, + invalidRequests: [], + measuredRunCount: 2, + validForComparison: true, + validMeasuredRunCount: 2, + validRequestCount: 4, + }); + }); + + it('fails each measured run with missing ELD, ELU, CPU, or raw capture metrics', () => { + const validity = assessDatabaseWorkerRequestMetricsValidity([ + invalidRequestIteration('run-eld', { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + 'event-loop-delay-capture-unavailable', + histogramFlushedEpochMs: null, + }), + invalidRequestIteration('run-elu', { + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + 'event-loop-utilization-unavailable', + }), + invalidRequestIteration('run-cpu', { + threadCpuSystemMicros: null, + threadCpuUnavailableReason: 'thread-cpu-usage-unavailable', + threadCpuUserMicros: null, + }), + invalidRequestIteration('run-capture', { + performanceCaptureUnavailableReason: + 'worker-performance-capture-invalid', + }), + ]); + + assert.equal(validity.expectedRequestCount, 8); + assert.equal(validity.validRequestCount, 4); + assert.equal(validity.validMeasuredRunCount, 0); + assert.equal(validity.validForComparison, false); + assert.deepEqual( + validity.invalidRequests.map(({ reason }) => reason), + [ + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_DELAY_INVALID, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_UTILIZATION_INVALID, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.THREAD_CPU_INVALID, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.WORKER_CAPTURE_INVALID, + ] + ); + }); + + it('fails closed for missing, multiple, and wrong request sets', () => { + const worker = completeWorker(); + const validity = assessDatabaseWorkerRequestMetricsValidity([ + iteration('missing', 'measured', []), + iteration('multiple', 'measured', [worker, worker]), + iteration('wrong-requests', 'measured', [ + { + ...worker, + requests: worker.requests.slice(0, 1), + }, + ]), + ]); + + assert.equal(validity.expectedRequestCount, 6); + assert.equal(validity.validRequestCount, 0); + assert.equal(validity.validForComparison, false); + assert.deepEqual( + validity.invalidRequests.map(({ reason }) => reason), + [ + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.DATABASE_WORKER_MISSING, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.MULTIPLE_DATABASE_WORKERS, + DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.REQUEST_SET_INVALID, + ] + ); + }); +}); + +function completeWorker(): WorkerCaptureMetrics { + const worker = createCompleteTestMainCapture().workers[0]; + assert.ok(worker); + return worker; +} + +function invalidRequestIteration( + runId: string, + updates: Partial +) { + const worker = completeWorker(); + const get = worker.requests[1]; + assert.ok(get); + return iteration(runId, 'measured', [ + { + ...worker, + requests: [worker.requests[0], { ...get, ...updates }], + }, + ]); +} + +function iteration( + runId: string, + kind: string, + workers: readonly WorkerCaptureMetrics[] +) { + return { kind, main: { workers }, runId }; +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.ts b/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.ts new file mode 100644 index 000000000..5a924854b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-request-metrics-validity.ts @@ -0,0 +1,197 @@ +import type { + WorkerCaptureMetrics, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { + PERFORMANCE_ITERATION_KIND, + PERFORMANCE_WORKER_KIND, +} from './m3u-refresh-cancellation-contract'; + +export const DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON = { + DATABASE_WORKER_MISSING: 'database-worker-missing', + EVENT_LOOP_DELAY_INVALID: 'event-loop-delay-invalid', + EVENT_LOOP_UTILIZATION_INVALID: 'event-loop-utilization-invalid', + MULTIPLE_DATABASE_WORKERS: 'multiple-database-workers', + REQUEST_SET_INVALID: 'initial-import-request-set-invalid', + THREAD_CPU_INVALID: 'thread-cpu-invalid', + WORKER_CAPTURE_INVALID: 'worker-performance-capture-invalid', +} as const; + +export type DatabaseWorkerRequestMetricsInvalidReason = + (typeof DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON)[keyof typeof DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON]; + +export interface DatabaseWorkerRequestMetricsValidity { + readonly expectedRequestCount: number; + readonly invalidRequests: readonly { + readonly operation: string | null; + readonly reason: DatabaseWorkerRequestMetricsInvalidReason; + readonly runId: string; + }[]; + readonly measuredRunCount: number; + readonly validForComparison: boolean; + readonly validMeasuredRunCount: number; + readonly validRequestCount: number; +} + +interface RequestMetricsIteration { + readonly kind: string; + readonly main: { + readonly workers: readonly WorkerCaptureMetrics[]; + }; + readonly runId: string; +} + +export function assessDatabaseWorkerRequestMetricsValidity( + iterations: readonly RequestMetricsIteration[] +): DatabaseWorkerRequestMetricsValidity { + const measured = iterations.filter( + (iteration) => + iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED + ); + const invalidRequests: { + operation: string | null; + reason: DatabaseWorkerRequestMetricsInvalidReason; + runId: string; + }[] = []; + let validMeasuredRunCount = 0; + let validRequestCount = 0; + + for (const iteration of measured) { + const workers = iteration.main.workers.filter( + (worker) => worker.kind === PERFORMANCE_WORKER_KIND.DATABASE + ); + if (workers.length === 0) { + invalidRequests.push({ + operation: null, + reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.DATABASE_WORKER_MISSING, + runId: iteration.runId, + }); + continue; + } + const worker = workers.length === 1 ? workers[0] : null; + if (!worker) { + invalidRequests.push({ + operation: null, + reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.MULTIPLE_DATABASE_WORKERS, + runId: iteration.runId, + }); + continue; + } + const requests = requireInitialImportRequestPair(worker); + if (requests === null) { + invalidRequests.push({ + operation: null, + reason: DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.REQUEST_SET_INVALID, + runId: iteration.runId, + }); + continue; + } + + let runIsValid = true; + for (const request of requests) { + const reason = assessRequest(request); + if (reason === null) { + validRequestCount += 1; + } else { + runIsValid = false; + invalidRequests.push({ + operation: request.operation, + reason, + runId: iteration.runId, + }); + } + } + if (runIsValid) { + validMeasuredRunCount += 1; + } + } + + const expectedRequestCount = measured.length * 2; + return Object.freeze({ + expectedRequestCount, + invalidRequests: Object.freeze(invalidRequests), + measuredRunCount: measured.length, + validForComparison: + measured.length > 0 && + validMeasuredRunCount === measured.length && + validRequestCount === expectedRequestCount, + validMeasuredRunCount, + validRequestCount, + }); +} + +function requireInitialImportRequestPair( + worker: WorkerCaptureMetrics +): readonly [ + WorkerRequestPerformanceMetrics, + WorkerRequestPerformanceMetrics, +] | null { + const upserts = worker.requests.filter( + (request) => + request.operation === 'DB_UPSERT_APP_PLAYLIST' && request.success + ); + const gets = worker.requests.filter( + (request) => + request.operation === 'DB_GET_APP_PLAYLIST' && request.success + ); + return worker.requests.length === 2 && + upserts.length === 1 && + upserts[0] && + gets.length === 1 && + gets[0] + ? [upserts[0], gets[0]] + : null; +} + +function assessRequest( + request: WorkerRequestPerformanceMetrics +): DatabaseWorkerRequestMetricsInvalidReason | null { + if ( + request.performanceCaptureUnavailableReason !== null || + request.invalidReason !== null + ) { + return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.WORKER_CAPTURE_INVALID; + } + if ( + request.eventLoopDelayUnavailableReason !== null || + request.histogramFlushedEpochMs === null || + !isEventLoopDelay(request.eventLoopDelay) + ) { + return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_DELAY_INVALID; + } + if ( + request.eventLoopUtilizationUnavailableReason !== null || + !isUtilization(request.eventLoopUtilization) + ) { + return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.EVENT_LOOP_UTILIZATION_INVALID; + } + if ( + request.threadCpuUnavailableReason !== null || + !isFiniteNonNegative(request.threadCpuSystemMicros) || + !isFiniteNonNegative(request.threadCpuUserMicros) + ) { + return DATABASE_WORKER_REQUEST_METRICS_INVALID_REASON.THREAD_CPU_INVALID; + } + return null; +} + +function isEventLoopDelay( + value: WorkerRequestPerformanceMetrics['eventLoopDelay'] +): value is NonNullable { + return ( + value !== null && + isFiniteNonNegative(value.maxMs) && + isFiniteNonNegative(value.p95Ms) && + isFiniteNonNegative(value.p99Ms) && + value.p95Ms <= value.p99Ms && + value.p99Ms <= value.maxMs + ); +} + +function isUtilization(value: number | null): value is number { + return isFiniteNonNegative(value) && value <= 1; +} + +function isFiniteNonNegative(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts new file mode 100644 index 000000000..b162bdafa --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts @@ -0,0 +1,185 @@ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isCpuProfile(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + const startTime = value['startTime']; + const endTime = value['endTime']; + const nodes = value['nodes']; + const samples = value['samples']; + const timeDeltas = value['timeDeltas']; + if ( + !isNonNegativeSafeInteger(startTime) || + !isNonNegativeSafeInteger(endTime) || + endTime <= startTime || + !Array.isArray(nodes) || + nodes.length === 0 || + !Array.isArray(samples) || + samples.length === 0 || + !Array.isArray(timeDeltas) || + timeDeltas.length !== samples.length + ) { + return false; + } + + const nodeIds = new Set(); + for (const node of nodes) { + if (!isCpuProfileNode(node) || nodeIds.has(node['id'])) { + return false; + } + nodeIds.add(node['id']); + } + if ( + nodes.some((node) => + (node as Record)['children'] instanceof Array + ? ( + (node as Record)[ + 'children' + ] as unknown[] + ).some( + (childId) => + !isNonNegativeSafeInteger(childId) || + !nodeIds.has(childId) + ) + : false + ) || + samples.some( + (sampleId) => + !isNonNegativeSafeInteger(sampleId) || + !nodeIds.has(sampleId) + ) + ) { + return false; + } + + const duration = endTime - startTime; + let elapsed = 0; + for (const delta of timeDeltas) { + if (!Number.isSafeInteger(delta)) { + return false; + } + elapsed += delta as number; + if ( + !Number.isSafeInteger(elapsed) || + elapsed < 0 || + elapsed > duration + ) { + return false; + } + } + return elapsed > 0; +} + +export function isHeapSnapshot(value: unknown): boolean { + if ( + !isRecord(value) || + !isRecord(value['snapshot']) || + !isRecord(value['snapshot']['meta']) + ) { + return false; + } + const meta = value['snapshot']['meta']; + const nodeFields = meta['node_fields']; + const nodeTypes = meta['node_types']; + const edgeFields = meta['edge_fields']; + const edgeTypes = meta['edge_types']; + const nodes = value['nodes']; + const edges = value['edges']; + const strings = value['strings']; + return ( + isNonEmptyStringArray(nodeFields) && + isHeapTypeTable(nodeTypes, nodeFields.length) && + isNonEmptyStringArray(edgeFields) && + isHeapTypeTable(edgeTypes, edgeFields.length) && + Array.isArray(nodes) && + nodes.length > 0 && + nodes.length % nodeFields.length === 0 && + Array.isArray(edges) && + edges.length > 0 && + edges.length % edgeFields.length === 0 && + Array.isArray(strings) && + strings.length > 0 + ); +} + +export function isRendererTrace(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + const events = value['traceEvents']; + return ( + Array.isArray(events) && + events.length > 0 && + events.every( + (event) => + isRecord(event) && + isNonEmptyString(event['name']) && + isNonEmptyString(event['ph']) && + isNonNegativeFiniteNumber(event['ts']) + ) + ); +} + +function isHeapTypeTable(value: unknown, fieldCount: number): boolean { + return ( + Array.isArray(value) && + value.length === fieldCount && + value.every( + (entry) => + isNonEmptyString(entry) || isNonEmptyStringArray(entry) + ) + ); +} + +function isCpuProfileNode( + value: unknown +): value is Record & { readonly id: number } { + if ( + !isRecord(value) || + !isNonNegativeSafeInteger(value['id']) || + !isRecord(value['callFrame']) + ) { + return false; + } + const callFrame = value['callFrame']; + const scriptId = callFrame['scriptId']; + const children = value['children']; + const hitCount = value['hitCount']; + return ( + typeof callFrame['functionName'] === 'string' && + (typeof scriptId === 'string' || + isNonNegativeSafeInteger(scriptId)) && + typeof callFrame['url'] === 'string' && + Number.isSafeInteger(callFrame['lineNumber']) && + Number.isSafeInteger(callFrame['columnNumber']) && + (children === undefined || + (Array.isArray(children) && + children.every(isNonNegativeSafeInteger))) && + (hitCount === undefined || isNonNegativeSafeInteger(hitCount)) + ); +} + +function isNonEmptyStringArray(value: unknown): value is readonly string[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every(isNonEmptyString) + ); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function isNonNegativeFiniteNumber(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isFinite(value) && value >= 0 + ); +} diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content.spec.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content.spec.ts new file mode 100644 index 000000000..8a67af360 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content.spec.ts @@ -0,0 +1,234 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + type InitialImportDiagnosticCapture, + validateInitialImportDiagnosticArtifacts, +} from './initial-import-diagnostic-artifacts'; +import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures'; +const HEAP_META = { + edge_fields: ['type', 'name_or_index', 'to_node'], + edge_types: [['context'], 'string_or_number', 'node'], + node_fields: [ + 'type', + 'name', + 'id', + 'self_size', + 'edge_count', + 'detachedness', + ], + node_types: [ + ['hidden'], + 'string', + 'number', + 'number', + 'number', + 'number', + ], +}; +const HEAP_SNAPSHOT = { + edges: [0, 0, 0], + nodes: [0, 0, 1, 0, 1, 0], + snapshot: { meta: HEAP_META }, + strings: ['', 'root'], +}; +const TRACE = { + traceEvents: [{ name: 'initial-import', ph: 'X', ts: 1 }], +}; + +function capture(directory: string): InitialImportDiagnosticCapture { + return { + main: { + cpuProfilePath: join(directory, 'main.cpuprofile'), + heapSnapshotPath: join(directory, 'main.heapsnapshot'), + workers: [ + { + kind: 'database.worker', + ordinal: 4, + profilePath: join( + directory, + 'database.worker-4.cpuprofile' + ), + snapshotPath: join( + directory, + 'database.worker-4.heapsnapshot' + ), + }, + ], + }, + renderer: { + cpuProfilePath: join(directory, 'renderer.cpuprofile'), + heapSnapshotPath: join(directory, 'renderer.heapsnapshot'), + tracePath: join(directory, 'renderer.trace.json'), + }, + }; +} + +async function writeArtifacts(directory: string): Promise { + const artifacts: Readonly> = { + 'database.worker-4.cpuprofile': CPU_PROFILE, + 'database.worker-4.heapsnapshot': HEAP_SNAPSHOT, + 'main.cpuprofile': CPU_PROFILE, + 'main.heapsnapshot': HEAP_SNAPSHOT, + 'renderer.cpuprofile': CPU_PROFILE, + 'renderer.heapsnapshot': HEAP_SNAPSHOT, + 'renderer.trace.json': TRACE, + }; + await Promise.all( + Object.entries(artifacts).map(([filename, value]) => + writeFile(join(directory, filename), JSON.stringify(value)) + ) + ); +} + +async function withArtifacts( + run: (directory: string) => Promise +): Promise { + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-content-') + ); + try { + await writeArtifacts(directory); + await run(directory); + } finally { + await rm(directory, { force: true, recursive: true }); + } +} + +test('accepts bounded signed CPU time deltas emitted by Chromium', async () => { + await withArtifacts(async (directory) => { + await writeFile( + join(directory, 'renderer.cpuprofile'), + JSON.stringify({ + ...CPU_PROFILE, + endTime: 5_000, + samples: [1, 1, 1], + timeDeltas: [1_000, -100, 1_000], + }) + ); + await assert.doesNotReject(() => + validateInitialImportDiagnosticArtifacts( + capture(directory), + directory + ) + ); + }); +}); + +test('rejects CPU profiles without useful bounded samples and stack references', async () => { + await withArtifacts(async (directory) => { + const invalidProfiles = [ + { ...CPU_PROFILE, samples: [], timeDeltas: [] }, + { ...CPU_PROFILE, timeDeltas: undefined }, + { ...CPU_PROFILE, timeDeltas: [] }, + { + ...CPU_PROFILE, + endTime: CPU_PROFILE.startTime, + }, + { + ...CPU_PROFILE, + samples: [2], + }, + { + ...CPU_PROFILE, + timeDeltas: [-1], + }, + { + ...CPU_PROFILE, + timeDeltas: [2_001], + }, + { + ...CPU_PROFILE, + nodes: [ + ...CPU_PROFILE.nodes, + { ...CPU_PROFILE.nodes[0], id: 1 }, + ], + }, + { + ...CPU_PROFILE, + nodes: [{ id: 1 }], + }, + ]; + for (const profile of invalidProfiles) { + await writeFile( + join(directory, 'main.cpuprofile'), + JSON.stringify(profile) + ); + await assert.rejects( + () => + validateInitialImportDiagnosticArtifacts( + capture(directory), + directory + ), + /initial-import-diagnostic-artifact-invalid:main-cpu-profile/ + ); + } + }); +}); + +test('rejects empty or malformed heap tables and invalid field strides', async () => { + await withArtifacts(async (directory) => { + const invalidSnapshots = [ + { snapshot: {}, nodes: [], edges: [], strings: [] }, + { ...HEAP_SNAPSHOT, nodes: [] }, + { ...HEAP_SNAPSHOT, edges: [] }, + { ...HEAP_SNAPSHOT, strings: [] }, + { + ...HEAP_SNAPSHOT, + snapshot: { + meta: { ...HEAP_META, node_types: undefined }, + }, + }, + { ...HEAP_SNAPSHOT, nodes: [...HEAP_SNAPSHOT.nodes, 0] }, + { ...HEAP_SNAPSHOT, edges: [...HEAP_SNAPSHOT.edges, 0] }, + ]; + for (const snapshot of invalidSnapshots) { + await writeFile( + join(directory, 'main.heapsnapshot'), + JSON.stringify(snapshot) + ); + await assert.rejects( + () => + validateInitialImportDiagnosticArtifacts( + capture(directory), + directory + ), + /initial-import-diagnostic-artifact-invalid:main-heap-snapshot/ + ); + } + }); +}); + +test('rejects empty traces and malformed trace events', async () => { + await withArtifacts(async (directory) => { + const invalidTraces = [ + { traceEvents: [] }, + { traceEvents: [null] }, + { traceEvents: [{ name: '', ph: 'X', ts: 1 }] }, + { traceEvents: [{ name: 'initial-import', ph: '', ts: 1 }] }, + { + traceEvents: [ + { name: 'initial-import', ph: 'X', ts: 'invalid' }, + ], + }, + ]; + for (const trace of invalidTraces) { + await writeFile( + join(directory, 'renderer.trace.json'), + JSON.stringify(trace) + ); + await assert.rejects( + () => + validateInitialImportDiagnosticArtifacts( + capture(directory), + directory + ), + /initial-import-diagnostic-artifact-invalid:renderer-trace/ + ); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.spec.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.spec.ts new file mode 100644 index 000000000..1270709c3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.spec.ts @@ -0,0 +1,394 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, symlink, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures'; + +interface DiagnosticCapture { + readonly main: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly workers: readonly { + readonly kind: string; + readonly ordinal: number; + readonly profilePath: string | null; + readonly snapshotPath: string | null; + }[]; + }; + readonly renderer: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly tracePath: string | null; + }; +} + +interface DiagnosticArtifactsModule { + validateInitialImportDiagnosticArtifacts?: ( + capture: DiagnosticCapture, + iterationDirectory: string + ) => Promise; +} + +const diagnosticArtifactsModulePromise = import( + new URL('./initial-import-diagnostic-artifacts.ts', import.meta.url).href +) + .then((module) => module as DiagnosticArtifactsModule) + .catch(() => null); + +const HEAP_SNAPSHOT = { + edges: [0, 0, 0], + nodes: [0, 0, 1, 0, 1, 0], + snapshot: { + meta: { + edge_fields: ['type', 'name_or_index', 'to_node'], + edge_types: [['context'], 'string_or_number', 'node'], + node_fields: [ + 'type', + 'name', + 'id', + 'self_size', + 'edge_count', + 'detachedness', + ], + node_types: [ + ['hidden'], + 'string', + 'number', + 'number', + 'number', + 'number', + ], + }, + }, + strings: ['', 'root'], +}; +const RENDERER_TRACE = { + traceEvents: [{ name: 'initial-import', ph: 'X', ts: 1 }], +}; + +function capture(directory: string, databaseOrdinal = 4): DiagnosticCapture { + return { + main: { + cpuProfilePath: join(directory, 'main.cpuprofile'), + heapSnapshotPath: join(directory, 'main.heapsnapshot'), + workers: [ + { + kind: 'database.worker', + ordinal: databaseOrdinal, + profilePath: join( + directory, + `database.worker-${databaseOrdinal}.cpuprofile` + ), + snapshotPath: join( + directory, + `database.worker-${databaseOrdinal}.heapsnapshot` + ), + }, + ], + }, + renderer: { + cpuProfilePath: join(directory, 'renderer.cpuprofile'), + heapSnapshotPath: join(directory, 'renderer.heapsnapshot'), + tracePath: join(directory, 'renderer.trace.json'), + }, + }; +} + +async function writeValidArtifacts( + directory: string, + databaseOrdinal = 4 +): Promise { + await Promise.all([ + writeFile( + join(directory, 'main.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ), + writeFile( + join(directory, 'renderer.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ), + writeFile( + join(directory, `database.worker-${databaseOrdinal}.cpuprofile`), + JSON.stringify(CPU_PROFILE) + ), + writeFile( + join(directory, `database.worker-${databaseOrdinal}.heapsnapshot`), + JSON.stringify(HEAP_SNAPSHOT) + ), + writeFile( + join(directory, 'main.heapsnapshot'), + JSON.stringify(HEAP_SNAPSHOT) + ), + writeFile( + join(directory, 'renderer.heapsnapshot'), + JSON.stringify(HEAP_SNAPSHOT) + ), + writeFile( + join(directory, 'renderer.trace.json'), + JSON.stringify(RENDERER_TRACE) + ), + ]); +} + +test('accepts the complete parseable initial-import diagnostic artifact set', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module, 'initial-import diagnostic validator must exist'); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-diagnostic-') + ); + + try { + await writeValidArtifacts(directory); + await assert.doesNotReject(() => + validate?.(capture(directory), directory) + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('fails closed for malformed or missing required process artifacts', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-invalid-') + ); + + try { + await writeValidArtifacts(directory); + await writeFile(join(directory, 'renderer.trace.json'), '{'); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-artifact-invalid:renderer-trace/ + ); + + await writeFile( + join(directory, 'renderer.trace.json'), + JSON.stringify(RENDERER_TRACE) + ); + const missingMainProfile: DiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + cpuProfilePath: null, + }, + }; + await assert.rejects( + () => validate?.(missingMainProfile, directory), + /initial-import-diagnostic-artifact-missing:main-cpu-profile/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('rejects escaped paths and symlinks instead of reading outside the iteration directory', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-contained-') + ); + const outsideDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-outside-') + ); + + try { + await writeValidArtifacts(directory); + const escapedCapture: DiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + cpuProfilePath: join(outsideDirectory, 'main.cpuprofile'), + }, + }; + await writeFile( + join(outsideDirectory, 'main.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ); + await assert.rejects( + () => validate?.(escapedCapture, directory), + /initial-import-diagnostic-artifact-path-invalid:main-cpu-profile/ + ); + + const rendererProfilePath = join(directory, 'renderer.cpuprofile'); + await unlink(rendererProfilePath); + await symlink( + join(outsideDirectory, 'main.cpuprofile'), + rendererProfilePath + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-artifact-path-invalid:renderer-cpu-profile/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + await rm(outsideDirectory, { force: true, recursive: true }); + } +}); + +test('requires exactly one database profile and rejects every playlist-worker profile', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-workers-') + ); + + try { + await writeValidArtifacts(directory); + await writeFile( + join(directory, 'database.worker-9.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-database-profile-cardinality-invalid/ + ); + + await unlink(join(directory, 'database.worker-9.cpuprofile')); + await writeFile( + join(directory, 'playlist-refresh.worker-2.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-playlist-profile-unexpected/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('requires a structurally valid database worker heap snapshot', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-worker-snapshot-') + ); + + try { + await writeValidArtifacts(directory); + const missingSnapshot: DiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + workers: [ + { + ...capture(directory).main.workers[0], + snapshotPath: null, + }, + ], + }, + }; + await assert.rejects( + () => validate?.(missingSnapshot, directory), + /initial-import-diagnostic-artifact-missing:database-heap-snapshot/ + ); + + await writeFile( + join(directory, 'database.worker-4.heapsnapshot'), + JSON.stringify({ snapshot: {}, nodes: [], edges: [] }) + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-artifact-invalid:database-heap-snapshot/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('rejects escaped and symlinked database worker heap snapshots', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-worker-contained-') + ); + const outsideDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-worker-outside-') + ); + + try { + await writeValidArtifacts(directory); + const outsideSnapshotPath = join( + outsideDirectory, + 'database.worker-4.heapsnapshot' + ); + await writeFile(outsideSnapshotPath, JSON.stringify(HEAP_SNAPSHOT)); + const escapedSnapshot: DiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + workers: [ + { + ...capture(directory).main.workers[0], + snapshotPath: outsideSnapshotPath, + }, + ], + }, + }; + await assert.rejects( + () => validate?.(escapedSnapshot, directory), + /initial-import-diagnostic-artifact-path-invalid:database-heap-snapshot/ + ); + + const snapshotPath = join(directory, 'database.worker-4.heapsnapshot'); + await unlink(snapshotPath); + await symlink(outsideSnapshotPath, snapshotPath); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-artifact-path-invalid:database-heap-snapshot/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + await rm(outsideDirectory, { force: true, recursive: true }); + } +}); + +test('rejects extra database snapshots and every playlist-worker snapshot', async () => { + const module = await diagnosticArtifactsModulePromise; + assert.ok(module); + const validate = module.validateInitialImportDiagnosticArtifacts; + assert.equal(typeof validate, 'function'); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-initial-import-worker-snapshot-count-') + ); + + try { + await writeValidArtifacts(directory); + await writeFile( + join(directory, 'database.worker-9.heapsnapshot'), + JSON.stringify(HEAP_SNAPSHOT) + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-database-snapshot-cardinality-invalid/ + ); + + await unlink(join(directory, 'database.worker-9.heapsnapshot')); + await writeFile( + join(directory, 'playlist-refresh.worker-2.heapsnapshot'), + JSON.stringify(HEAP_SNAPSHOT) + ); + await assert.rejects( + () => validate?.(capture(directory), directory), + /initial-import-diagnostic-playlist-snapshot-unexpected/ + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.ts new file mode 100644 index 000000000..f9f5cbdb3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.ts @@ -0,0 +1,262 @@ +import { lstat, readFile, readdir, realpath } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { + isCpuProfile, + isHeapSnapshot, + isRendererTrace, +} from './initial-import-diagnostic-artifact-content-validation'; + +interface DiagnosticWorkerArtifact { + readonly kind: string; + readonly ordinal: number; + readonly profilePath: string | null; + readonly snapshotPath: string | null; +} + +export interface InitialImportDiagnosticCapture { + readonly main: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly workers: readonly DiagnosticWorkerArtifact[]; + }; + readonly renderer: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly tracePath: string | null; + }; +} + +type ArtifactLabel = + | 'database-cpu-profile' + | 'database-heap-snapshot' + | 'main-cpu-profile' + | 'main-heap-snapshot' + | 'renderer-cpu-profile' + | 'renderer-heap-snapshot' + | 'renderer-trace'; + +interface ArtifactDescription { + readonly filename: string; + readonly label: ArtifactLabel; + readonly path: string | null; + readonly validate: (value: unknown) => boolean; +} + +export async function validateInitialImportDiagnosticArtifacts( + capture: InitialImportDiagnosticCapture, + iterationDirectory: string +): Promise { + const directory = resolve(iterationDirectory); + let realDirectory: string; + try { + realDirectory = await realpath(directory); + } catch { + throw new Error( + 'initial-import-diagnostic-artifact-path-invalid:iteration-directory' + ); + } + + const databaseWorker = requireExactDatabaseWorker(capture.main.workers); + await assertWorkerArtifactCardinality(directory); + + const artifacts: readonly ArtifactDescription[] = [ + { + filename: 'main.cpuprofile', + label: 'main-cpu-profile', + path: capture.main.cpuProfilePath, + validate: isCpuProfile, + }, + { + filename: 'renderer.cpuprofile', + label: 'renderer-cpu-profile', + path: capture.renderer.cpuProfilePath, + validate: isCpuProfile, + }, + { + filename: 'renderer.trace.json', + label: 'renderer-trace', + path: capture.renderer.tracePath, + validate: isRendererTrace, + }, + { + filename: 'main.heapsnapshot', + label: 'main-heap-snapshot', + path: capture.main.heapSnapshotPath, + validate: isHeapSnapshot, + }, + { + filename: 'renderer.heapsnapshot', + label: 'renderer-heap-snapshot', + path: capture.renderer.heapSnapshotPath, + validate: isHeapSnapshot, + }, + { + filename: `database.worker-${databaseWorker.ordinal}.cpuprofile`, + label: 'database-cpu-profile', + path: databaseWorker.profilePath, + validate: isCpuProfile, + }, + { + filename: `database.worker-${databaseWorker.ordinal}.heapsnapshot`, + label: 'database-heap-snapshot', + path: databaseWorker.snapshotPath, + validate: isHeapSnapshot, + }, + ]; + + for (const artifact of artifacts) { + await validateJsonArtifact(directory, realDirectory, artifact); + } +} + +function requireExactDatabaseWorker( + workers: readonly DiagnosticWorkerArtifact[] +): DiagnosticWorkerArtifact { + if ( + workers.some( + (worker) => + worker.kind === 'playlist-refresh.worker' && + worker.profilePath !== null + ) + ) { + throw new Error( + 'initial-import-diagnostic-playlist-profile-unexpected' + ); + } + if ( + workers.some( + (worker) => + worker.kind === 'playlist-refresh.worker' && + worker.snapshotPath !== null + ) + ) { + throw new Error( + 'initial-import-diagnostic-playlist-snapshot-unexpected' + ); + } + const databaseWorkers = workers.filter( + (worker) => worker.kind === 'database.worker' + ); + const worker = databaseWorkers[0]; + if ( + databaseWorkers.length !== 1 || + !worker || + !Number.isSafeInteger(worker.ordinal) || + worker.ordinal < 0 + ) { + throw new Error( + 'initial-import-diagnostic-database-profile-cardinality-invalid' + ); + } + return worker; +} + +async function assertWorkerArtifactCardinality( + directory: string +): Promise { + let entries: readonly string[]; + try { + entries = await readdir(directory); + } catch { + throw new Error( + 'initial-import-diagnostic-artifact-path-invalid:iteration-directory' + ); + } + if ( + entries.some((entry) => + /^playlist-refresh\.worker-\d+\.cpuprofile$/u.test(entry) + ) + ) { + throw new Error( + 'initial-import-diagnostic-playlist-profile-unexpected' + ); + } + if ( + entries.filter((entry) => + /^database\.worker-\d+\.cpuprofile$/u.test(entry) + ).length !== 1 + ) { + throw new Error( + 'initial-import-diagnostic-database-profile-cardinality-invalid' + ); + } + if ( + entries.some((entry) => + /^playlist-refresh\.worker-\d+\.heapsnapshot$/u.test(entry) + ) + ) { + throw new Error( + 'initial-import-diagnostic-playlist-snapshot-unexpected' + ); + } + if ( + entries.filter((entry) => + /^database\.worker-\d+\.heapsnapshot$/u.test(entry) + ).length !== 1 + ) { + throw new Error( + 'initial-import-diagnostic-database-snapshot-cardinality-invalid' + ); + } +} + +async function validateJsonArtifact( + directory: string, + realDirectory: string, + artifact: ArtifactDescription +): Promise { + if (artifact.path === null) { + throw new Error( + `initial-import-diagnostic-artifact-missing:${artifact.label}` + ); + } + const expectedPath = join(directory, artifact.filename); + if (resolve(artifact.path) !== expectedPath) { + throw new Error( + `initial-import-diagnostic-artifact-path-invalid:${artifact.label}` + ); + } + + let value: unknown; + try { + const stats = await lstat(expectedPath); + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error('unsafe-artifact-type'); + } + const realArtifactPath = await realpath(expectedPath); + if (!isContained(realDirectory, realArtifactPath)) { + throw new Error('escaped-artifact'); + } + value = JSON.parse(await readFile(realArtifactPath, 'utf8')) as unknown; + } catch (error) { + if ( + error instanceof Error && + (error.message === 'unsafe-artifact-type' || + error.message === 'escaped-artifact') + ) { + throw new Error( + `initial-import-diagnostic-artifact-path-invalid:${artifact.label}` + ); + } + throw new Error( + `initial-import-diagnostic-artifact-invalid:${artifact.label}` + ); + } + + if (!artifact.validate(value)) { + throw new Error( + `initial-import-diagnostic-artifact-invalid:${artifact.label}` + ); + } +} + +function isContained(directory: string, artifactPath: string): boolean { + const relativePath = relative(directory, artifactPath); + return ( + relativePath.length > 0 && + !isAbsolute(relativePath) && + relativePath !== '..' && + !relativePath.startsWith(`..${sep}`) + ); +} diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-test-fixtures.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-test-fixtures.ts new file mode 100644 index 000000000..1746c9335 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-test-fixtures.ts @@ -0,0 +1,19 @@ +export const CPU_PROFILE = Object.freeze({ + endTime: 3_000, + nodes: Object.freeze([ + Object.freeze({ + callFrame: Object.freeze({ + columnNumber: -1, + functionName: '(root)', + lineNumber: -1, + scriptId: '0', + url: '', + }), + hitCount: 1, + id: 1, + }), + ]), + samples: Object.freeze([1]), + startTime: 1_000, + timeDeltas: Object.freeze([1_000]), +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.spec.ts new file mode 100644 index 000000000..21c4aadc4 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.spec.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract'; +import { + assertSyntheticM3uImportUrl, + createM3uImportIterationDefinitions, + listM3uImportScenarios, + M3U_IMPORT_SCENARIO_ID, +} from './m3u-import-benchmark-contract'; + +describe('formal M3U import benchmark contract', () => { + it('defines the exact 10k, 50k, and 100k synthetic scenarios', () => { + assert.deepEqual(listM3uImportScenarios(), [ + { + channelCount: 10_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_10K, + }, + { + channelCount: 50_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_50K, + }, + { + channelCount: 100_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_100K, + }, + ]); + }); + + it('schedules one warm-up, five measured runs, and one diagnostic per size', () => { + const definitions = createM3uImportIterationDefinitions(false); + + assert.equal(definitions.length, 21); + for (const scenario of listM3uImportScenarios()) { + const scenarioRuns = definitions.filter( + (definition) => definition.scenarioId === scenario.id + ); + assert.deepEqual( + scenarioRuns.map(({ kind, runId }) => ({ kind, runId })), + [ + { + kind: PERFORMANCE_ITERATION_KIND.WARMUP, + runId: 'warmup-01', + }, + ...Array.from({ length: 5 }, (_, index) => ({ + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + runId: `run-${String(index + 1).padStart(2, '0')}`, + })), + { + kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + runId: 'diagnostic', + }, + ] + ); + assert.ok( + scenarioRuns.every( + (definition) => + definition.channelCount === scenario.channelCount + ) + ); + } + }); + + it('keeps fixture sizes unchanged while smoke mode reduces measured runs to one', () => { + const definitions = createM3uImportIterationDefinitions(true); + + assert.equal(definitions.length, 9); + for (const scenario of listM3uImportScenarios()) { + assert.deepEqual( + definitions + .filter( + (definition) => definition.scenarioId === scenario.id + ) + .map(({ kind, runId }) => ({ kind, runId })), + [ + { + kind: PERFORMANCE_ITERATION_KIND.WARMUP, + runId: 'warmup-01', + }, + { + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + runId: 'run-01', + }, + { + kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + runId: 'diagnostic', + }, + ] + ); + } + }); + + it('accepts only the exact credential-free loopback fixture URL', () => { + assert.doesNotThrow(() => + assertSyntheticM3uImportUrl( + 'http://127.0.0.1:43210/synthetic-performance.m3u' + ) + ); + + for (const unsafe of [ + 'https://127.0.0.1:43210/synthetic-performance.m3u', + 'http://localhost:43210/synthetic-performance.m3u', + 'http://127.0.0.1/synthetic-performance.m3u', + 'http://user:pass@127.0.0.1:43210/synthetic-performance.m3u', + 'http://127.0.0.1:43210/other.m3u', + 'http://127.0.0.1:43210/synthetic-performance.m3u?token=secret', + 'http://127.0.0.1:43210/synthetic-performance.m3u#fragment', + ]) { + assert.throws( + () => assertSyntheticM3uImportUrl(unsafe), + /exact synthetic HTTP loopback URL/ + ); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.ts new file mode 100644 index 000000000..6740be17f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-contract.ts @@ -0,0 +1,118 @@ +import { + PERFORMANCE_ITERATION_KIND, + type PerformanceIterationKind, +} from './m3u-refresh-cancellation-contract'; +import { SYNTHETIC_M3U_RESOURCE_PATH } from './synthetic-m3u-server'; +import type { SyntheticM3uChannelCount } from './synthetic-m3u'; + +export const M3U_IMPORT_SCENARIO_ID = { + IMPORT_10K: 'm3u-import-10k', + IMPORT_50K: 'm3u-import-50k', + IMPORT_100K: 'm3u-import-100k', +} as const; + +export type M3uImportScenarioId = + (typeof M3U_IMPORT_SCENARIO_ID)[keyof typeof M3U_IMPORT_SCENARIO_ID]; + +export interface M3uImportScenario { + readonly channelCount: SyntheticM3uChannelCount; + readonly id: M3uImportScenarioId; +} + +export interface M3uImportIterationDefinition { + readonly channelCount: SyntheticM3uChannelCount; + readonly kind: PerformanceIterationKind; + readonly runId: string; + readonly scenarioId: M3uImportScenarioId; +} + +const SCENARIOS: readonly M3uImportScenario[] = Object.freeze([ + Object.freeze({ + channelCount: 10_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_10K, + }), + Object.freeze({ + channelCount: 50_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_50K, + }), + Object.freeze({ + channelCount: 100_000, + id: M3U_IMPORT_SCENARIO_ID.IMPORT_100K, + }), +]); + +export function listM3uImportScenarios(): readonly M3uImportScenario[] { + return SCENARIOS; +} + +export function createM3uImportIterationDefinitions( + smoke: boolean +): readonly M3uImportIterationDefinition[] { + const measuredRuns = smoke ? 1 : 5; + const definitions: M3uImportIterationDefinition[] = []; + + for (const scenario of SCENARIOS) { + definitions.push( + iteration(scenario, PERFORMANCE_ITERATION_KIND.WARMUP, 'warmup-01') + ); + for (let index = 1; index <= measuredRuns; index += 1) { + definitions.push( + iteration( + scenario, + PERFORMANCE_ITERATION_KIND.MEASURED, + `run-${String(index).padStart(2, '0')}` + ) + ); + } + definitions.push( + iteration( + scenario, + PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + 'diagnostic' + ) + ); + } + + return Object.freeze(definitions); +} + +export function assertSyntheticM3uImportUrl(value: string): void { + let url: URL; + try { + url = new URL(value); + } catch { + throw invalidSyntheticUrl(); + } + + if ( + url.protocol !== 'http:' || + url.hostname !== '127.0.0.1' || + url.port.length === 0 || + url.username.length > 0 || + url.password.length > 0 || + url.pathname !== SYNTHETIC_M3U_RESOURCE_PATH || + url.search.length > 0 || + url.hash.length > 0 + ) { + throw invalidSyntheticUrl(); + } +} + +function iteration( + scenario: M3uImportScenario, + kind: PerformanceIterationKind, + runId: string +): M3uImportIterationDefinition { + return Object.freeze({ + channelCount: scenario.channelCount, + kind, + runId, + scenarioId: scenario.id, + }); +} + +function invalidSyntheticUrl(): Error { + return new Error( + 'M3U performance fixture must use the exact synthetic HTTP loopback URL' + ); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-lifecycle.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-lifecycle.spec.ts new file mode 100644 index 000000000..f1dcd053a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-lifecycle.spec.ts @@ -0,0 +1,184 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { describe, it } from 'node:test'; + +import { + assertM3uImportOutputPathHasNoSymlinks, + assertM3uImportSourceState, + M3U_IMPORT_RENDERER_CDP_PORT, + resolveM3uImportRendererCdpPort, + resolveM3uImportOutputLayout, +} from './m3u-import-benchmark-support'; +import { assertM3uImportRequestDelta } from './m3u-import.benchmark'; + +describe('initial M3U import benchmark lifecycle', () => { + it('keeps requested output and variant strictly below dist/performance', () => { + const workspaceRoot = resolve('/workspace/iptvnator'); + const outputRoot = resolve( + workspaceRoot, + 'dist/performance/20260727T040000Z-m3u-import' + ); + assert.deepEqual( + resolveM3uImportOutputLayout(workspaceRoot, outputRoot, 'baseline'), + { + outputRoot, + variantDirectory: resolve(outputRoot, 'baseline'), + } + ); + + for (const requestedRoot of [ + 'dist/performance/run', + resolve(workspaceRoot, 'dist/performance'), + resolve(workspaceRoot, 'dist/performance-sibling/run'), + resolve(workspaceRoot, '../outside'), + ]) { + assert.throws( + () => + resolveM3uImportOutputLayout( + workspaceRoot, + requestedRoot, + 'baseline' + ), + /absolute path below dist\/performance/ + ); + } + assert.throws( + () => + resolveM3uImportOutputLayout( + workspaceRoot, + outputRoot, + '../escape' + ), + /variant is invalid/i + ); + }); + + it('requires a clean source tree for formal runs but permits dirty smoke evidence', () => { + const dirtyState = { + commit: 'a'.repeat(40), + diffSha256: 'b'.repeat(64), + dirty: true, + }; + + assert.throws( + () => assertM3uImportSourceState(false, dirtyState), + /Formal performance runs require a clean Git worktree/ + ); + assert.doesNotThrow(() => assertM3uImportSourceState(true, dirtyState)); + assert.doesNotThrow(() => + assertM3uImportSourceState(false, { + ...dirtyState, + dirty: false, + }) + ); + }); + + it('rejects an existing output ancestor symlink before artifact creation', async () => { + const fixtureRoot = await mkdtemp( + join(tmpdir(), 'iptvnator-m3u-output-path-') + ); + const repositoryRoot = join(fixtureRoot, 'repository'); + const performanceRoot = join( + repositoryRoot, + 'dist', + 'performance' + ); + const outside = join(fixtureRoot, 'outside'); + await mkdir(performanceRoot, { recursive: true }); + await mkdir(outside); + await symlink(outside, join(performanceRoot, 'redirect')); + + try { + await assert.rejects( + assertM3uImportOutputPathHasNoSymlinks( + repositoryRoot, + join(performanceRoot, 'redirect', 'run') + ), + /Performance output path contains a symbolic link/ + ); + await assert.doesNotReject( + assertM3uImportOutputPathHasNoSymlinks( + repositoryRoot, + join(performanceRoot, 'ordinary', 'run') + ) + ); + } finally { + await rm(fixtureRoot, { force: true, recursive: true }); + } + }); + + it('keeps formal CDP capture on 9222 while allowing an isolated smoke port', () => { + assert.equal( + resolveM3uImportRendererCdpPort(false, undefined), + M3U_IMPORT_RENDERER_CDP_PORT + ); + assert.equal( + resolveM3uImportRendererCdpPort(false, '9222'), + M3U_IMPORT_RENDERER_CDP_PORT + ); + assert.equal(resolveM3uImportRendererCdpPort(true, '9322'), 9322); + + assert.throws( + () => resolveM3uImportRendererCdpPort(false, '9322'), + /Formal performance runs require CDP port 9222/ + ); + for (const invalid of ['0', '1023', '65536', '9222.5', 'not-a-port']) { + assert.throws( + () => resolveM3uImportRendererCdpPort(true, invalid), + /IPTVNATOR_PERF_CDP_PORT is invalid/ + ); + } + }); + + it('fails closed unless one and only one app GET occurs', () => { + assert.doesNotThrow(() => assertM3uImportRequestDelta(3, 4)); + assert.throws( + () => assertM3uImportRequestDelta(3, 3), + /exactly one application GET/ + ); + assert.throws( + () => assertM3uImportRequestDelta(3, 5), + /exactly one application GET/ + ); + }); + + it('waits for the exact upsert plus route-reload GET sequence', () => { + const source = readFileSync( + resolve( + process.cwd(), + 'src/performance/m3u-import.benchmark.ts' + ), + 'utf8' + ); + + assert.match(source, /status\.databaseRequests\s*===\s*2/); + assert.match(source, /status\.databaseUpsertsCompleted\s*===\s*1/); + assert.match(source, /status\.databaseGetsCompleted\s*===\s*1/); + assert.match(source, /status\.databasePending\s*===\s*0/); + assert.match(source, /status\.preloadSuccessMarkers\s*===\s*2/); + }); + + it('always disposes renderer capture before closing Electron on iteration failure', () => { + const source = readFileSync( + resolve( + process.cwd(), + 'src/performance/m3u-import.benchmark.ts' + ), + 'utf8' + ); + const runIteration = source.indexOf('async function runIteration'); + const finallyBlock = source.indexOf('} finally {', runIteration); + const rendererDispose = source.indexOf( + 'await renderer.dispose()', + finallyBlock + ); + const appClose = source.indexOf('await closeElectronApp(app)', finallyBlock); + + assert.ok(finallyBlock > runIteration); + assert.ok(rendererDispose > finallyBlock); + assert.ok(appClose > rendererDispose); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts new file mode 100644 index 000000000..c153a3581 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts @@ -0,0 +1,300 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { access, lstat, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { createServer as createTcpServer } from 'node:net'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { expect, type Page } from '@playwright/test'; + +import { + type LaunchedElectronApp, + workspaceRoot, +} from '../electron-test-fixtures'; +import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight'; +import type { RendererWindowIdentity } from './renderer-window-rss-session'; + +const OUTPUT_VARIANT_PATTERN = /^[a-z][a-z0-9-]{0,31}$/; +export const M3U_IMPORT_RENDERER_CDP_PORT = 9222; + +export interface M3uImportGitSourceState { + readonly commit: string; + readonly diffSha256: string; + readonly dirty: boolean; +} + +export interface M3uImportOutputLayout { + readonly outputRoot: string; + readonly variantDirectory: string; +} + +export interface M3uImportBenchmarkConfiguration extends M3uImportOutputLayout { + readonly electronVersion: string; + readonly measuredRuns: number; + readonly rendererCdpPort: number; + readonly smoke: boolean; + readonly sourceState: M3uImportGitSourceState; + readonly variant: string; +} + +export async function resolveM3uImportBenchmarkConfiguration(): Promise { + const requestedRoot = process.env['IPTVNATOR_PERF_OUTPUT_DIR']; + if (!requestedRoot) { + throw new Error('IPTVNATOR_PERF_OUTPUT_DIR is required'); + } + const variant = process.env['IPTVNATOR_PERF_VARIANT'] ?? 'baseline'; + const layout = resolveM3uImportOutputLayout( + workspaceRoot, + requestedRoot, + variant + ); + const smoke = process.env['IPTVNATOR_PERF_SMOKE'] === '1'; + const rendererCdpPort = resolveM3uImportRendererCdpPort( + smoke, + process.env['IPTVNATOR_PERF_CDP_PORT'] + ); + const sourceState = readGitSourceState(workspaceRoot); + assertM3uImportSourceState(smoke, sourceState); + const electronPackage = JSON.parse( + await readFile( + join(workspaceRoot, 'node_modules', 'electron', 'package.json'), + 'utf8' + ) + ) as { version?: unknown }; + if (typeof electronPackage.version !== 'string') { + throw new Error('Unable to resolve the Electron runtime version'); + } + await assertM3uImportOutputPathHasNoSymlinks( + workspaceRoot, + layout.outputRoot + ); + await assertMissing(layout.variantDirectory); + await assertPerformanceArtifactCapacity(layout.variantDirectory); + await mkdir(layout.variantDirectory, { recursive: true }); + return Object.freeze({ + ...layout, + electronVersion: electronPackage.version, + measuredRuns: smoke ? 1 : 5, + rendererCdpPort, + smoke, + sourceState, + variant, + }); +} + +export function resolveM3uImportOutputLayout( + repositoryRoot: string, + requestedRoot: string, + variant: string +): M3uImportOutputLayout { + const performanceRoot = resolve(repositoryRoot, 'dist', 'performance'); + const outputRoot = resolve(requestedRoot); + if ( + !isAbsolute(requestedRoot) || + !isStrictDescendant(performanceRoot, outputRoot) + ) { + throw new Error( + 'Performance output must be an absolute path below dist/performance' + ); + } + if (!OUTPUT_VARIANT_PATTERN.test(variant)) { + throw new Error('IPTVNATOR_PERF_VARIANT is invalid'); + } + const variantDirectory = resolve(outputRoot, variant); + if (!isStrictDescendant(outputRoot, variantDirectory)) { + throw new Error('IPTVNATOR_PERF_VARIANT is invalid'); + } + return Object.freeze({ outputRoot, variantDirectory }); +} + +export async function assertM3uImportOutputPathHasNoSymlinks( + repositoryRoot: string, + outputRoot: string +): Promise { + const root = resolve(repositoryRoot); + const output = resolve(outputRoot); + if (!isStrictDescendant(root, output)) { + throw new Error('Performance output path escapes the repository'); + } + const components = relative(root, output).split(sep); + let current = root; + + for (const component of ['', ...components]) { + current = component === '' ? current : join(current, component); + try { + const stats = await lstat(current); + if (stats.isSymbolicLink()) { + throw new Error( + `Performance output path contains a symbolic link: ${current}` + ); + } + } catch (error) { + if (isMissingPath(error)) { + return; + } + throw error; + } + } +} + +export function assertM3uImportSourceState( + smoke: boolean, + state: M3uImportGitSourceState +): void { + if (!smoke && state.dirty) { + throw new Error( + 'Formal performance runs require a clean Git worktree; commit or stash changes first' + ); + } +} + +export function resolveM3uImportRendererCdpPort( + smoke: boolean, + requestedPort: string | undefined +): number { + const port = + requestedPort === undefined + ? M3U_IMPORT_RENDERER_CDP_PORT + : Number(requestedPort); + if ( + (requestedPort !== undefined && !/^[1-9]\d*$/.test(requestedPort)) || + !Number.isSafeInteger(port) || + port < 1024 || + port > 65_535 + ) { + throw new Error('IPTVNATOR_PERF_CDP_PORT is invalid'); + } + if (!smoke && port !== M3U_IMPORT_RENDERER_CDP_PORT) { + throw new Error( + `Formal performance runs require CDP port ${M3U_IMPORT_RENDERER_CDP_PORT}` + ); + } + return port; +} + +export async function resolveRendererWindowIdentity( + app: LaunchedElectronApp +): Promise { + const handle = await app.electronApp.browserWindow(app.mainWindow); + try { + return await handle.evaluate((browserWindow) => { + const exactWindow = browserWindow as unknown as { + readonly id: number; + readonly webContents: { readonly id: number }; + }; + return { + browserWindowId: exactWindow.id, + webContentsId: exactWindow.webContents.id, + }; + }); + } finally { + await handle.dispose(); + } +} + +export async function assertTcpPortAvailable(port: number): Promise { + const server = createTcpServer(); + await new Promise((resolvePromise, rejectPromise) => { + server.once('error', rejectPromise); + server.listen( + { exclusive: true, host: '127.0.0.1', port }, + resolvePromise + ); + }); + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => + error ? rejectPromise(error) : resolvePromise() + ); + }); +} + +export async function assertRendererCdpTarget( + page: Page, + port: number +): Promise { + await expect + .poll( + async () => { + try { + const response = await fetch( + `http://127.0.0.1:${port}/json/list` + ); + const targets = response.ok + ? ((await response.json()) as unknown) + : null; + return ( + Array.isArray(targets) && + targets.some( + (target) => + typeof target === 'object' && + target !== null && + (target as Record)['type'] === + 'page' && + (target as Record)['url'] === + page.url() + ) + ); + } catch { + return false; + } + }, + { timeout: 10_000 } + ) + .toBe(true); +} + +export async function writeM3uImportJson( + path: string, + value: unknown +): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +function readGitSourceState(repositoryRoot: string): M3uImportGitSourceState { + const git = (args: readonly string[]): string => + execFileSync('git', [...args], { + cwd: repositoryRoot, + encoding: 'utf8', + }); + const commit = git(['rev-parse', 'HEAD']).trim(); + const status = git(['status', '--porcelain=v1', '--untracked-files=all']); + const diffSha256 = createHash('sha256') + .update(status) + .update('\0') + .update(git(['diff', '--binary', 'HEAD'])) + .update('\0') + .update(git(['diff', '--binary', '--cached', 'HEAD'])) + .digest('hex'); + return Object.freeze({ + commit, + diffSha256, + dirty: status.trim().length > 0, + }); +} + +function isStrictDescendant(parent: string, candidate: string): boolean { + const pathFromParent = relative(resolve(parent), resolve(candidate)); + return ( + pathFromParent.length > 0 && + pathFromParent !== '..' && + !pathFromParent.startsWith(`..${sep}`) && + !isAbsolute(pathFromParent) + ); +} + +async function assertMissing(path: string): Promise { + try { + await access(path); + } catch { + return; + } + throw new Error(`Performance output already exists: ${path}`); +} + +function isMissingPath(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-ipc-attribution.ts b/apps/electron-backend-e2e/src/performance/m3u-import-ipc-attribution.ts new file mode 100644 index 000000000..29e17edb6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-ipc-attribution.ts @@ -0,0 +1,250 @@ +import type { + MainTimelineRecord, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; + +type InitialImportDatabaseOperation = + | 'DB_GET_APP_PLAYLIST' + | 'DB_UPSERT_APP_PLAYLIST'; + +export interface M3uImportIpcAttribution { + readonly databaseWorkerToMainCloneProxyMs: number; + readonly ipcStructuredCloneProxyMs: number; + readonly mainToDatabaseWorkerCloneProxyMs: number; + readonly mainToRendererCloneProxyMs: number; + readonly rendererToMainCloneProxyMs: number; +} + +interface RequestBoundaryAttribution { + readonly mainToDatabaseWorkerMs: number; + readonly mainToRendererMs: number; + readonly rendererToMainMs: number; + readonly successEpochMs: number; + readonly workerToMainMs: number; +} + +export function deriveM3uImportIpcAttribution(input: { + readonly get: WorkerRequestPerformanceMetrics; + readonly getReadStartedEpochMs: number; + readonly importDispatchEndedEpochMs: number; + readonly importDispatchStartedEpochMs: number; + readonly normalizeEndedEpochMs: number; + readonly playlistId: string; + readonly publishChannelsStartedEpochMs: number; + readonly serializeStartedEpochMs: number; + readonly timeline: readonly MainTimelineRecord[]; + readonly upsert: WorkerRequestPerformanceMetrics; +}): M3uImportIpcAttribution { + requireOrdered( + input.normalizeEndedEpochMs, + input.importDispatchStartedEpochMs + ); + requireOrdered( + input.importDispatchStartedEpochMs, + input.importDispatchEndedEpochMs + ); + + const upsert = deriveRequestBoundaryAttribution({ + operation: 'DB_UPSERT_APP_PLAYLIST', + playlistId: input.playlistId, + request: input.upsert, + timeline: input.timeline, + workPhaseStartedEpochMs: input.serializeStartedEpochMs, + }); + requireOrdered(input.importDispatchEndedEpochMs, requireSource(input.upsert)); + + const get = deriveRequestBoundaryAttribution({ + operation: 'DB_GET_APP_PLAYLIST', + playlistId: input.playlistId, + request: input.get, + timeline: input.timeline, + workPhaseStartedEpochMs: input.getReadStartedEpochMs, + }); + requireOrdered(upsert.successEpochMs, requireSource(input.get)); + requireOrdered( + get.successEpochMs, + input.publishChannelsStartedEpochMs + ); + + const initialMainToRendererMs = difference( + input.importDispatchStartedEpochMs, + input.normalizeEndedEpochMs + ); + const mainToRendererCloneProxyMs = + initialMainToRendererMs + + upsert.mainToRendererMs + + get.mainToRendererMs; + const rendererToMainCloneProxyMs = + upsert.rendererToMainMs + get.rendererToMainMs; + const mainToDatabaseWorkerCloneProxyMs = + upsert.mainToDatabaseWorkerMs + get.mainToDatabaseWorkerMs; + const databaseWorkerToMainCloneProxyMs = + upsert.workerToMainMs + get.workerToMainMs; + + return Object.freeze({ + databaseWorkerToMainCloneProxyMs, + ipcStructuredCloneProxyMs: + mainToRendererCloneProxyMs + + rendererToMainCloneProxyMs + + mainToDatabaseWorkerCloneProxyMs + + databaseWorkerToMainCloneProxyMs, + mainToDatabaseWorkerCloneProxyMs, + mainToRendererCloneProxyMs, + rendererToMainCloneProxyMs, + }); +} + +function deriveRequestBoundaryAttribution(input: { + readonly operation: InitialImportDatabaseOperation; + readonly playlistId: string; + readonly request: WorkerRequestPerformanceMetrics; + readonly timeline: readonly MainTimelineRecord[]; + readonly workPhaseStartedEpochMs: number; +}): RequestBoundaryAttribution { + requireInitialImportPreloadIdentity(input.request, input.operation); + const requestTimeline = requireTimelineRecord( + input.timeline, + 'db-request', + input.request, + input.operation, + input.playlistId + ); + requireTimelineRecord( + input.timeline, + 'db-response', + input.request, + input.operation, + input.playlistId + ); + const successTimeline = requireTimelineRecord( + input.timeline, + 'preload-performance-success', + input.request, + input.operation, + input.playlistId + ); + const sourceEpochMs = requireSource(input.request); + const requestReceivedEpochMs = requireTimestamp( + input.request.requestReceivedEpochMs + ); + const workStartedEpochMs = requireTimestamp( + input.request.workStartedEpochMs + ); + const workEndedEpochMs = requireTimestamp(input.request.workEndedEpochMs); + const responsePostedEpochMs = requireTimestamp( + input.request.responsePostedEpochMs + ); + const responseEpochMs = requireTimestamp(input.request.responseEpochMs); + const successEpochMs = requireTimestamp(successTimeline.sourceEpochMs); + + requireOrdered(sourceEpochMs, requestTimeline.epochMs); + requireOrdered(requestTimeline.epochMs, requestReceivedEpochMs); + requireOrdered(requestReceivedEpochMs, workStartedEpochMs); + requireOrdered(workStartedEpochMs, input.workPhaseStartedEpochMs); + requireOrdered(input.workPhaseStartedEpochMs, workEndedEpochMs); + requireOrdered(workEndedEpochMs, responsePostedEpochMs); + requireOrdered(responsePostedEpochMs, responseEpochMs); + requireOrdered(responseEpochMs, successEpochMs); + + return { + mainToDatabaseWorkerMs: difference( + requestReceivedEpochMs, + requestTimeline.epochMs + ), + mainToRendererMs: difference(successEpochMs, responseEpochMs), + rendererToMainMs: difference(requestTimeline.epochMs, sourceEpochMs), + successEpochMs, + workerToMainMs: difference(responseEpochMs, responsePostedEpochMs), + }; +} + +function requireTimelineRecord( + timeline: readonly MainTimelineRecord[], + type: 'db-request' | 'db-response' | 'preload-performance-success', + request: WorkerRequestPerformanceMetrics, + operation: InitialImportDatabaseOperation, + playlistId: string +): MainTimelineRecord { + if (!request.requestId || !Number.isSafeInteger(request.ipcCallId)) { + throw new Error('database-request-identity-missing'); + } + const matches = timeline.filter((record) => { + if ( + record.type !== type || + record.operation !== operation || + record.playlistId !== playlistId + ) { + return false; + } + if (type === 'preload-performance-success') { + return ( + record.ipcCallId === request.ipcCallId && + record.sourceEpochMs !== undefined && + record.success === true + ); + } + return ( + record.requestId === request.requestId && + (type !== 'db-response' || record.success === true) + ); + }); + if (matches.length !== 1 || !matches[0]) { + throw new Error(`database-${type}-timeline-correlation`); + } + const record = matches[0]; + if ( + type === 'db-request' && + (record.ipcCallId !== request.ipcCallId || + record.sourceEpochMs !== request.sourceEpochMs) + ) { + throw new Error('database-request-timeline-identity'); + } + return record; +} + +function requireInitialImportPreloadIdentity( + request: WorkerRequestPerformanceMetrics, + operation: InitialImportDatabaseOperation +): void { + if ( + request.operation !== operation || + !request.success || + request.performanceCaptureUnavailableReason !== null || + !Number.isSafeInteger(request.ipcCallId) || + Number(request.ipcCallId) < 1 || + typeof request.sourceEpochMs !== 'number' || + !Number.isFinite(request.sourceEpochMs) || + request.sourceEpochMs < 0 || + request.operationId !== null || + request.operationIdUnavailableReason !== + 'preload-performance-marker-uncorrelated' + ) { + throw new Error('database-request-preload-identity'); + } +} + +function requireSource(request: WorkerRequestPerformanceMetrics): number { + return requireTimestamp(request.sourceEpochMs); +} + +function requireTimestamp(value: number | null | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new Error('database-request-timestamp-missing'); + } + return value; +} + +function requireOrdered(startEpochMs: number, endEpochMs: number): void { + if ( + !Number.isFinite(startEpochMs) || + !Number.isFinite(endEpochMs) || + endEpochMs < startEpochMs + ) { + throw new Error('phase-order'); + } +} + +function difference(endEpochMs: number, startEpochMs: number): number { + requireOrdered(startEpochMs, endEpochMs); + return endEpochMs - startEpochMs; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-renderer-artifacts.ts b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-artifacts.ts new file mode 100644 index 000000000..fa8f198cb --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-artifacts.ts @@ -0,0 +1,181 @@ +import { once } from 'node:events'; +import { createWriteStream } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { CDPSession } from '@playwright/test'; + +interface TraceData { + readonly value: readonly unknown[]; +} + +export interface RendererArtifactCapture { + beginTraceOutput(): void; + readonly cpuProfilePath: string | null; + finishTraceOutput(): Promise; + readonly heapSnapshotPath: string | null; + readonly onTraceComplete: () => void; + readonly onTraceData: (data: TraceData) => void; + readonly traceComplete: Promise; + readonly tracePath: string | null; +} + +export function createRendererArtifactCapture(options: { + readonly diagnostic: boolean; + readonly outputDirectory: string; +}): RendererArtifactCapture { + const tracePath = options.diagnostic + ? join(options.outputDirectory, 'renderer.trace.json') + : null; + const traceOutput = tracePath ? createWriteStream(tracePath) : null; + let traceOutputOpened = false; + let traceOutputFinish: Promise | null = null; + let traceFirstEvent = true; + let completeTrace: (() => void) | null = null; + const traceComplete = new Promise((resolve) => { + completeTrace = resolve; + }); + return { + beginTraceOutput: () => { + if ( + traceOutput && + !traceOutputOpened && + traceOutputFinish === null + ) { + traceOutput.write('{"traceEvents":['); + traceOutputOpened = true; + } + }, + cpuProfilePath: options.diagnostic + ? join(options.outputDirectory, 'renderer.cpuprofile') + : null, + finishTraceOutput: () => { + traceOutputFinish ??= (async () => { + if (!traceOutput) { + return; + } + if (!traceOutputOpened) { + traceOutput.write('{"traceEvents":['); + traceOutputOpened = true; + } + if (!traceOutput.writableEnded) { + traceOutput.write(']}'); + traceOutput.end(); + } + if (!traceOutput.writableFinished) { + await once(traceOutput, 'finish'); + } + })(); + return traceOutputFinish; + }, + heapSnapshotPath: options.diagnostic + ? join(options.outputDirectory, 'renderer.heapsnapshot') + : null, + onTraceComplete: () => { + completeTrace?.(); + completeTrace = null; + }, + onTraceData: (data) => { + if (!traceOutputOpened || traceOutput?.writableEnded) { + return; + } + for (const event of data.value) { + traceOutput?.write( + `${traceFirstEvent ? '' : ','}${JSON.stringify(event)}` + ); + traceFirstEvent = false; + } + }, + traceComplete, + tracePath, + }; +} + +export async function startRendererDiagnosticCapture( + session: CDPSession, + artifacts: RendererArtifactCapture +): Promise { + await session.send('Profiler.enable'); + await session.send('Profiler.start'); + artifacts.beginTraceOutput(); + session.on('Tracing.dataCollected', artifacts.onTraceData); + session.on('Tracing.tracingComplete', artifacts.onTraceComplete); + await session.send('Tracing.start', { + categories: [ + 'blink.user_timing', + 'devtools.timeline', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-v8.cpu_profiler', + 'v8', + ].join(','), + options: 'sampling-frequency=1000', + transferMode: 'ReportEvents', + }); +} + +export async function stopRendererDiagnosticCapture( + session: CDPSession, + artifacts: RendererArtifactCapture +): Promise { + try { + const cpuProfile = await session.send('Profiler.stop'); + await writeFile( + requirePath(artifacts.cpuProfilePath, 'renderer CPU profile'), + JSON.stringify(cpuProfile['profile']), + 'utf8' + ); + await session.send('Tracing.end'); + await artifacts.traceComplete; + } finally { + disposeRendererDiagnosticListeners(session, artifacts); + await artifacts.finishTraceOutput(); + } +} + +export async function disposeRendererDiagnosticCapture( + session: CDPSession, + artifacts: RendererArtifactCapture +): Promise { + disposeRendererDiagnosticListeners(session, artifacts); + await artifacts.finishTraceOutput(); +} + +export async function takeRendererHeapSnapshot( + session: CDPSession, + path: string | null +): Promise { + const output = createWriteStream( + requirePath(path, 'renderer heap snapshot') + ); + const onChunk = ({ chunk }: { readonly chunk: string }): void => { + output.write(chunk); + }; + session.on('HeapProfiler.addHeapSnapshotChunk', onChunk); + try { + await session.send('HeapProfiler.takeHeapSnapshot', { + reportProgress: false, + }); + } finally { + session.off('HeapProfiler.addHeapSnapshotChunk', onChunk); + output.end(); + if (!output.writableFinished) { + await once(output, 'finish'); + } + } +} + +function disposeRendererDiagnosticListeners( + session: CDPSession, + artifacts: RendererArtifactCapture +): void { + session.off('Tracing.dataCollected', artifacts.onTraceData); + session.off('Tracing.tracingComplete', artifacts.onTraceComplete); + artifacts.onTraceComplete(); +} + +function requirePath(value: string | null, label: string): string { + if (value === null) { + throw new Error(`${label} path is unavailable`); + } + return value; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture-lifecycle.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture-lifecycle.spec.ts new file mode 100644 index 000000000..d2121babd --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture-lifecycle.spec.ts @@ -0,0 +1,313 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based lifecycle test. */ +import { RENDERER_PERFORMANCE_PHASE_HOOK_KEY } from '@iptvnator/shared/logging'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { startM3uImportRendererCapture } from './m3u-import-renderer-capture'; +import { M3U_IMPORT_RENDERER_STATE_KEY } from './m3u-import-renderer-probe'; + +test('idempotently disposes failed-iteration timers, probe, trace stream, listeners, and CDP session', async () => { + const browser = installFakeProbeEnvironment(); + const outputDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-renderer-capture-dispose-') + ); + const session = new FakeCdpSession(); + const control = { + click: async () => undefined, + fill: async () => undefined, + first: () => control, + getByRole: () => control, + isDisabled: async () => false, + last: () => control, + waitFor: async () => undefined, + }; + const page = { + ...browser.page, + context: () => ({ + newCDPSession: async () => session, + }), + getByRole: () => control, + locator: () => control, + }; + + try { + const capture = await startM3uImportRendererCapture( + page as never, + { + diagnostic: true, + outputDirectory, + playlistTitle: 'Synthetic', + playlistUrl: + 'http://127.0.0.1:43210/synthetic-performance.m3u', + } + ); + assert.equal(session.listenerCount('Tracing.dataCollected'), 1); + assert.equal(session.listenerCount('Tracing.tracingComplete'), 1); + + await capture.dispose(); + await capture.dispose(); + + assert.equal(session.detachCount, 1); + assert.equal(session.listenerCount('Tracing.dataCollected'), 0); + assert.equal(session.listenerCount('Tracing.tracingComplete'), 0); + assert.equal(browser.readState(), undefined); + const heapSamplesAfterDispose = session.heapUsageCalls; + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(session.heapUsageCalls, heapSamplesAfterDispose); + assert.deepEqual( + JSON.parse( + await readFile( + join(outputDirectory, 'renderer.trace.json'), + 'utf8' + ) + ), + { traceEvents: [] } + ); + } finally { + browser.cleanup(); + await rm(outputDirectory, { force: true, recursive: true }); + } +}); + +test('failed-iteration disposal does not await a wedged renderer or CDP detach', async () => { + const browser = installFakeProbeEnvironment(); + const outputDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-renderer-capture-wedged-') + ); + const session = new FakeCdpSession({ hangDetach: true }); + let evaluateCalls = 0; + const page = createFakePage(browser, session, async (callback, argument) => { + evaluateCalls += 1; + if (evaluateCalls > 1) { + return new Promise(() => undefined); + } + return browser.page.evaluate(callback, argument); + }); + + try { + const capture = await startM3uImportRendererCapture(page as never, { + diagnostic: false, + outputDirectory, + playlistTitle: 'Synthetic', + playlistUrl: + 'http://127.0.0.1:43210/synthetic-performance.m3u', + }); + const outcome = await Promise.race([ + capture.dispose().then(() => 'disposed'), + new Promise<'timeout'>((resolve) => + setTimeout(() => resolve('timeout'), 150) + ), + ]); + + assert.equal(outcome, 'disposed'); + assert.equal(session.detachCount, 1); + const heapSamplesAfterDispose = session.heapUsageCalls; + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(session.heapUsageCalls, heapSamplesAfterDispose); + } finally { + browser.cleanup(); + await rm(outputDirectory, { force: true, recursive: true }); + } +}); + +test('rolls back partially started capture when Chromium tracing rejects', async () => { + const browser = installFakeProbeEnvironment(); + const outputDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-renderer-capture-startup-') + ); + const session = new FakeCdpSession({ failMethod: 'Tracing.start' }); + const intervalToken = Object.freeze({}); + const setIntervalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'setInterval' + ); + const clearIntervalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'clearInterval' + ); + let clearIntervalCalls = 0; + Object.defineProperty(globalThis, 'setInterval', { + configurable: true, + value: () => intervalToken, + writable: true, + }); + Object.defineProperty(globalThis, 'clearInterval', { + configurable: true, + value: (token: unknown) => { + if (token === intervalToken) { + clearIntervalCalls += 1; + } + }, + writable: true, + }); + + try { + await assert.rejects( + startM3uImportRendererCapture( + createFakePage(browser, session) as never, + { + diagnostic: true, + outputDirectory, + playlistTitle: 'Synthetic', + playlistUrl: + 'http://127.0.0.1:43210/synthetic-performance.m3u', + } + ), + /synthetic Tracing\.start failure/ + ); + + assert.equal(clearIntervalCalls, 1); + assert.equal(session.detachCount, 1); + assert.equal(session.listenerCount('Tracing.dataCollected'), 0); + assert.equal(session.listenerCount('Tracing.tracingComplete'), 0); + assert.equal(browser.readState(), undefined); + assert.deepEqual( + JSON.parse( + await readFile( + join(outputDirectory, 'renderer.trace.json'), + 'utf8' + ) + ), + { traceEvents: [] } + ); + } finally { + restoreGlobal('setInterval', setIntervalDescriptor); + restoreGlobal('clearInterval', clearIntervalDescriptor); + browser.cleanup(); + await rm(outputDirectory, { force: true, recursive: true }); + } +}); + +class FakeCdpSession extends EventEmitter { + detachCount = 0; + heapUsageCalls = 0; + + constructor( + private readonly options: { + readonly failMethod?: string; + readonly hangDetach?: boolean; + } = {} + ) { + super(); + } + + async detach(): Promise { + this.detachCount += 1; + if (this.options.hangDetach) { + return new Promise(() => undefined); + } + } + + async send(method: string): Promise> { + if (method === this.options.failMethod) { + throw new Error(`synthetic ${method} failure`); + } + if (method === 'Runtime.getHeapUsage') { + this.heapUsageCalls += 1; + return { usedSize: 1_024 }; + } + return {}; + } +} + +function installFakeProbeEnvironment() { + const descriptors = new Map(); + let nextFrameId = 1; + const replaceGlobal = (key: string, value: unknown): void => { + descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { + configurable: true, + value, + writable: true, + }); + }; + class FakePerformanceObserver { + static readonly supportedEntryTypes = ['longtask']; + + disconnect(): undefined { + return undefined; + } + observe(): undefined { + return undefined; + } + takeRecords(): PerformanceEntry[] { + return []; + } + } + replaceGlobal('performance', { now: () => 0, timeOrigin: 0 }); + replaceGlobal('PerformanceObserver', FakePerformanceObserver); + replaceGlobal('cancelAnimationFrame', () => undefined); + replaceGlobal('requestAnimationFrame', () => nextFrameId++); + replaceGlobal('location', { pathname: '/' }); + replaceGlobal('document', { querySelector: () => null }); + + return { + cleanup: () => { + for (const [key, descriptor] of descriptors) { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + } else { + Reflect.deleteProperty(globalThis, key); + } + } + Reflect.deleteProperty( + globalThis, + Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY) + ); + Reflect.deleteProperty( + globalThis, + M3U_IMPORT_RENDERER_STATE_KEY + ); + }, + page: { + evaluate: async ( + callback: (argument: never) => unknown, + argument: never + ) => callback(argument), + }, + readState: () => + (globalThis as Record)[ + M3U_IMPORT_RENDERER_STATE_KEY + ], + }; +} + +function createFakePage( + browser: ReturnType, + session: FakeCdpSession, + evaluate = browser.page.evaluate +) { + const control = { + click: async () => undefined, + fill: async () => undefined, + first: () => control, + getByRole: () => control, + isDisabled: async () => false, + last: () => control, + waitFor: async () => undefined, + }; + return { + ...browser.page, + context: () => ({ + newCDPSession: async () => session, + }), + evaluate, + getByRole: () => control, + locator: () => control, + }; +} + +function restoreGlobal( + key: 'clearInterval' | 'setInterval', + descriptor: PropertyDescriptor | undefined +): void { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + } else { + Reflect.deleteProperty(globalThis, key); + } +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.spec.ts new file mode 100644 index 000000000..a409caf69 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.spec.ts @@ -0,0 +1,389 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import { RENDERER_PERFORMANCE_PHASE_HOOK_KEY } from '@iptvnator/shared/logging'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +interface ImportProbe { + firstChannelVisibleEpochMs: number | null; + frameGapsMs: readonly number[]; + heartbeatDelaysMs: readonly number[]; + longTasksMs: readonly number[]; + operationStartEpochMs: number; + performancePhaseEvents: readonly unknown[]; + playlistId: string | null; + routeReadyEpochMs: number | null; + terminalEpochMs: number | null; + uiPaintedEpochMs: number | null; +} + +interface RendererCaptureModule { + advanceM3uImportHeartbeatDeadline?: ( + deadlineEpochMs: number, + nowEpochMs: number + ) => number; + assertCompleteM3uImportRendererProbe?: (probe: ImportProbe) => void; + startM3uImportRendererCapture?: unknown; +} + +interface RendererProbeModule { + M3U_IMPORT_RENDERER_STATE_KEY?: string; + installM3uImportRendererProbe?: (page: unknown) => Promise; + recordM3uImportHeartbeat?: ( + page: unknown, + deadlineEpochMs: number + ) => Promise; + stopM3uImportRendererProbe?: (page: unknown) => Promise; +} + +interface FakeProbeState { + frameGapsMs: number[]; + heartbeatDelaysMs: number[]; + operationStartEpochMs: number; + operationStartMonotonicMs: number; + terminalEpochMs: number | null; +} + +const captureUrl = new URL('./m3u-import-renderer-capture.ts', import.meta.url); +const artifactsUrl = new URL( + './m3u-import-renderer-artifacts.ts', + import.meta.url +); +const probeUrl = new URL('./m3u-import-renderer-probe.ts', import.meta.url); +const captureModulePromise = import(captureUrl.href) + .then((module) => module as RendererCaptureModule) + .catch(() => null); +const probeModulePromise = import(probeUrl.href) + .then((module) => module as unknown as RendererProbeModule) + .catch(() => null); + +function installFakeBrowserEnvironment() { + const descriptors = new Map(); + const frames = new Map void>(); + let nextFrameId = 1; + let nowMs = 0; + let observerRecords: PerformanceEntry[] | null = null; + + const replaceGlobal = (key: string, value: unknown): void => { + descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { + configurable: true, + value, + writable: true, + }); + }; + class FakePerformanceObserver { + static readonly supportedEntryTypes = ['longtask']; + + constructor() { + observerRecords = []; + } + + disconnect(): undefined { + return undefined; + } + observe(): undefined { + return undefined; + } + takeRecords(): PerformanceEntry[] { + return observerRecords?.splice(0) ?? []; + } + } + replaceGlobal('performance', { now: () => nowMs, timeOrigin: 0 }); + replaceGlobal('PerformanceObserver', FakePerformanceObserver); + replaceGlobal('cancelAnimationFrame', (requestId: number) => { + frames.delete(requestId); + }); + replaceGlobal( + 'requestAnimationFrame', + (callback: (timestamp: number) => void) => { + const requestId = nextFrameId++; + frames.set(requestId, callback); + return requestId; + } + ); + replaceGlobal('location', { pathname: '/' }); + replaceGlobal('document', { querySelector: () => null }); + + return { + cleanup: () => { + for (const [key, descriptor] of descriptors) { + if (descriptor) { + Object.defineProperty(globalThis, key, descriptor); + } else { + Reflect.deleteProperty(globalThis, key); + } + } + Reflect.deleteProperty( + globalThis, + Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY) + ); + }, + getObserver: () => observerRecords, + page: { + evaluate: async ( + callback: (argument: unknown) => unknown, + argument: unknown + ) => callback(argument), + }, + readState: (stateKey: string) => + (globalThis as Record)[stateKey] as FakeProbeState, + runFrame: (timestamp: number) => { + nowMs = timestamp; + for (const [requestId, callback] of [...frames]) { + if (frames.delete(requestId)) { + callback(timestamp); + } + } + }, + setNow: (value: number) => { + nowMs = value; + }, + showTerminal: () => { + ( + globalThis as unknown as { + location: { pathname: string }; + } + ).location.pathname = + '/Users/test/iptvnator/dist/apps/web/workspace/playlists/synthetic-id/all'; + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { querySelector: () => ({}) }, + writable: true, + }); + }, + }; +} + +function readSource(url: URL): string | null { + try { + return readFileSync(url, 'utf8'); + } catch { + return null; + } +} + +function completeProbe(): ImportProbe { + return { + firstChannelVisibleEpochMs: 130, + frameGapsMs: [16, 48], + heartbeatDelaysMs: [0, 12], + longTasksMs: [72], + operationStartEpochMs: 100, + performancePhaseEvents: [], + playlistId: 'synthetic-id', + routeReadyEpochMs: 120, + terminalEpochMs: 150, + uiPaintedEpochMs: 150, + }; +} + +test('exports the formal M3U import renderer collector', async () => { + const module = await captureModulePromise; + + assert.ok(module, 'M3U import renderer capture module must exist'); + assert.equal(typeof module.startM3uImportRendererCapture, 'function'); + assert.equal( + typeof module.assertCompleteM3uImportRendererProbe, + 'function' + ); +}); + +test('prepares the URL dialog before starting any measured capture', () => { + const source = readSource(captureUrl); + assert.ok(source, 'M3U import renderer capture source must exist'); + + const dialogPreparation = source.indexOf( + 'await prepareM3uImportDialog(page, options)' + ); + const cdpSession = source.indexOf('newCDPSession(page)'); + const initialHeapSample = source.indexOf('await sampleHeap()'); + + assert.ok(dialogPreparation >= 0); + assert.ok(cdpSession > dialogPreparation); + assert.ok(initialHeapSample > cdpSession); +}); + +test('captures heap samples in every run and raw CPU, trace, and heap artifacts only in diagnostics', () => { + const source = readSource(captureUrl); + const artifactSource = readSource(artifactsUrl); + assert.ok(source); + assert.ok(artifactSource); + + assert.match(source, /setInterval\(\(\) => void sampleHeap\(\), 20\)/); + assert.match(source, /Runtime\.getHeapUsage/); + assert.match(source, /HeapProfiler\.collectGarbage/); + assert.match( + artifactSource, + /cpuProfilePath: options\.diagnostic[\s\S]*renderer\.cpuprofile/ + ); + assert.match(artifactSource, /Profiler\.start/); + assert.match(artifactSource, /Tracing\.start/); + assert.match(artifactSource, /HeapProfiler\.takeHeapSnapshot/); +}); + +test('clips drained long tasks and heartbeat samples to the operation window', async () => { + const module = await probeModulePromise; + assert.ok(module); + assert.equal(typeof module.installM3uImportRendererProbe, 'function'); + assert.equal(typeof module.recordM3uImportHeartbeat, 'function'); + assert.equal(typeof module.stopM3uImportRendererProbe, 'function'); + assert.equal(typeof module.M3U_IMPORT_RENDERER_STATE_KEY, 'string'); + const browser = installFakeBrowserEnvironment(); + + try { + await module.installM3uImportRendererProbe?.(browser.page); + const state = browser.readState( + module.M3U_IMPORT_RENDERER_STATE_KEY ?? '' + ); + state.operationStartEpochMs = 100; + state.operationStartMonotonicMs = 100; + state.terminalEpochMs = 200; + const observerRecords = browser.getObserver(); + assert.ok(observerRecords); + observerRecords.push( + { duration: 40, startTime: 80 } as PerformanceEntry, + { duration: 30, startTime: 190 } as PerformanceEntry, + { duration: 30, startTime: 210 } as PerformanceEntry + ); + + browser.setNow(250); + await module.recordM3uImportHeartbeat?.(browser.page, 90); + await module.recordM3uImportHeartbeat?.(browser.page, 150); + await module.recordM3uImportHeartbeat?.(browser.page, 210); + const metrics = await module.stopM3uImportRendererProbe?.(browser.page); + + assert.deepEqual(metrics?.heartbeatDelaysMs, [50]); + assert.deepEqual(metrics?.longTasksMs, [20, 10]); + } finally { + browser.cleanup(); + } +}); + +test('records the terminal animation-frame gap exactly once', async () => { + const module = await probeModulePromise; + assert.ok(module?.installM3uImportRendererProbe); + assert.equal(typeof module.M3U_IMPORT_RENDERER_STATE_KEY, 'string'); + const browser = installFakeBrowserEnvironment(); + + try { + await module.installM3uImportRendererProbe(browser.page); + browser.runFrame(80); + const state = browser.readState( + module.M3U_IMPORT_RENDERER_STATE_KEY ?? '' + ); + state.operationStartEpochMs = 100; + state.operationStartMonotonicMs = 100; + browser.showTerminal(); + browser.runFrame(120); + browser.runFrame(140); + browser.runFrame(160); + + assert.equal(state.terminalEpochMs, 160); + assert.deepEqual(state.frameGapsMs, [20, 20, 20]); + await module.stopM3uImportRendererProbe?.(browser.page); + } finally { + browser.cleanup(); + } +}); + +test('advances the external heartbeat on its fixed 50ms deadline grid', async () => { + const module = await captureModulePromise; + assert.ok(module); + const advance = module.advanceM3uImportHeartbeatDeadline; + assert.equal(typeof advance, 'function'); + + assert.equal(advance?.(1_000, 1_005), 1_050); + assert.equal(advance?.(1_000, 1_060), 1_100); + assert.equal(advance?.(1_000, 1_200), 1_250); +}); + +test('installs the production renderer phase hook and returns its raw events', () => { + const source = readSource(probeUrl); + assert.ok(source); + + assert.match(source, /RENDERER_PERFORMANCE_PHASE_HOOK_KEY/); + assert.match(source, /Symbol\.for\(hookKey\)/); + assert.match(source, /performancePhaseEvents\.push\(event\)/); + assert.match( + source, + /performancePhaseEvents: \[\.\.\.state\.performancePhaseEvents\]/ + ); + assert.match(source, /delete target\[hookSymbol\]/); +}); + +test('timestamps the click immediately before final Add and requires route, first item, and two animation frames', () => { + const source = readSource(probeUrl); + assert.ok(source); + + const operationStart = source.indexOf( + 'state.operationStartMonotonicMs = performance.now()' + ); + const click = source.indexOf('button.click()', operationStart); + assert.ok(operationStart >= 0); + assert.ok(click > operationStart); + assert.ok(source.includes('/\\/workspace\\/playlists\\/([^/]+)\\/all$/')); + assert.match(source, /\[data-test-id="channel-item"\]/); + const firstPaintFrame = source.indexOf( + 'state.uiPaintFrameRequestId = requestAnimationFrame' + ); + const secondPaintFrame = source.indexOf( + 'state.uiPaintFrameRequestId = requestAnimationFrame', + firstPaintFrame + 1 + ); + assert.ok(firstPaintFrame >= 0); + assert.ok(secondPaintFrame > firstPaintFrame); + for (const phase of [ + 'operationStartEpochMs', + 'routeReadyEpochMs', + 'firstChannelVisibleEpochMs', + 'uiPaintedEpochMs', + 'terminalEpochMs', + ]) { + assert.match(source, new RegExp(phase)); + } +}); + +test('fails closed when any terminal proof or the probe itself is missing', async () => { + const module = await captureModulePromise; + assert.ok(module); + const assertComplete = module.assertCompleteM3uImportRendererProbe; + assert.equal(typeof assertComplete, 'function'); + + assert.doesNotThrow(() => assertComplete?.(completeProbe())); + for (const field of [ + 'playlistId', + 'routeReadyEpochMs', + 'firstChannelVisibleEpochMs', + 'uiPaintedEpochMs', + 'terminalEpochMs', + ] as const) { + const incomplete = { ...completeProbe(), [field]: null }; + assert.throws( + () => assertComplete?.(incomplete), + /m3u-import-renderer-probe-incomplete/ + ); + } + assert.throws( + () => + assertComplete?.({ + ...completeProbe(), + operationStartEpochMs: 0, + }), + /m3u-import-renderer-probe-incomplete/ + ); +}); + +test('clears the harness heap sampler even when the renderer probe is missing', () => { + const source = readSource(captureUrl); + assert.ok(source); + const backgroundStop = source.indexOf( + 'await stopBackgroundWork()', + source.indexOf('const stopCapture') + ); + const probeStop = source.indexOf('probe = await stopProbe()', backgroundStop); + + assert.match(source, /clearInterval\(heapSampleTimer\)/); + assert.ok(backgroundStop >= 0); + assert.ok(probeStop > backgroundStop); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.ts new file mode 100644 index 000000000..db5bd80bf --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-capture.ts @@ -0,0 +1,366 @@ +import type { CDPSession, Page } from '@playwright/test'; + +import type { NumericDistribution } from './m3u-refresh-cancellation-contract'; +import { + createRendererArtifactCapture, + disposeRendererDiagnosticCapture, + type RendererArtifactCapture, + startRendererDiagnosticCapture, + stopRendererDiagnosticCapture, + takeRendererHeapSnapshot, +} from './m3u-import-renderer-artifacts'; +import { + installM3uImportRendererProbe, + type M3uImportRendererProbeMetrics, + prepareM3uImportDialog, + recordM3uImportHeartbeat, + stopM3uImportRendererProbe, + triggerM3uImport, + waitForM3uImportTerminal, +} from './m3u-import-renderer-probe'; +import { summarizeNumbers } from './performance-statistics'; + +const HEARTBEAT_INTERVAL_MS = 50; + +export interface M3uImportRendererCaptureStartOptions { + readonly diagnostic: boolean; + readonly outputDirectory: string; + readonly playlistTitle: string; + readonly playlistUrl: string; +} + +export interface M3uImportRendererPhaseTimestamps { + readonly firstChannelVisibleEpochMs: number; + readonly operationStartEpochMs: number; + readonly routeReadyEpochMs: number; + readonly terminalEpochMs: number; + readonly uiPaintedEpochMs: number; +} + +export interface M3uImportRendererCaptureMetrics { + readonly cpuProfilePath: string | null; + readonly frameGap: NumericDistribution; + readonly heapSnapshotPath: string | null; + readonly heartbeatDelay: NumericDistribution; + readonly longTask: NumericDistribution; + readonly peakHeapUsedBytes: number; + readonly phaseTimestamps: M3uImportRendererPhaseTimestamps; + readonly postGcHeapUsedBytes: number; + readonly probe: M3uImportRendererProbeMetrics; + readonly tracePath: string | null; +} + +export interface RunningM3uImportRendererCapture { + readonly session: CDPSession; + dispose(): Promise; + stop(): Promise; + triggerImport(): Promise; + waitForTerminal(): Promise; +} + +interface HeapUsageResult { + readonly usedSize: number; +} + +export async function startM3uImportRendererCapture( + page: Page, + options: M3uImportRendererCaptureStartOptions +): Promise { + await prepareM3uImportDialog(page, options); + const session = await page.context().newCDPSession(page); + let probeInstalled = false; + let heapSampleTimer: NodeJS.Timeout | null = null; + let artifacts: RendererArtifactCapture | null = null; + const heapSamples: number[] = []; + let heapSampleError: unknown = null; + let heapSamplePromise: Promise | null = null; + const sampleHeap = (): Promise => { + heapSamplePromise ??= (async () => { + try { + const sample = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsageResult; + if (Number.isFinite(sample.usedSize)) { + heapSamples.push(sample.usedSize); + } + } catch (error) { + heapSampleError ??= error; + } finally { + heapSamplePromise = null; + } + })(); + return heapSamplePromise; + }; + + try { + await session.send('Runtime.enable'); + await session.send('Performance.enable'); + await installM3uImportRendererProbe(page); + probeInstalled = true; + await sampleHeap(); + throwCaptureError(heapSampleError, 'Renderer heap sampling failed'); + heapSampleTimer = setInterval(() => void sampleHeap(), 20); + artifacts = createRendererArtifactCapture(options); + if (options.diagnostic) { + await startRendererDiagnosticCapture(session, artifacts); + } + } catch (error) { + if (heapSampleTimer) { + clearInterval(heapSampleTimer); + heapSampleTimer = null; + } + if (artifacts && options.diagnostic) { + await disposeRendererDiagnosticCapture( + session, + artifacts + ).catch(() => undefined); + } + if (probeInstalled) { + continueBestEffort(() => stopM3uImportRendererProbe(page)); + } + continueBestEffort(() => session.detach()); + throw error; + } + if (!artifacts || !heapSampleTimer) { + throw new Error('M3U import renderer capture initialization failed'); + } + + let heartbeatDeadlineEpochMs: number | null = null; + let heartbeatError: unknown = null; + let heartbeatInFlight: Promise | null = null; + let heartbeatTimer: NodeJS.Timeout | null = null; + let stopped = false; + let backgroundStopPromise: Promise | null = null; + let probeStopPromise: Promise | null = null; + let sessionDetachPromise: Promise | null = null; + let abortPromise: Promise | null = null; + let stopPromise: Promise | null = null; + let stopRequested = false; + let disposeRequested = false; + const stopSchedulingBackgroundWork = (): void => { + stopped = true; + if (heartbeatTimer) { + clearTimeout(heartbeatTimer); + heartbeatTimer = null; + } + if (heapSampleTimer) { + clearInterval(heapSampleTimer); + heapSampleTimer = null; + } + }; + const scheduleHeartbeat = (): void => { + if (stopped || heartbeatDeadlineEpochMs === null) { + return; + } + const deadline = heartbeatDeadlineEpochMs; + heartbeatTimer = setTimeout( + () => { + heartbeatTimer = null; + heartbeatInFlight = recordM3uImportHeartbeat(page, deadline) + .catch((error: unknown) => { + heartbeatError ??= error; + }) + .finally(() => { + heartbeatInFlight = null; + if (stopped) { + return; + } + heartbeatDeadlineEpochMs = + advanceM3uImportHeartbeatDeadline( + deadline, + Date.now() + ); + scheduleHeartbeat(); + }); + }, + Math.max(0, deadline - Date.now()) + ); + }; + const stopBackgroundWork = (): Promise => { + backgroundStopPromise ??= (async () => { + stopSchedulingBackgroundWork(); + await heartbeatInFlight; + await heapSamplePromise; + await sampleHeap(); + })(); + return backgroundStopPromise; + }; + const stopProbe = (): Promise => { + probeStopPromise ??= stopM3uImportRendererProbe(page); + return probeStopPromise; + }; + const detachSession = (): Promise => { + sessionDetachPromise ??= session.detach(); + return sessionDetachPromise; + }; + const abortResources = (): Promise => { + abortPromise ??= (async () => { + stopSchedulingBackgroundWork(); + continueBestEffort(() => heartbeatInFlight ?? Promise.resolve()); + continueBestEffort(() => heapSamplePromise ?? Promise.resolve()); + continueBestEffort(stopProbe); + if (options.diagnostic) { + await disposeRendererDiagnosticCapture( + session, + artifacts + ).catch(() => undefined); + } + continueBestEffort(detachSession); + })(); + return abortPromise; + }; + const stopCapture = + async (): Promise => { + try { + await stopBackgroundWork(); + let probe: M3uImportRendererProbeMetrics | null = null; + let probeError: unknown = null; + try { + probe = await stopProbe(); + } catch (error) { + probeError = error; + } + + if (options.diagnostic) { + await stopRendererDiagnosticCapture(session, artifacts); + } + await session.send('HeapProfiler.enable'); + await session.send('HeapProfiler.collectGarbage'); + const postGc = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsageResult; + if (options.diagnostic) { + await takeRendererHeapSnapshot( + session, + artifacts.heapSnapshotPath + ); + } + await detachSession(); + throwCaptureError( + heapSampleError, + 'Renderer heap sampling failed' + ); + throwCaptureError(heartbeatError, 'Renderer heartbeat failed'); + throwCaptureError(probeError, 'Renderer probe stop failed'); + assertCompleteM3uImportRendererProbe(probe); + return Object.freeze({ + cpuProfilePath: artifacts.cpuProfilePath, + frameGap: summarizeNumbers(probe.frameGapsMs), + heapSnapshotPath: artifacts.heapSnapshotPath, + heartbeatDelay: summarizeNumbers( + probe.heartbeatDelaysMs + ), + longTask: summarizeNumbers(probe.longTasksMs), + peakHeapUsedBytes: Math.max(0, ...heapSamples), + phaseTimestamps: Object.freeze({ + firstChannelVisibleEpochMs: + probe.firstChannelVisibleEpochMs, + operationStartEpochMs: probe.operationStartEpochMs, + routeReadyEpochMs: probe.routeReadyEpochMs, + terminalEpochMs: probe.terminalEpochMs, + uiPaintedEpochMs: probe.uiPaintedEpochMs, + }), + postGcHeapUsedBytes: postGc.usedSize, + probe, + tracePath: artifacts.tracePath, + }); + } catch (error) { + await abortResources(); + throw error; + } + }; + + return Object.freeze({ + dispose: async () => { + disposeRequested = true; + if (stopPromise) { + continueBestEffort(() => stopPromise as Promise); + } + await abortResources(); + }, + session, + stop: () => { + if (stopRequested || disposeRequested) { + return Promise.reject( + new Error('M3U import renderer capture already stopped') + ); + } + stopRequested = true; + stopPromise = stopCapture(); + return stopPromise; + }, + triggerImport: async () => { + const operationStartEpochMs = await triggerM3uImport(page); + heartbeatDeadlineEpochMs = + operationStartEpochMs + HEARTBEAT_INTERVAL_MS; + scheduleHeartbeat(); + return operationStartEpochMs; + }, + waitForTerminal: () => waitForM3uImportTerminal(page), + }); +} + +export function advanceM3uImportHeartbeatDeadline( + deadlineEpochMs: number, + nowEpochMs: number +): number { + const nextDeadline = deadlineEpochMs + HEARTBEAT_INTERVAL_MS; + if (nextDeadline > nowEpochMs) { + return nextDeadline; + } + const missedIntervals = + Math.floor((nowEpochMs - nextDeadline) / HEARTBEAT_INTERVAL_MS) + 1; + return nextDeadline + missedIntervals * HEARTBEAT_INTERVAL_MS; +} + +export function assertCompleteM3uImportRendererProbe( + probe: M3uImportRendererProbeMetrics | null | undefined +): asserts probe is M3uImportRendererProbeMetrics & { + readonly firstChannelVisibleEpochMs: number; + readonly playlistId: string; + readonly routeReadyEpochMs: number; + readonly terminalEpochMs: number; + readonly uiPaintedEpochMs: number; +} { + if ( + !probe || + typeof probe.playlistId !== 'string' || + probe.playlistId.length === 0 || + !isPositiveFinite(probe.operationStartEpochMs) || + !isPositiveFinite(probe.routeReadyEpochMs) || + !isPositiveFinite(probe.firstChannelVisibleEpochMs) || + !isPositiveFinite(probe.uiPaintedEpochMs) || + !isPositiveFinite(probe.terminalEpochMs) + ) { + throw new Error('m3u-import-renderer-probe-incomplete'); + } + if ( + probe.routeReadyEpochMs < probe.operationStartEpochMs || + probe.firstChannelVisibleEpochMs < probe.routeReadyEpochMs || + probe.uiPaintedEpochMs < probe.firstChannelVisibleEpochMs || + probe.terminalEpochMs < probe.uiPaintedEpochMs + ) { + throw new Error('m3u-import-renderer-probe-incomplete'); + } +} + +function throwCaptureError(error: unknown, label: string): void { + if (error instanceof Error) { + throw error; + } + if (error !== null) { + throw new Error(`${label}: ${String(error)}`); + } +} + +function continueBestEffort(work: () => Promise): void { + try { + void work().catch(() => undefined); + } catch { + // The owning Electron app is closed immediately after abort cleanup. + } +} + +function isPositiveFinite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-probe.ts new file mode 100644 index 000000000..599abb88a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-renderer-probe.ts @@ -0,0 +1,378 @@ +import type { Page } from '@playwright/test'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, + type RendererPerformancePhaseHook, +} from '@iptvnator/shared/logging'; + +export const M3U_IMPORT_RENDERER_STATE_KEY = + '__iptvnatorM3uImportRendererCapture'; + +export interface M3uImportDialogOptions { + readonly playlistTitle: string; + readonly playlistUrl: string; +} + +export interface M3uImportRendererProbeMetrics { + readonly firstChannelVisibleEpochMs: number | null; + readonly frameGapsMs: readonly number[]; + readonly heartbeatDelaysMs: readonly number[]; + readonly longTasksMs: readonly number[]; + readonly operationStartEpochMs: number; + readonly performancePhaseEvents: readonly RendererPerformancePhaseEvent[]; + readonly playlistId: string | null; + readonly routeReadyEpochMs: number | null; + readonly terminalEpochMs: number | null; + readonly uiPaintedEpochMs: number | null; +} + +export async function prepareM3uImportDialog( + page: Page, + options: M3uImportDialogOptions +): Promise { + await page.getByRole('button', { name: 'Add playlist' }).first().click(); + const dialog = page.locator('mat-dialog-container').last(); + await dialog.waitFor({ state: 'visible' }); + await dialog + .getByRole('textbox', { name: 'Playlist URL (m3u, m3u8)' }) + .fill(options.playlistUrl); + await dialog + .getByRole('textbox', { name: 'Playlist title' }) + .fill(options.playlistTitle); + const submit = dialog.getByRole('button', { name: 'Add playlist' }).last(); + await submit.waitFor({ state: 'visible' }); + if (await submit.isDisabled()) { + throw new Error('m3u-import-final-add-disabled'); + } +} + +export async function installM3uImportRendererProbe(page: Page): Promise { + await page.evaluate( + ({ hookKey, stateKey }) => { + interface ProbeState { + firstChannelVisibleEpochMs: number | null; + frameGapsMs: number[]; + frameRequestId: number; + heartbeatDelaysMs: number[]; + lastFrameAt: number | null; + longTaskObserver: PerformanceObserver | null; + longTasksMs: number[]; + operationStartEpochMs: number; + operationStartMonotonicMs: number; + performancePhaseEvents: RendererPerformancePhaseEvent[]; + playlistId: string | null; + recordFrameGap(timestamp: number): void; + recordLongTask(entry: PerformanceEntry): void; + routeReadyEpochMs: number | null; + terminalEpochMs: number | null; + uiPaintFrameRequestId: number | null; + uiPaintedEpochMs: number | null; + } + const target = globalThis as unknown as Record< + PropertyKey, + unknown + >; + const hookSymbol = Symbol.for(hookKey); + if ( + target[stateKey] !== undefined || + target[hookSymbol] !== undefined + ) { + throw new Error('m3u-import-renderer-probe-already-installed'); + } + const epoch = (): number => + performance.timeOrigin + performance.now(); + const state: ProbeState = { + firstChannelVisibleEpochMs: null, + frameGapsMs: [], + frameRequestId: 0, + heartbeatDelaysMs: [], + lastFrameAt: null, + longTaskObserver: null, + longTasksMs: [], + operationStartEpochMs: 0, + operationStartMonotonicMs: 0, + performancePhaseEvents: [], + playlistId: null, + recordFrameGap: (timestamp) => { + const previousFrameAt = state.lastFrameAt; + state.lastFrameAt = timestamp; + if ( + previousFrameAt === null || + state.operationStartEpochMs <= 0 || + state.terminalEpochMs !== null + ) { + return; + } + const measuredFrameStart = Math.max( + previousFrameAt, + state.operationStartMonotonicMs + ); + if (timestamp > measuredFrameStart) { + state.frameGapsMs.push(timestamp - measuredFrameStart); + } + }, + recordLongTask: (entry) => { + const entryEpochMs = + performance.timeOrigin + entry.startTime; + const measuredStartEpochMs = Math.max( + entryEpochMs, + state.operationStartEpochMs + ); + const measuredEndEpochMs = Math.min( + entryEpochMs + entry.duration, + state.terminalEpochMs ?? Number.POSITIVE_INFINITY + ); + if ( + state.operationStartEpochMs > 0 && + measuredEndEpochMs > measuredStartEpochMs + ) { + state.longTasksMs.push( + measuredEndEpochMs - measuredStartEpochMs + ); + } + }, + routeReadyEpochMs: null, + terminalEpochMs: null, + uiPaintFrameRequestId: null, + uiPaintedEpochMs: null, + }; + const performancePhaseHook: RendererPerformancePhaseHook = ( + event + ) => { + state.performancePhaseEvents.push(event); + }; + target[hookSymbol] = performancePhaseHook; + const terminalTarget = (): string | null => { + const route = location.pathname.match( + /\/workspace\/playlists\/([^/]+)\/all$/ + ); + if ( + !route || + !document.querySelector('[data-test-id="channel-item"]') + ) { + return null; + } + return route[1] ?? null; + }; + const inspectTerminal = (): void => { + if ( + state.operationStartEpochMs <= 0 || + state.terminalEpochMs !== null + ) { + return; + } + const route = location.pathname.match( + /\/workspace\/playlists\/([^/]+)\/all$/ + ); + if (!route) { + return; + } + state.routeReadyEpochMs ??= epoch(); + if (!document.querySelector('[data-test-id="channel-item"]')) { + return; + } + state.firstChannelVisibleEpochMs ??= epoch(); + if (state.uiPaintFrameRequestId !== null) { + return; + } + state.uiPaintFrameRequestId = requestAnimationFrame(() => { + state.uiPaintFrameRequestId = requestAnimationFrame( + (terminalFrameAt) => { + state.uiPaintFrameRequestId = null; + const playlistId = terminalTarget(); + if (playlistId === null) { + return; + } + state.recordFrameGap(terminalFrameAt); + state.playlistId = playlistId; + state.uiPaintedEpochMs = epoch(); + state.terminalEpochMs = state.uiPaintedEpochMs; + cancelAnimationFrame(state.frameRequestId); + state.frameRequestId = 0; + for (const entry of state.longTaskObserver?.takeRecords() ?? + []) { + state.recordLongTask(entry); + } + state.longTaskObserver?.disconnect(); + state.longTaskObserver = null; + } + ); + }); + }; + const frame = (timestamp: number): void => { + state.recordFrameGap(timestamp); + if (state.operationStartEpochMs > 0) { + inspectTerminal(); + } + state.frameRequestId = + state.terminalEpochMs === null + ? requestAnimationFrame(frame) + : 0; + }; + state.frameRequestId = requestAnimationFrame(frame); + if ( + typeof PerformanceObserver === 'function' && + PerformanceObserver.supportedEntryTypes.includes('longtask') + ) { + state.longTaskObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + state.recordLongTask(entry); + } + }); + state.longTaskObserver.observe({ entryTypes: ['longtask'] }); + } + target[stateKey] = state; + }, + { + hookKey: RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + stateKey: M3U_IMPORT_RENDERER_STATE_KEY, + } + ); +} + +export async function triggerM3uImport(page: Page): Promise { + return page.evaluate((stateKey) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + | { + operationStartEpochMs: number; + operationStartMonotonicMs: number; + } + | undefined; + if (!state || state.operationStartEpochMs > 0) { + throw new Error('m3u-import-renderer-probe-unavailable'); + } + const dialogs = Array.from( + document.querySelectorAll('mat-dialog-container') + ); + const dialog = dialogs.at(-1); + const button = Array.from(dialog?.querySelectorAll('button') ?? []) + .reverse() + .find( + (candidate) => + candidate.textContent?.trim() === 'Add playlist' && + !candidate.disabled + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error('m3u-import-final-add-unavailable'); + } + state.operationStartMonotonicMs = performance.now(); + state.operationStartEpochMs = + performance.timeOrigin + state.operationStartMonotonicMs; + button.click(); + return state.operationStartEpochMs; + }, M3U_IMPORT_RENDERER_STATE_KEY); +} + +export async function recordM3uImportHeartbeat( + page: Page, + deadlineEpochMs: number +): Promise { + await page.evaluate( + ({ deadlineEpochMs, stateKey }) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + | { + heartbeatDelaysMs: number[]; + operationStartEpochMs: number; + terminalEpochMs: number | null; + } + | undefined; + if (!state || state.operationStartEpochMs <= 0) { + throw new Error('m3u-import-renderer-probe-unavailable'); + } + if ( + deadlineEpochMs >= state.operationStartEpochMs && + (state.terminalEpochMs === null || + deadlineEpochMs <= state.terminalEpochMs) + ) { + const receivedEpochMs = + performance.timeOrigin + performance.now(); + const measuredEpochMs = + state.terminalEpochMs === null + ? receivedEpochMs + : Math.min(receivedEpochMs, state.terminalEpochMs); + state.heartbeatDelaysMs.push( + Math.max(0, measuredEpochMs - deadlineEpochMs) + ); + } + }, + { deadlineEpochMs, stateKey: M3U_IMPORT_RENDERER_STATE_KEY } + ); +} + +export async function waitForM3uImportTerminal(page: Page): Promise { + await page + .waitForFunction( + (stateKey) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + { terminalEpochMs: number | null } | undefined; + return typeof state?.terminalEpochMs === 'number'; + }, + M3U_IMPORT_RENDERER_STATE_KEY, + { timeout: 120_000 } + ) + .then(() => undefined); +} + +export async function stopM3uImportRendererProbe( + page: Page +): Promise { + return page.evaluate( + ({ hookKey, stateKey }) => { + const target = globalThis as unknown as Record< + PropertyKey, + unknown + >; + const hookSymbol = Symbol.for(hookKey); + const state = target[stateKey] as + | { + firstChannelVisibleEpochMs: number | null; + frameGapsMs: number[]; + frameRequestId: number; + heartbeatDelaysMs: number[]; + longTaskObserver: PerformanceObserver | null; + longTasksMs: number[]; + operationStartEpochMs: number; + performancePhaseEvents: RendererPerformancePhaseEvent[]; + playlistId: string | null; + recordLongTask(entry: PerformanceEntry): void; + routeReadyEpochMs: number | null; + terminalEpochMs: number | null; + uiPaintFrameRequestId: number | null; + uiPaintedEpochMs: number | null; + } + | undefined; + if (!state) { + throw new Error('m3u-import-renderer-probe-unavailable'); + } + cancelAnimationFrame(state.frameRequestId); + if (state.uiPaintFrameRequestId !== null) { + cancelAnimationFrame(state.uiPaintFrameRequestId); + } + for (const entry of state.longTaskObserver?.takeRecords() ?? []) { + state.recordLongTask(entry); + } + state.longTaskObserver?.disconnect(); + delete target[stateKey]; + delete target[hookSymbol]; + return { + firstChannelVisibleEpochMs: state.firstChannelVisibleEpochMs, + frameGapsMs: [...state.frameGapsMs], + heartbeatDelaysMs: [...state.heartbeatDelaysMs], + longTasksMs: [...state.longTasksMs], + operationStartEpochMs: state.operationStartEpochMs, + performancePhaseEvents: [...state.performancePhaseEvents], + playlistId: state.playlistId, + routeReadyEpochMs: state.routeReadyEpochMs, + terminalEpochMs: state.terminalEpochMs, + uiPaintedEpochMs: state.uiPaintedEpochMs, + }; + }, + { + hookKey: RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + stateKey: M3U_IMPORT_RENDERER_STATE_KEY, + } + ); +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-report.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-report.spec.ts new file mode 100644 index 000000000..951030aed --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-report.spec.ts @@ -0,0 +1,338 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; +import { deriveM3uImportPhaseMetrics } from './m3u-import-report'; +import { + createTestMainCapture, + createTestRendererCapture, + TEST_M3U_CHANNEL_COUNT, + TEST_M3U_FIXTURE_BYTES, +} from './m3u-import-report.test-helpers'; + +describe('formal M3U import phase report', () => { + it('attributes every required phase without presenting indexes as zero work', () => { + const phases = deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: createTestMainCapture(), + renderer: createTestRendererCapture(), + }); + + assert.deepEqual(phases, { + angularRenderingMs: 10, + dataAcquireMs: 20, + databaseWorkerToMainCloneProxyMs: 12, + indexAndCommitMs: null, + indexAndCommitUnavailableReason: + 'indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write', + ipcStructuredCloneProxyMs: 44, + mainToDatabaseWorkerCloneProxyMs: 2, + mainToRendererCloneProxyMs: 28, + m3uParsingMs: 40, + normalizationMs: 10, + playlistDeserializationMs: 12, + playlistSerializationMs: 20, + rendererToMainCloneProxyMs: 2, + sqliteReadMs: 5, + sqliteWriteMs: 30, + storeImportDispatchMs: 2, + storePublishChannelsMs: 10, + storeUpdateMs: 12, + totalMs: 215, + }); + assert.equal( + phases.ipcStructuredCloneProxyMs, + phases.mainToRendererCloneProxyMs + + phases.rendererToMainCloneProxyMs + + phases.mainToDatabaseWorkerCloneProxyMs + + phases.databaseWorkerToMainCloneProxyMs + ); + }); + + it('fails closed when any structured-clone boundary is missing, ambiguous, or out of order', () => { + const main = createTestMainCapture(); + const databaseWorker = main.workers[0]; + const upsert = databaseWorker?.requests[0]; + assert.ok(databaseWorker); + assert.ok(upsert); + + for (const invalidMain of [ + { + ...main, + timeline: main.timeline.filter( + (event) => event.type !== 'db-request' + ), + }, + { + ...main, + timeline: [ + ...main.timeline, + main.timeline.find((event) => event.type === 'db-request'), + ].filter( + (event): event is (typeof main.timeline)[number] => + event !== undefined + ), + }, + { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + { + ...upsert, + sourceEpochMs: 1080, + }, + ...databaseWorker.requests.slice(1), + ], + }, + ], + }, + { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + { + ...upsert, + requestReceivedEpochMs: 1078, + }, + ...databaseWorker.requests.slice(1), + ], + }, + ], + }, + { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + { + ...upsert, + ipcCallId: null, + operationIdUnavailableReason: + 'preload-performance-marker-missing', + sourceEpochMs: null, + }, + ...databaseWorker.requests.slice(1), + ], + }, + ], + }, + { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + { + ...upsert, + workStartedEpochMs: 1079, + }, + ...databaseWorker.requests.slice(1), + ], + }, + ], + }, + ]) { + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: invalidMain, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + } + }); + + it('fails closed when a required phase, worker identity, or fixture count is ambiguous', () => { + const missingNormalize = createTestMainCapture(); + const missingTimeline = missingNormalize.timeline.filter( + (event) => event.phase !== 'normalize' + ); + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: { + ...missingNormalize, + timeline: missingTimeline, + }, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: { + ...createTestMainCapture(), + workers: [ + ...createTestMainCapture().workers, + { + ...createTestMainCapture().workers[0], + ordinal: 2, + }, + ], + }, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + + const wrongBytes = createTestMainCapture(); + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES + 1, + main: wrongBytes, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + }); + + it('requires the route-reload GET and both worker-to-renderer return paths', () => { + const main = createTestMainCapture(); + const databaseWorker = main.workers[0]; + const upsert = databaseWorker?.requests[0]; + const get = databaseWorker?.requests[1]; + assert.ok(databaseWorker); + assert.ok(upsert); + assert.ok(get); + + const invalidCaptures: MainCaptureMetrics[] = [ + { + ...main, + workers: [ + { + ...databaseWorker, + requests: databaseWorker.requests.slice(0, 1), + }, + ], + }, + { + ...main, + timeline: main.timeline.filter( + (event) => + !( + event.type === 'preload-performance-success' && + event.operation === 'DB_GET_APP_PLAYLIST' + ) + ), + }, + { + ...main, + timeline: main.timeline.filter( + (event) => + !( + event.type === 'preload-performance-success' && + event.operation === 'DB_UPSERT_APP_PLAYLIST' + ) + ), + }, + { + ...main, + timeline: main.timeline.map((event) => + event.type === 'preload-performance-success' && + event.operation === 'DB_GET_APP_PLAYLIST' + ? { + ...event, + sourceEpochMs: 1196, + } + : event + ), + }, + { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + upsert, + { + ...get, + responsePostedEpochMs: 1175, + }, + ], + }, + ], + }, + ]; + + for (const invalidMain of invalidCaptures) { + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: invalidMain, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + } + }); + + it('rejects playlist workers and cross-playlist database writes', () => { + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: { + ...createTestMainCapture(), + workers: [ + ...createTestMainCapture().workers, + { + kind: 'playlist-refresh.worker', + requests: [], + }, + ], + } as MainCaptureMetrics, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + + const main = createTestMainCapture(); + const databaseWorker = main.workers[0]; + assert.ok(databaseWorker); + const upsert = databaseWorker.requests[0]; + assert.ok(upsert); + assert.throws( + () => + deriveM3uImportPhaseMetrics({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + main: { + ...main, + workers: [ + { + ...databaseWorker, + requests: [ + { + ...upsert, + playlistId: 'other-playlist', + }, + ], + }, + ], + }, + renderer: createTestRendererCapture(), + }), + /m3u-import-phase-attribution-invalid/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts b/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts new file mode 100644 index 000000000..e00b7e637 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-report.test-helpers.ts @@ -0,0 +1,458 @@ +/* eslint-disable max-lines -- The deterministic fixture keeps the full production-shaped import sequence visible. */ +import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture'; +import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; +import type { M3uImportBenchmarkManifest } from './m3u-import-summary'; + +export const TEST_M3U_CHANNEL_COUNT = 10_000; +export const TEST_M3U_FIXTURE_BYTES = 1_024_000; + +export function createTestMainCapture(): MainCaptureMetrics { + return { + timeline: [ + mainPhase('start', 'data.acquire', 1001, null), + mainPhase('end', 'data.acquire', 1021, 20, { + byteCount: TEST_M3U_FIXTURE_BYTES, + }), + mainPhase('start', 'parse.m3u', 1021, null), + mainPhase('end', 'parse.m3u', 1061, 40, { + itemCount: TEST_M3U_CHANNEL_COUNT, + }), + mainPhase('start', 'normalize', 1061, null), + mainPhase('end', 'normalize', 1071, 10, { + itemCount: TEST_M3U_CHANNEL_COUNT, + }), + { + epochMs: 1080, + ipcCallId: 7, + operation: 'DB_UPSERT_APP_PLAYLIST', + playlistId: 'playlist-1', + requestId: 'db-request-1', + sourceEpochMs: 1079, + type: 'db-request', + }, + { + epochMs: 1137, + operation: 'DB_UPSERT_APP_PLAYLIST', + playlistId: 'playlist-1', + requestId: 'db-request-1', + success: true, + type: 'db-response', + }, + { + epochMs: 1140, + ipcCallId: 7, + operation: 'DB_UPSERT_APP_PLAYLIST', + playlistId: 'playlist-1', + sourceEpochMs: 1140, + success: true, + type: 'preload-performance-success', + }, + { + epochMs: 1142, + ipcCallId: 8, + operation: 'DB_GET_APP_PLAYLIST', + playlistId: 'playlist-1', + requestId: 'db-request-2', + sourceEpochMs: 1141, + type: 'db-request', + }, + { + epochMs: 1174, + operation: 'DB_GET_APP_PLAYLIST', + playlistId: 'playlist-1', + requestId: 'db-request-2', + success: true, + type: 'db-response', + }, + { + epochMs: 1194, + ipcCallId: 8, + operation: 'DB_GET_APP_PLAYLIST', + playlistId: 'playlist-1', + sourceEpochMs: 1194, + success: true, + type: 'preload-performance-success', + }, + ], + workers: [ + { + kind: 'database.worker', + requests: [ + { + ipcCallId: 7, + operation: 'DB_UPSERT_APP_PLAYLIST', + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-uncorrelated', + performanceCaptureUnavailableReason: null, + phaseEvents: [ + workerPhase( + 'start', + 'serialize.playlist', + 1082, + null, + 'db-request-1' + ), + workerPhase( + 'end', + 'serialize.playlist', + 1102, + 20, + 'db-request-1', + { + itemCount: TEST_M3U_CHANNEL_COUNT, + } + ), + workerPhase( + 'start', + 'sqlite.write', + 1102, + null, + 'db-request-1' + ), + workerPhase( + 'end', + 'sqlite.write', + 1132, + 30, + 'db-request-1', + { + itemCount: 1, + } + ), + ], + playlistId: 'playlist-1', + requestId: 'db-request-1', + requestReceivedEpochMs: 1081, + responseEpochMs: 1137, + responsePostedEpochMs: 1135, + sourceEpochMs: 1079, + success: true, + workEndedEpochMs: 1133, + workStartedEpochMs: 1082, + }, + { + ipcCallId: 8, + operation: 'DB_GET_APP_PLAYLIST', + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-uncorrelated', + performanceCaptureUnavailableReason: null, + phaseEvents: [ + workerPhase( + 'start', + 'sqlite.read', + 1144, + null, + 'db-request-2' + ), + workerPhase( + 'end', + 'sqlite.read', + 1149, + 5, + 'db-request-2', + { + itemCount: 1, + } + ), + workerPhase( + 'start', + 'deserialize.playlist', + 1149, + null, + 'db-request-2' + ), + workerPhase( + 'end', + 'deserialize.playlist', + 1161, + 12, + 'db-request-2', + { + itemCount: TEST_M3U_CHANNEL_COUNT, + } + ), + ], + playlistId: 'playlist-1', + requestId: 'db-request-2', + requestReceivedEpochMs: 1143, + responseEpochMs: 1174, + responsePostedEpochMs: 1164, + sourceEpochMs: 1141, + success: true, + workEndedEpochMs: 1162, + workStartedEpochMs: 1144, + }, + ], + }, + ], + } as unknown as MainCaptureMetrics; +} + +export function createTestRendererCapture(): M3uImportRendererCaptureMetrics { + return { + phaseTimestamps: { + firstChannelVisibleEpochMs: 1210, + operationStartEpochMs: 1000, + routeReadyEpochMs: 1140, + terminalEpochMs: 1215, + uiPaintedEpochMs: 1215, + }, + probe: { + performancePhaseEvents: [ + rendererPhase( + 'start', + 'store.m3u-import-dispatch', + 'renderer-1', + 1076, + 76 + ), + rendererPhase( + 'end', + 'store.m3u-import-dispatch', + 'renderer-1', + 1078, + 78 + ), + rendererPhase( + 'start', + 'store.m3u-publish-channels', + 'renderer-2', + 1195, + 195, + TEST_M3U_CHANNEL_COUNT + ), + rendererPhase( + 'end', + 'store.m3u-publish-channels', + 'renderer-2', + 1205, + 205, + TEST_M3U_CHANNEL_COUNT + ), + ], + playlistId: 'playlist-1', + }, + } as unknown as M3uImportRendererCaptureMetrics; +} + +export function createCompleteTestMainCapture( + overrides: { mainRss?: number; rendererRss?: number } = {} +): MainCaptureMetrics { + const base = createTestMainCapture(); + const databaseWorker = base.workers[0]; + const upsert = databaseWorker?.requests[0]; + const get = databaseWorker?.requests[1]; + if (!databaseWorker || !upsert || !get) { + throw new Error('invalid-test-main-capture'); + } + return { + ...base, + cpuProfilePath: null, + cpuSystemMicros: 20, + cpuUserMicros: 30, + eventLoopDelay: { maxMs: 3, p95Ms: 1, p99Ms: 2 }, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + 'electron-main-embedded-event-loop', + heapSnapshotPath: null, + memory: { + peakHeapUsedBytes: 2_000, + peakRssBytes: overrides.mainRss ?? 5_000, + postGcHeapUsedBytes: 1_000, + postGcRssBytes: 4_500, + }, + rendererWindow: { + responsiveEvents: 0, + rss: { + identity: { creationTime: 100, pid: 42 }, + missingSampleCount: 0, + peakRssBytes: overrides.rendererRss ?? 4_000, + unavailableReason: null, + validSampleCount: 5, + }, + unresponsiveEvents: 0, + windowIdentity: { + browserWindowId: 1, + webContentsId: 2, + }, + }, + rssScope: + 'electron-main-process-including-worker-threads-and-native-memory', + workers: [ + { + ...databaseWorker, + externalMemorySampleCount: 4, + heapUsedSampleCount: 4, + peakExternalBytes: 600, + peakExternalUnavailableReason: null, + peakHeapUsedBytes: 1_500, + peakHeapUsedUnavailableReason: null, + postGcHeapUnavailableReason: null, + postGcHeapUsedBytes: 900, + requests: [ + { + ...upsert, + eventLoopDelay: { maxMs: 6, p95Ms: 4, p99Ms: 5 }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.8, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 1134, + invalidReason: null, + threadCpuSystemMicros: 40, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 50, + }, + { + ...get, + eventLoopDelay: { maxMs: 9, p95Ms: 7, p99Ms: 8 }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.9, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 1163, + invalidReason: null, + threadCpuSystemMicros: 60, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 70, + }, + ], + }, + ], + }; +} + +export function createCompleteTestRendererCapture( + overrides: { rendererHeap?: number } = {} +): M3uImportRendererCaptureMetrics { + const base = createTestRendererCapture(); + return { + ...base, + cpuProfilePath: null, + frameGap: distribution([16, 40]), + heapSnapshotPath: null, + heartbeatDelay: distribution([0, 5]), + longTask: distribution([60]), + peakHeapUsedBytes: overrides.rendererHeap ?? 3_000, + postGcHeapUsedBytes: 1_100, + probe: { + ...base.probe, + firstChannelVisibleEpochMs: 1210, + frameGapsMs: [16, 40], + heartbeatDelaysMs: [0, 5], + longTasksMs: [60], + operationStartEpochMs: 1000, + routeReadyEpochMs: 1140, + terminalEpochMs: 1215, + uiPaintedEpochMs: 1215, + }, + tracePath: null, + }; +} + +export function createTestM3uImportManifest(): M3uImportBenchmarkManifest { + return { + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + fixtureSha256: 'fixture-sha', + gitCommit: 'commit', + gitDiffSha256: 'diff', + gitDirty: false, + measuredRuns: 1, + memoryAccounting: { + databaseWorkerRss: 'unavailable-per-worker-thread', + mainRss: + 'electron-main-process-including-worker-threads-and-native-memory', + nativeAndSqliteMemory: + 'included-in-main-rss-not-separately-attributable', + rendererRss: 'separate-renderer-process', + workerJsHeap: 'separate-v8-isolate', + }, + runtime: { + arch: 'arm64', + cdpPort: 9222, + electronVersion: '1', + harnessNodeVersion: '1', + platform: 'darwin', + }, + scenario: 'm3u-import-10k', + syntheticSeed: 240_724, + variant: 'baseline', + warmupRuns: 1, + }; +} + +function mainPhase( + boundary: 'end' | 'start', + phase: string, + epochMs: number, + durationMs: number | null, + metadata: { byteCount?: number; itemCount?: number } = {} +) { + return { + boundary, + durationMs, + epochMs, + ...metadata, + phase, + requestId: 'main-request-1', + type: 'm3u-import-phase', + }; +} + +function workerPhase( + boundary: 'end' | 'start', + phase: + | 'deserialize.playlist' + | 'serialize.playlist' + | 'sqlite.read' + | 'sqlite.write', + epochMs: number, + durationMs: number | null, + requestId: string, + metadata?: { byteCount?: number; itemCount?: number } +) { + return { + boundary, + durationMs, + epochMs, + ...(metadata ? { metadata } : {}), + phase, + requestId, + }; +} + +function rendererPhase( + boundary: 'end' | 'start', + phase: 'store.m3u-import-dispatch' | 'store.m3u-publish-channels', + phaseId: string, + epochMs: number, + monotonicMs: number, + items?: number +) { + return { + boundary, + epochMs, + ...(items === undefined ? {} : { metadata: { items } }), + monotonicMs, + ...(boundary === 'end' ? { outcome: 'success' as const } : {}), + phase, + phaseId, + schemaVersion: 1 as const, + }; +} + +function distribution(values: readonly number[]) { + const min = Math.min(...values); + const max = Math.max(...values); + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + return { + count: values.length, + max, + mean, + median: mean, + min, + p95: values.length === 1 ? max : min + (max - min) * 0.95, + p99: values.length === 1 ? max : min + (max - min) * 0.99, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-report.ts b/apps/electron-backend-e2e/src/performance/m3u-import-report.ts new file mode 100644 index 000000000..9fb2fe9a3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-report.ts @@ -0,0 +1,367 @@ +import { + APP_PLAYLIST_GET_PERFORMANCE_PHASE, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + M3U_IMPORT_PERFORMANCE_PHASE, +} from '@iptvnator/shared/interfaces'; + +import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture'; +import type { + MainCaptureMetrics, + MainTimelineRecord, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { deriveM3uImportIpcAttribution } from './m3u-import-ipc-attribution'; +import { + pairPerformancePhaseEvents, + requirePerformancePhase, + type PairedPerformancePhase, +} from './performance-phase-events'; +import { + pairRendererPerformancePhaseEvents, + requireRendererPerformancePhase, +} from './renderer-performance-phase-events'; + +export const M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON = + 'indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write'; + +export interface M3uImportPhaseMetrics { + readonly angularRenderingMs: number; + readonly dataAcquireMs: number; + readonly databaseWorkerToMainCloneProxyMs: number; + readonly indexAndCommitMs: null; + readonly indexAndCommitUnavailableReason: typeof M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON; + readonly ipcStructuredCloneProxyMs: number; + readonly mainToDatabaseWorkerCloneProxyMs: number; + readonly mainToRendererCloneProxyMs: number; + readonly m3uParsingMs: number; + readonly normalizationMs: number; + readonly playlistDeserializationMs: number; + readonly playlistSerializationMs: number; + readonly rendererToMainCloneProxyMs: number; + readonly sqliteReadMs: number; + readonly sqliteWriteMs: number; + readonly storeImportDispatchMs: number; + readonly storePublishChannelsMs: number; + readonly storeUpdateMs: number; + readonly totalMs: number; +} + +export interface M3uImportPhaseDerivationInput { + readonly channelCount: number; + readonly fixtureBytes: number; + readonly main: MainCaptureMetrics; + readonly renderer: M3uImportRendererCaptureMetrics; +} + +export function deriveM3uImportPhaseMetrics( + input: M3uImportPhaseDerivationInput +): M3uImportPhaseMetrics { + try { + return deriveM3uImportPhaseMetricsStrict(input); + } catch (error: unknown) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`m3u-import-phase-attribution-invalid:${detail}`); + } +} + +function deriveM3uImportPhaseMetricsStrict( + input: M3uImportPhaseDerivationInput +): M3uImportPhaseMetrics { + requirePositiveSafeCount(input.channelCount, 'channel-count'); + requirePositiveSafeCount(input.fixtureBytes, 'fixture-bytes'); + + const mainPhases = pairPerformancePhaseEvents( + input.main.timeline + .filter((event) => event.type === 'm3u-import-phase') + .map(toPerformancePhaseEvent) + ); + if (mainPhases.length !== 3) { + throw new Error('main-phase-cardinality'); + } + const mainRequestIds = new Set(mainPhases.map((phase) => phase.requestId)); + if (mainRequestIds.size !== 1) { + throw new Error('main-request-correlation'); + } + const mainRequestId = mainPhases[0]?.requestId; + if (!mainRequestId) { + throw new Error('main-request-missing'); + } + const dataAcquire = requirePerformancePhase( + mainPhases, + mainRequestId, + M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE + ); + const parseM3u = requirePerformancePhase( + mainPhases, + mainRequestId, + M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U + ); + const normalize = requirePerformancePhase( + mainPhases, + mainRequestId, + M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE + ); + requireMetadataCount( + dataAcquire, + 'byteCount', + input.fixtureBytes, + 'data-acquire-bytes' + ); + requireMetadataCount( + parseM3u, + 'itemCount', + input.channelCount, + 'parse-items' + ); + requireMetadataCount( + normalize, + 'itemCount', + input.channelCount, + 'normalize-items' + ); + requireOrdered(dataAcquire.endedEpochMs, parseM3u.startedEpochMs); + requireOrdered(parseM3u.endedEpochMs, normalize.startedEpochMs); + + const rendererEvents = ( + input.renderer.probe as typeof input.renderer.probe & { + readonly performancePhaseEvents?: readonly unknown[]; + } + ).performancePhaseEvents; + if (!rendererEvents) { + throw new Error('renderer-phase-events-missing'); + } + const rendererPhases = pairRendererPerformancePhaseEvents(rendererEvents); + if (rendererPhases.length !== 2) { + throw new Error('renderer-phase-cardinality'); + } + const importDispatch = requireRendererPerformancePhase( + rendererPhases, + 'store.m3u-import-dispatch' + ); + const publishChannels = requireRendererPerformancePhase( + rendererPhases, + 'store.m3u-publish-channels' + ); + requireRendererItems( + publishChannels.metadata?.items, + input.channelCount, + 'publish-channels-items' + ); + + const playlistId = input.renderer.probe.playlistId; + if (typeof playlistId !== 'string' || playlistId.length === 0) { + throw new Error('renderer-playlist-id-missing'); + } + const databaseWorkers = input.main.workers.filter( + (worker) => worker.kind === 'database.worker' + ); + if ( + databaseWorkers.length !== 1 || + input.main.workers.some( + (worker) => worker.kind === 'playlist-refresh.worker' + ) + ) { + throw new Error('worker-cardinality'); + } + const databaseWorker = databaseWorkers[0]; + if (!databaseWorker) { + throw new Error('database-worker-missing'); + } + const upserts = databaseWorker.requests.filter( + (request) => + request.operation === 'DB_UPSERT_APP_PLAYLIST' && + request.playlistId === playlistId && + request.success + ); + if (upserts.length !== 1 || !upserts[0]) { + throw new Error('database-upsert-correlation'); + } + const upsert = upserts[0]; + const gets = databaseWorker.requests.filter( + (request) => + request.operation === 'DB_GET_APP_PLAYLIST' && + request.playlistId === playlistId && + request.success + ); + if ( + gets.length !== 1 || + !gets[0] || + databaseWorker.requests.length !== 2 + ) { + throw new Error('database-get-correlation'); + } + const get = gets[0]; + const databasePhases = pairPerformancePhaseEvents(upsert.phaseEvents); + if ( + databasePhases.length !== 2 || + !upsert.requestId || + databasePhases.some((phase) => phase.requestId !== upsert.requestId) + ) { + throw new Error('database-phase-cardinality'); + } + const serializePlaylist = requireDatabasePhase( + databasePhases, + upsert, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST + ); + const sqliteWrite = requireDatabasePhase( + databasePhases, + upsert, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE + ); + requireMetadataCount( + serializePlaylist, + 'itemCount', + input.channelCount, + 'serialization-items' + ); + requireMetadataCount(sqliteWrite, 'itemCount', 1, 'sqlite-row-count'); + const getPhases = pairPerformancePhaseEvents(get.phaseEvents); + if ( + getPhases.length !== 2 || + !get.requestId || + getPhases.some((phase) => phase.requestId !== get.requestId) + ) { + throw new Error('database-get-phase-cardinality'); + } + const sqliteRead = requireDatabasePhase( + getPhases, + get, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ + ); + const deserializePlaylist = requireDatabasePhase( + getPhases, + get, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST + ); + requireMetadataCount(sqliteRead, 'itemCount', 1, 'sqlite-read-row-count'); + requireMetadataCount( + deserializePlaylist, + 'itemCount', + input.channelCount, + 'deserialization-items' + ); + + const operationStart = input.renderer.phaseTimestamps.operationStartEpochMs; + const uiPainted = input.renderer.phaseTimestamps.uiPaintedEpochMs; + const terminal = input.renderer.phaseTimestamps.terminalEpochMs; + requireOrdered(operationStart, dataAcquire.startedEpochMs); + requireOrdered(serializePlaylist.endedEpochMs, sqliteWrite.startedEpochMs); + requireOrdered(sqliteWrite.endedEpochMs, sqliteRead.startedEpochMs); + requireOrdered(sqliteRead.endedEpochMs, deserializePlaylist.startedEpochMs); + requireOrdered( + deserializePlaylist.endedEpochMs, + publishChannels.startedEpochMs + ); + requireOrdered(publishChannels.endedEpochMs, uiPainted); + requireOrdered(uiPainted, terminal); + + const ipc = deriveM3uImportIpcAttribution({ + get, + getReadStartedEpochMs: sqliteRead.startedEpochMs, + importDispatchEndedEpochMs: importDispatch.endedEpochMs, + importDispatchStartedEpochMs: importDispatch.startedEpochMs, + normalizeEndedEpochMs: normalize.endedEpochMs, + playlistId, + publishChannelsStartedEpochMs: publishChannels.startedEpochMs, + serializeStartedEpochMs: serializePlaylist.startedEpochMs, + timeline: input.main.timeline, + upsert, + }); + const angularRenderingMs = difference( + uiPainted, + publishChannels.endedEpochMs + ); + const totalMs = difference(terminal, operationStart); + + return Object.freeze({ + angularRenderingMs, + dataAcquireMs: dataAcquire.durationMs, + indexAndCommitMs: null, + indexAndCommitUnavailableReason: + M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON, + ...ipc, + m3uParsingMs: parseM3u.durationMs, + normalizationMs: normalize.durationMs, + playlistDeserializationMs: deserializePlaylist.durationMs, + playlistSerializationMs: serializePlaylist.durationMs, + sqliteReadMs: sqliteRead.durationMs, + sqliteWriteMs: sqliteWrite.durationMs, + storeImportDispatchMs: importDispatch.durationMs, + storePublishChannelsMs: publishChannels.durationMs, + storeUpdateMs: importDispatch.durationMs + publishChannels.durationMs, + totalMs, + }); +} + +function toPerformancePhaseEvent(event: MainTimelineRecord): unknown { + const metadata = { + ...(event.byteCount === undefined + ? {} + : { byteCount: event.byteCount }), + ...(event.itemCount === undefined + ? {} + : { itemCount: event.itemCount }), + }; + return { + boundary: event.boundary, + durationMs: event.durationMs, + epochMs: event.epochMs, + ...(Object.keys(metadata).length === 0 ? {} : { metadata }), + phase: event.phase, + requestId: event.requestId, + }; +} + +function requireDatabasePhase( + phases: readonly PairedPerformancePhase[], + request: WorkerRequestPerformanceMetrics, + phase: string +): PairedPerformancePhase { + if (!request.requestId) { + throw new Error('database-request-id-missing'); + } + return requirePerformancePhase(phases, request.requestId, phase); +} + +function requireMetadataCount( + phase: PairedPerformancePhase, + key: 'byteCount' | 'itemCount', + expected: number, + label: string +): void { + if (phase.metadata?.[key] !== expected) { + throw new Error(label); + } +} + +function requireRendererItems( + value: number | undefined, + expected: number, + label: string +): void { + if (value !== expected) { + throw new Error(label); + } +} + +function requirePositiveSafeCount(value: number, label: string): void { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(label); + } +} + +function requireOrdered(startEpochMs: number, endEpochMs: number): void { + if ( + !Number.isFinite(startEpochMs) || + !Number.isFinite(endEpochMs) || + endEpochMs < startEpochMs + ) { + throw new Error('phase-order'); + } +} + +function difference(endEpochMs: number, startEpochMs: number): number { + requireOrdered(startEpochMs, endEpochMs); + return endEpochMs - startEpochMs; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-summary-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-import-summary-contract.ts new file mode 100644 index 000000000..6f843f8b2 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-summary-contract.ts @@ -0,0 +1,131 @@ +import type { DatabaseWorkerPeakMemoryValidity } from './database-worker-peak-memory-validity'; +import type { DatabaseWorkerRequestMetricsValidity } from './database-worker-request-metrics-validity'; +import type { M3uImportScenarioId } from './m3u-import-benchmark-contract'; +import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture'; +import { + M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON, + type M3uImportPhaseMetrics, +} from './m3u-import-report'; +import type { + DatabaseWorkerPostGcValidity, + MainCaptureMetrics, + NumericDistribution, + PerformanceIterationKind, +} from './m3u-refresh-cancellation-contract'; +import type { RendererRssValidity } from './renderer-rss-validity'; + +export interface M3uImportIterationResult { + readonly channelCount: number; + readonly fixtureBytes: number; + readonly kind: PerformanceIterationKind; + readonly main: MainCaptureMetrics; + readonly phases: M3uImportPhaseMetrics; + readonly renderer: M3uImportRendererCaptureMetrics; + readonly runId: string; + readonly scenarioId: M3uImportScenarioId; +} + +export interface M3uImportBenchmarkManifest { + readonly channelCount: number; + readonly fixtureBytes: number; + readonly fixtureSha256: string; + readonly gitCommit: string; + readonly gitDiffSha256: string; + readonly gitDirty: boolean; + readonly measuredRuns: number; + readonly memoryAccounting: { + readonly databaseWorkerRss: 'unavailable-per-worker-thread'; + readonly mainRss: 'electron-main-process-including-worker-threads-and-native-memory'; + readonly nativeAndSqliteMemory: 'included-in-main-rss-not-separately-attributable'; + readonly rendererRss: 'separate-renderer-process'; + readonly workerJsHeap: 'separate-v8-isolate'; + }; + readonly runtime: { + readonly arch: string; + readonly cdpPort: number; + readonly electronVersion: string; + readonly harnessNodeVersion: string; + readonly platform: string; + }; + readonly scenario: M3uImportScenarioId; + readonly syntheticSeed: number; + readonly variant: string; + readonly warmupRuns: number; +} + +export interface M3uImportMeasuredSummary { + readonly angularRenderingMs: NumericDistribution; + readonly dataAcquireMs: NumericDistribution; + readonly databaseWorkerGetEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerGetEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerGetEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerGetEventLoopUtilization: NumericDistribution; + readonly databaseWorkerGetThreadCpuSystemMicros: NumericDistribution; + readonly databaseWorkerGetThreadCpuUserMicros: NumericDistribution; + readonly databaseWorkerEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerEventLoopUtilization: NumericDistribution; + readonly databaseWorkerExternalPeakBytes: NumericDistribution; + readonly databaseWorkerHeapPeakBytes: NumericDistribution; + readonly databaseWorkerPostGcHeapBytes: NumericDistribution; + readonly databaseWorkerThreadCpuSystemMicros: NumericDistribution; + readonly databaseWorkerThreadCpuUserMicros: NumericDistribution; + readonly databaseWorkerToMainCloneProxyMs: NumericDistribution; + readonly databaseWorkerUpsertEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerUpsertEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerUpsertEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerUpsertEventLoopUtilization: NumericDistribution; + readonly databaseWorkerUpsertThreadCpuSystemMicros: NumericDistribution; + readonly databaseWorkerUpsertThreadCpuUserMicros: NumericDistribution; + readonly ipcStructuredCloneProxyMs: NumericDistribution; + readonly mainToDatabaseWorkerCloneProxyMs: NumericDistribution; + readonly mainToRendererCloneProxyMs: NumericDistribution; + readonly m3uParsingMs: NumericDistribution; + readonly mainEventLoopDelayMaxMs: NumericDistribution; + readonly mainEventLoopDelayP95Ms: NumericDistribution; + readonly mainEventLoopDelayP99Ms: NumericDistribution; + readonly mainEventLoopUtilization: NumericDistribution; + readonly mainHeapPeakBytes: NumericDistribution; + readonly mainHeapPostGcBytes: NumericDistribution; + readonly mainRssPeakBytes: NumericDistribution; + readonly mainRssPostGcBytes: NumericDistribution; + readonly normalizationMs: NumericDistribution; + readonly playlistDeserializationMs: NumericDistribution; + readonly playlistSerializationMs: NumericDistribution; + readonly rendererFrameGapMs: NumericDistribution; + readonly rendererHeartbeatDelayMs: NumericDistribution; + readonly rendererHeapPeakBytes: NumericDistribution; + readonly rendererHeapPostGcBytes: NumericDistribution; + readonly rendererLongTaskCount: number; + readonly rendererLongTaskMs: NumericDistribution; + readonly rendererRssPeakBytes: NumericDistribution; + readonly rendererToMainCloneProxyMs: NumericDistribution; + readonly responsiveEvents: number; + readonly sqliteReadMs: NumericDistribution; + readonly sqliteWriteMs: NumericDistribution; + readonly storeImportDispatchMs: NumericDistribution; + readonly storePublishChannelsMs: NumericDistribution; + readonly storeUpdateMs: NumericDistribution; + readonly totalMs: NumericDistribution; + readonly unresponsiveEvents: number; +} + +export interface M3uImportBenchmarkSummary { + readonly iterations: readonly M3uImportIterationResult[]; + readonly manifest: M3uImportBenchmarkManifest; + readonly measured: M3uImportMeasuredSummary; + readonly notApplicable: { + readonly cancelAcknowledgementLatencyMs: 'scenario-has-no-cancellation'; + readonly databaseWorkerRss: 'unavailable-per-worker-thread'; + readonly indexAndCommitMs: typeof M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON; + readonly nativeAndSqliteMemory: 'included-in-main-rss-not-separately-attributable'; + readonly playlistRefreshWorker: 'not-used-by-initial-import'; + }; + readonly validity: { + readonly databaseWorkerPeakMemory: DatabaseWorkerPeakMemoryValidity; + readonly databaseWorkerPostGc: DatabaseWorkerPostGcValidity; + readonly databaseWorkerRequestMetrics: DatabaseWorkerRequestMetricsValidity; + readonly rendererRss: RendererRssValidity; + }; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-summary.spec.ts b/apps/electron-backend-e2e/src/performance/m3u-import-summary.spec.ts new file mode 100644 index 000000000..558b33365 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-summary.spec.ts @@ -0,0 +1,247 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { it } from 'node:test'; + +import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract'; +import { + createM3uImportBenchmarkSummary, + createM3uImportIterationResult, + type M3uImportBenchmarkSummary, +} from './m3u-import-summary'; +import { assertM3uImportComparisonValidity } from './m3u-import.benchmark'; +import { + createCompleteTestMainCapture, + createCompleteTestRendererCapture, + createTestM3uImportManifest, + TEST_M3U_CHANNEL_COUNT, + TEST_M3U_FIXTURE_BYTES, +} from './m3u-import-report.test-helpers'; + +it('keeps summary aggregation below the hard line limit without a suppression', () => { + const source = readFileSync( + resolve(process.cwd(), 'src/performance/m3u-import-summary.ts'), + 'utf8' + ); + + assert.doesNotMatch(source, /eslint-disable max-lines/); + assert.ok(source.split(/\r?\n/).length <= 400); +}); + +it('summarizes measured runs only while preserving per-process memory boundaries', () => { + const warmup = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.WARMUP, + main: createCompleteTestMainCapture(), + renderer: createCompleteTestRendererCapture(), + runId: 'warmup-01', + scenarioId: 'm3u-import-10k', + }); + const measured = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: createCompleteTestMainCapture(), + renderer: createCompleteTestRendererCapture(), + runId: 'run-01', + scenarioId: 'm3u-import-10k', + }); + const diagnostic = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + main: createCompleteTestMainCapture({ + mainRss: 9_999, + rendererRss: 8_888, + }), + renderer: createCompleteTestRendererCapture({ + rendererHeap: 7_777, + }), + runId: 'diagnostic', + scenarioId: 'm3u-import-10k', + }); + const summary = createM3uImportBenchmarkSummary( + createTestM3uImportManifest(), + [warmup, measured, diagnostic] + ); + + assert.equal(summary.measured.totalMs.count, 1); + assert.equal(summary.measured.totalMs.mean, 215); + assert.equal(summary.measured.ipcStructuredCloneProxyMs.mean, 44); + assert.equal(summary.measured.mainToDatabaseWorkerCloneProxyMs.mean, 2); + assert.equal(summary.measured.databaseWorkerToMainCloneProxyMs.mean, 12); + assert.equal(summary.measured.mainToRendererCloneProxyMs.mean, 28); + assert.equal(summary.measured.rendererToMainCloneProxyMs.mean, 2); + assert.equal(summary.measured.sqliteReadMs.mean, 5); + assert.equal(summary.measured.playlistDeserializationMs.mean, 12); + assert.equal(summary.measured.databaseWorkerThreadCpuSystemMicros.mean, 100); + assert.equal(summary.measured.databaseWorkerThreadCpuUserMicros.mean, 120); + assert.equal( + summary.measured.databaseWorkerUpsertEventLoopDelayP95Ms.mean, + 4 + ); + assert.equal( + summary.measured.databaseWorkerGetEventLoopDelayP95Ms.mean, + 7 + ); + assert.equal(summary.measured.mainRssPeakBytes.mean, 5_000); + assert.equal(summary.measured.mainHeapPeakBytes.mean, 2_000); + assert.equal(summary.measured.rendererRssPeakBytes.mean, 4_000); + assert.equal(summary.measured.rendererHeapPeakBytes.mean, 3_000); + assert.equal(summary.measured.databaseWorkerHeapPeakBytes.mean, 1_500); + assert.equal(summary.measured.databaseWorkerPostGcHeapBytes.mean, 900); + assert.equal(summary.measured.rendererLongTaskCount, 1); + assert.equal(summary.measured.unresponsiveEvents, 0); + assert.deepEqual(summary.notApplicable, { + cancelAcknowledgementLatencyMs: 'scenario-has-no-cancellation', + databaseWorkerRss: 'unavailable-per-worker-thread', + indexAndCommitMs: + 'indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write', + nativeAndSqliteMemory: + 'included-in-main-rss-not-separately-attributable', + playlistRefreshWorker: 'not-used-by-initial-import', + }); + assert.equal(summary.validity.rendererRss.validForComparison, true); + assert.equal( + summary.validity.databaseWorkerPeakMemory.validForComparison, + true + ); + assert.equal( + summary.validity.databaseWorkerPostGc.validForComparison, + true + ); + assert.doesNotThrow(() => assertM3uImportComparisonValidity(summary)); + + const databaseWorker = measured.main.workers[0]; + assert.ok(databaseWorker); + const invalidMeasured = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: { + ...measured.main, + workers: [ + { + ...databaseWorker, + externalMemorySampleCount: 0, + heapUsedSampleCount: 0, + peakExternalBytes: null, + peakExternalUnavailableReason: + 'worker-external-memory-samples-missing', + peakHeapUsedBytes: null, + peakHeapUsedUnavailableReason: + 'worker-heap-used-samples-missing', + }, + ], + }, + renderer: measured.renderer, + runId: 'run-01', + scenarioId: 'm3u-import-10k', + }); + const invalidPeakSummary = createM3uImportBenchmarkSummary( + createTestM3uImportManifest(), + [warmup, invalidMeasured, diagnostic] + ); + assert.equal( + invalidPeakSummary.validity.databaseWorkerPeakMemory.validForComparison, + false + ); + assert.equal( + invalidPeakSummary.measured.databaseWorkerHeapPeakBytes.count, + 0 + ); + assert.throws( + () => + assertM3uImportComparisonValidity( + invalidPeakSummary as M3uImportBenchmarkSummary + ), + /Database worker peak-memory capture is invalid/ + ); + assert.throws( + () => + createM3uImportBenchmarkSummary(createTestM3uImportManifest(), [ + measured, + diagnostic, + ]), + /m3u-import-summary-schedule-invalid/ + ); +}); + +it('fails comparison when any measured worker request omits mandatory event-loop or CPU metrics', () => { + const completeMain = createCompleteTestMainCapture(); + const databaseWorker = completeMain.workers[0]; + const get = databaseWorker?.requests[1]; + assert.ok(databaseWorker); + assert.ok(get); + const invalidMain = { + ...completeMain, + workers: [ + { + ...databaseWorker, + requests: [ + databaseWorker.requests[0], + { + ...get, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + 'event-loop-delay-capture-unavailable', + histogramFlushedEpochMs: null, + }, + ], + }, + ], + }; + const warmup = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.WARMUP, + main: createCompleteTestMainCapture(), + renderer: createCompleteTestRendererCapture(), + runId: 'warmup-01', + scenarioId: 'm3u-import-10k', + }); + const measured = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: invalidMain, + renderer: createCompleteTestRendererCapture(), + runId: 'run-01', + scenarioId: 'm3u-import-10k', + }); + const diagnostic = createM3uImportIterationResult({ + channelCount: TEST_M3U_CHANNEL_COUNT, + fixtureBytes: TEST_M3U_FIXTURE_BYTES, + kind: PERFORMANCE_ITERATION_KIND.DIAGNOSTIC, + main: createCompleteTestMainCapture(), + renderer: createCompleteTestRendererCapture(), + runId: 'diagnostic', + scenarioId: 'm3u-import-10k', + }); + const summary = createM3uImportBenchmarkSummary( + createTestM3uImportManifest(), + [warmup, measured, diagnostic] + ); + + assert.equal( + summary.validity.databaseWorkerRequestMetrics.validForComparison, + false + ); + assert.equal( + summary.validity.databaseWorkerRequestMetrics.expectedRequestCount, + 2 + ); + assert.equal( + summary.validity.databaseWorkerRequestMetrics.validRequestCount, + 1 + ); + assert.equal( + summary.measured.databaseWorkerGetEventLoopDelayP95Ms.count, + 0 + ); + assert.throws( + () => assertM3uImportComparisonValidity(summary), + /Database worker request metrics are invalid/ + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-summary.ts b/apps/electron-backend-e2e/src/performance/m3u-import-summary.ts new file mode 100644 index 000000000..5a81e0a87 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import-summary.ts @@ -0,0 +1,382 @@ +import type { M3uImportRendererCaptureMetrics } from './m3u-import-renderer-capture'; +import type { M3uImportScenarioId } from './m3u-import-benchmark-contract'; +import type { + MainCaptureMetrics, + NumericDistribution, + PerformanceIterationKind, + WorkerCaptureMetrics, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { + PERFORMANCE_ITERATION_KIND, + PERFORMANCE_WORKER_KIND, +} from './m3u-refresh-cancellation-contract'; +import { + deriveM3uImportPhaseMetrics, + M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON, +} from './m3u-import-report'; +import { summarizeNumbers } from './performance-statistics'; +import { assessDatabaseWorkerPeakMemoryValidity } from './database-worker-peak-memory-validity'; +import { assessDatabaseWorkerPostGcValidity } from './database-worker-post-gc-validity'; +import { assessDatabaseWorkerRequestMetricsValidity } from './database-worker-request-metrics-validity'; +import { assessRendererRssValidity } from './renderer-rss-validity'; +import type { + M3uImportBenchmarkManifest, + M3uImportBenchmarkSummary, + M3uImportIterationResult, +} from './m3u-import-summary-contract'; + +export type { + M3uImportBenchmarkManifest, + M3uImportBenchmarkSummary, + M3uImportIterationResult, + M3uImportMeasuredSummary, +} from './m3u-import-summary-contract'; + +export function createM3uImportIterationResult(input: { + readonly channelCount: number; + readonly fixtureBytes: number; + readonly kind: PerformanceIterationKind; + readonly main: MainCaptureMetrics; + readonly renderer: M3uImportRendererCaptureMetrics; + readonly runId: string; + readonly scenarioId: M3uImportScenarioId; +}): M3uImportIterationResult { + return Object.freeze({ + channelCount: input.channelCount, + fixtureBytes: input.fixtureBytes, + kind: input.kind, + main: input.main, + phases: deriveM3uImportPhaseMetrics(input), + renderer: input.renderer, + runId: input.runId, + scenarioId: input.scenarioId, + }); +} + +export function createM3uImportBenchmarkSummary( + manifest: M3uImportBenchmarkManifest, + iterations: readonly M3uImportIterationResult[] +): M3uImportBenchmarkSummary { + validateIterationSchedule(manifest, iterations); + const measured = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED + ); + validateMeasuredIterations(manifest, measured); + const values = ( + select: (iteration: M3uImportIterationResult) => number | null + ): NumericDistribution => summarizeNumbers(measured.map(select)); + const rendererSamples = ( + select: ( + probe: M3uImportRendererCaptureMetrics['probe'] + ) => readonly number[] + ): NumericDistribution => + summarizeNumbers( + measured.flatMap((iteration) => [ + ...select(iteration.renderer.probe), + ]) + ); + const databaseWorkers = measured.map(requireDatabaseWorker); + const databaseRequestPairs = databaseWorkers.map( + requireInitialImportRequests + ); + const databaseUpserts = databaseRequestPairs.map((pair) => pair.upsert); + const databaseGets = databaseRequestPairs.map((pair) => pair.get); + const databaseRequests = databaseRequestPairs.flatMap((pair) => [ + pair.upsert, + pair.get, + ]); + + return Object.freeze({ + iterations: Object.freeze([...iterations]), + manifest, + measured: Object.freeze({ + angularRenderingMs: values( + (iteration) => iteration.phases.angularRenderingMs + ), + dataAcquireMs: values( + (iteration) => iteration.phases.dataAcquireMs + ), + databaseWorkerGetEventLoopDelayMaxMs: summarizeNumbers( + databaseGets.map((request) => request.eventLoopDelay?.maxMs) + ), + databaseWorkerGetEventLoopDelayP95Ms: summarizeNumbers( + databaseGets.map((request) => request.eventLoopDelay?.p95Ms) + ), + databaseWorkerGetEventLoopDelayP99Ms: summarizeNumbers( + databaseGets.map((request) => request.eventLoopDelay?.p99Ms) + ), + databaseWorkerGetEventLoopUtilization: summarizeNumbers( + databaseGets.map((request) => request.eventLoopUtilization) + ), + databaseWorkerGetThreadCpuSystemMicros: summarizeNumbers( + databaseGets.map((request) => request.threadCpuSystemMicros) + ), + databaseWorkerGetThreadCpuUserMicros: summarizeNumbers( + databaseGets.map((request) => request.threadCpuUserMicros) + ), + databaseWorkerEventLoopDelayMaxMs: summarizeNumbers( + databaseRequests.map((request) => request.eventLoopDelay?.maxMs) + ), + databaseWorkerEventLoopDelayP95Ms: summarizeNumbers( + databaseRequests.map((request) => request.eventLoopDelay?.p95Ms) + ), + databaseWorkerEventLoopDelayP99Ms: summarizeNumbers( + databaseRequests.map((request) => request.eventLoopDelay?.p99Ms) + ), + databaseWorkerEventLoopUtilization: summarizeNumbers( + databaseRequests.map((request) => request.eventLoopUtilization) + ), + databaseWorkerExternalPeakBytes: summarizeNumbers( + databaseWorkers.map((worker) => worker.peakExternalBytes) + ), + databaseWorkerHeapPeakBytes: summarizeNumbers( + databaseWorkers.map((worker) => worker.peakHeapUsedBytes) + ), + databaseWorkerPostGcHeapBytes: summarizeNumbers( + databaseWorkers.map((worker) => worker.postGcHeapUsedBytes) + ), + databaseWorkerThreadCpuSystemMicros: summarizeNumbers( + databaseRequestPairs.map((pair) => + sumRequestMetric( + pair.upsert.threadCpuSystemMicros, + pair.get.threadCpuSystemMicros + ) + ) + ), + databaseWorkerThreadCpuUserMicros: summarizeNumbers( + databaseRequestPairs.map((pair) => + sumRequestMetric( + pair.upsert.threadCpuUserMicros, + pair.get.threadCpuUserMicros + ) + ) + ), + databaseWorkerToMainCloneProxyMs: values( + (iteration) => + iteration.phases.databaseWorkerToMainCloneProxyMs + ), + databaseWorkerUpsertEventLoopDelayMaxMs: summarizeNumbers( + databaseUpserts.map((request) => request.eventLoopDelay?.maxMs) + ), + databaseWorkerUpsertEventLoopDelayP95Ms: summarizeNumbers( + databaseUpserts.map((request) => request.eventLoopDelay?.p95Ms) + ), + databaseWorkerUpsertEventLoopDelayP99Ms: summarizeNumbers( + databaseUpserts.map((request) => request.eventLoopDelay?.p99Ms) + ), + databaseWorkerUpsertEventLoopUtilization: summarizeNumbers( + databaseUpserts.map((request) => request.eventLoopUtilization) + ), + databaseWorkerUpsertThreadCpuSystemMicros: summarizeNumbers( + databaseUpserts.map( + (request) => request.threadCpuSystemMicros + ) + ), + databaseWorkerUpsertThreadCpuUserMicros: summarizeNumbers( + databaseUpserts.map((request) => request.threadCpuUserMicros) + ), + ipcStructuredCloneProxyMs: values( + (iteration) => iteration.phases.ipcStructuredCloneProxyMs + ), + mainToDatabaseWorkerCloneProxyMs: values( + (iteration) => iteration.phases.mainToDatabaseWorkerCloneProxyMs + ), + mainToRendererCloneProxyMs: values( + (iteration) => iteration.phases.mainToRendererCloneProxyMs + ), + m3uParsingMs: values((iteration) => iteration.phases.m3uParsingMs), + mainEventLoopDelayMaxMs: values( + (iteration) => iteration.main.eventLoopDelay.maxMs + ), + mainEventLoopDelayP95Ms: values( + (iteration) => iteration.main.eventLoopDelay.p95Ms + ), + mainEventLoopDelayP99Ms: values( + (iteration) => iteration.main.eventLoopDelay.p99Ms + ), + mainEventLoopUtilization: values( + (iteration) => iteration.main.eventLoopUtilization + ), + mainHeapPeakBytes: values( + (iteration) => iteration.main.memory.peakHeapUsedBytes + ), + mainHeapPostGcBytes: values( + (iteration) => iteration.main.memory.postGcHeapUsedBytes + ), + mainRssPeakBytes: values( + (iteration) => iteration.main.memory.peakRssBytes + ), + mainRssPostGcBytes: values( + (iteration) => iteration.main.memory.postGcRssBytes + ), + normalizationMs: values( + (iteration) => iteration.phases.normalizationMs + ), + playlistDeserializationMs: values( + (iteration) => iteration.phases.playlistDeserializationMs + ), + playlistSerializationMs: values( + (iteration) => iteration.phases.playlistSerializationMs + ), + rendererFrameGapMs: rendererSamples((probe) => probe.frameGapsMs), + rendererHeartbeatDelayMs: rendererSamples( + (probe) => probe.heartbeatDelaysMs + ), + rendererHeapPeakBytes: values( + (iteration) => iteration.renderer.peakHeapUsedBytes + ), + rendererHeapPostGcBytes: values( + (iteration) => iteration.renderer.postGcHeapUsedBytes + ), + rendererLongTaskCount: measured.reduce( + (total, iteration) => + total + iteration.renderer.probe.longTasksMs.length, + 0 + ), + rendererLongTaskMs: rendererSamples((probe) => probe.longTasksMs), + rendererRssPeakBytes: values( + (iteration) => iteration.main.rendererWindow.rss.peakRssBytes + ), + rendererToMainCloneProxyMs: values( + (iteration) => iteration.phases.rendererToMainCloneProxyMs + ), + responsiveEvents: measured.reduce( + (total, iteration) => + total + iteration.main.rendererWindow.responsiveEvents, + 0 + ), + sqliteReadMs: values((iteration) => iteration.phases.sqliteReadMs), + sqliteWriteMs: values( + (iteration) => iteration.phases.sqliteWriteMs + ), + storeImportDispatchMs: values( + (iteration) => iteration.phases.storeImportDispatchMs + ), + storePublishChannelsMs: values( + (iteration) => iteration.phases.storePublishChannelsMs + ), + storeUpdateMs: values( + (iteration) => iteration.phases.storeUpdateMs + ), + totalMs: values((iteration) => iteration.phases.totalMs), + unresponsiveEvents: measured.reduce( + (total, iteration) => + total + iteration.main.rendererWindow.unresponsiveEvents, + 0 + ), + }), + notApplicable: Object.freeze({ + cancelAcknowledgementLatencyMs: 'scenario-has-no-cancellation', + databaseWorkerRss: 'unavailable-per-worker-thread', + indexAndCommitMs: M3U_IMPORT_INDEX_COMMIT_UNAVAILABLE_REASON, + nativeAndSqliteMemory: + 'included-in-main-rss-not-separately-attributable', + playlistRefreshWorker: 'not-used-by-initial-import', + }), + validity: Object.freeze({ + databaseWorkerPeakMemory: + assessDatabaseWorkerPeakMemoryValidity(iterations), + databaseWorkerPostGc: + assessDatabaseWorkerPostGcValidity(iterations), + databaseWorkerRequestMetrics: + assessDatabaseWorkerRequestMetricsValidity(iterations), + rendererRss: assessRendererRssValidity(iterations), + }), + }); +} + +function validateIterationSchedule( + manifest: M3uImportBenchmarkManifest, + iterations: readonly M3uImportIterationResult[] +): void { + const warmupCount = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.WARMUP + ).length; + const measuredCount = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED + ).length; + const diagnosticCount = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC + ).length; + if ( + warmupCount !== manifest.warmupRuns || + measuredCount !== manifest.measuredRuns || + diagnosticCount !== 1 || + iterations.length !== warmupCount + measuredCount + diagnosticCount || + new Set(iterations.map((iteration) => iteration.runId)).size !== + iterations.length || + iterations.some( + (iteration) => + iteration.channelCount !== manifest.channelCount || + iteration.fixtureBytes !== manifest.fixtureBytes || + iteration.scenarioId !== manifest.scenario + ) + ) { + throw new Error('m3u-import-summary-schedule-invalid'); + } +} + +function validateMeasuredIterations( + manifest: M3uImportBenchmarkManifest, + measured: readonly M3uImportIterationResult[] +): void { + if ( + measured.length !== manifest.measuredRuns || + new Set(measured.map((iteration) => iteration.runId)).size !== + measured.length || + measured.some( + (iteration) => + iteration.channelCount !== manifest.channelCount || + iteration.fixtureBytes !== manifest.fixtureBytes || + iteration.scenarioId !== manifest.scenario + ) + ) { + throw new Error('m3u-import-summary-measured-runs-invalid'); + } +} + +function requireDatabaseWorker( + iteration: M3uImportIterationResult +): WorkerCaptureMetrics { + const databaseWorkers = iteration.main.workers.filter( + (worker) => worker.kind === PERFORMANCE_WORKER_KIND.DATABASE + ); + if (databaseWorkers.length !== 1 || !databaseWorkers[0]) { + throw new Error('m3u-import-summary-database-worker-invalid'); + } + return databaseWorkers[0]; +} + +function requireInitialImportRequests( + worker: WorkerCaptureMetrics +): { + readonly get: WorkerRequestPerformanceMetrics; + readonly upsert: WorkerRequestPerformanceMetrics; +} { + const upserts = worker.requests.filter( + (request) => + request.operation === 'DB_UPSERT_APP_PLAYLIST' && request.success + ); + const gets = worker.requests.filter( + (request) => + request.operation === 'DB_GET_APP_PLAYLIST' && request.success + ); + if ( + worker.requests.length !== 2 || + upserts.length !== 1 || + !upserts[0] || + gets.length !== 1 || + !gets[0] + ) { + throw new Error('m3u-import-summary-database-request-invalid'); + } + return { get: gets[0], upsert: upserts[0] }; +} + +function sumRequestMetric( + first: number | null, + second: number | null +): number | null { + return first === null || second === null ? null : first + second; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts new file mode 100644 index 000000000..dab027bc9 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts @@ -0,0 +1,341 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + closeElectronApp, + expect, + launchElectronApp, + type LaunchedElectronApp, +} from '../electron-test-fixtures'; +import { validateInitialImportDiagnosticArtifacts } from './initial-import-diagnostic-artifacts'; +import { + assertRendererCdpTarget, + assertTcpPortAvailable, + type M3uImportBenchmarkConfiguration, + resolveM3uImportBenchmarkConfiguration, + resolveRendererWindowIdentity, + writeM3uImportJson as writeJson, +} from './m3u-import-benchmark-support'; +import { + assertSyntheticM3uImportUrl, + createM3uImportIterationDefinitions, + listM3uImportScenarios, + type M3uImportIterationDefinition, + type M3uImportScenario, +} from './m3u-import-benchmark-contract'; +import { + createM3uImportBenchmarkSummary, + createM3uImportIterationResult, + type M3uImportBenchmarkManifest, + type M3uImportBenchmarkSummary, + type M3uImportIterationResult, +} from './m3u-import-summary'; +import { + startM3uImportRendererCapture, + type RunningM3uImportRendererCapture, +} from './m3u-import-renderer-capture'; +import { + installMainCapture, + readMainCaptureStatus, + startMainCapture, + stopMainCapture, +} from './m3u-refresh-main-capture'; +import { PERFORMANCE_ITERATION_KIND } from './m3u-refresh-cancellation-contract'; +import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight'; +import { + startSyntheticM3uServer, + type SyntheticM3uServer, +} from './synthetic-m3u-server'; +import { + createSyntheticM3uFixture, + SYNTHETIC_M3U_SEED, + type SyntheticM3uFixture, +} from './synthetic-m3u'; + +export async function runM3uImportBenchmark(): Promise { + const config = await resolveM3uImportBenchmarkConfiguration(); + const definitions = createM3uImportIterationDefinitions(config.smoke); + + for (const scenario of listM3uImportScenarios()) { + await runScenario( + config, + scenario, + definitions.filter( + (definition) => definition.scenarioId === scenario.id + ) + ); + } +} + +async function runScenario( + config: M3uImportBenchmarkConfiguration, + scenario: M3uImportScenario, + definitions: readonly M3uImportIterationDefinition[] +): Promise { + const fixture = createSyntheticM3uFixture(scenario.channelCount); + const server = await startSyntheticM3uServer(fixture); + const scenarioDirectory = join(config.variantDirectory, scenario.id); + const iterations: M3uImportIterationResult[] = []; + + try { + assertSyntheticM3uImportUrl(server.resourceUrl); + await server.prime(); + await assertPerformanceArtifactCapacity(scenarioDirectory); + await mkdir(scenarioDirectory, { recursive: false }); + for (const definition of definitions) { + console.log( + `[performance] ${config.variant}/${scenario.id}/${definition.runId} starting` + ); + const result = await runIteration( + config, + definition, + fixture, + server, + scenarioDirectory + ); + iterations.push(result); + console.log( + `[performance] ${definition.runId} total=${result.phases.totalMs.toFixed(3)}ms` + ); + } + } finally { + await server.close(); + } + + if (server.requestCount() !== definitions.length + 1) { + throw new Error('Synthetic M3U server request total is invalid'); + } + const manifest = createManifest(config, scenario, fixture); + const summary = createM3uImportBenchmarkSummary(manifest, iterations); + await writeJson(join(scenarioDirectory, 'manifest.json'), manifest); + await writeJson(join(scenarioDirectory, 'summary.json'), summary); + const diagnostic = requireDiagnosticIteration(iterations); + await validateInitialImportDiagnosticArtifacts( + { main: diagnostic.main, renderer: diagnostic.renderer }, + join(scenarioDirectory, diagnostic.runId) + ); + assertM3uImportComparisonValidity(summary); + console.log( + `[performance] completed: ${join(scenarioDirectory, 'summary.json')}` + ); +} + +export function assertM3uImportComparisonValidity( + summary: M3uImportBenchmarkSummary +): void { + if (!summary.validity.rendererRss.validForComparison) { + throw new Error( + `Renderer RSS capture is invalid for comparison: ${JSON.stringify( + summary.validity.rendererRss.invalidMeasuredRuns + )}` + ); + } + if (!summary.validity.databaseWorkerPostGc.validForComparison) { + throw new Error( + `Database worker post-GC capture is invalid for comparison: ${JSON.stringify( + summary.validity.databaseWorkerPostGc.invalidMeasuredRuns + )}` + ); + } + if (!summary.validity.databaseWorkerPeakMemory.validForComparison) { + throw new Error( + `Database worker peak-memory capture is invalid for comparison: ${JSON.stringify( + summary.validity.databaseWorkerPeakMemory.invalidMeasuredRuns + )}` + ); + } + if (!summary.validity.databaseWorkerRequestMetrics.validForComparison) { + throw new Error( + `Database worker request metrics are invalid for comparison: ${JSON.stringify( + summary.validity.databaseWorkerRequestMetrics.invalidRequests + )}` + ); + } +} + +async function runIteration( + config: M3uImportBenchmarkConfiguration, + definition: M3uImportIterationDefinition, + fixture: SyntheticM3uFixture, + server: SyntheticM3uServer, + scenarioDirectory: string +): Promise { + const iterationDirectory = join(scenarioDirectory, definition.runId); + await assertPerformanceArtifactCapacity(iterationDirectory); + await mkdir(iterationDirectory, { recursive: false }); + const dataDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-m3u-import-performance-') + ); + let app: LaunchedElectronApp | null = null; + let renderer: RunningM3uImportRendererCapture | null = null; + + try { + await assertTcpPortAvailable(config.rendererCdpPort); + app = await launchElectronApp(dataDirectory, { + args: [ + '--js-flags=--expose-gc', + '--remote-debugging-address=127.0.0.1', + `--remote-debugging-port=${config.rendererCdpPort}`, + `--user-data-dir=${join(dataDirectory, 'user-data')}`, + ], + env: { + IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', + IPTVNATOR_PERF_CAPTURE: '1', + IPTVNATOR_PERF_WORKER_PROFILING: '1', + IPTVNATOR_TRACE_RENDERER_CONSOLE: + process.env['IPTVNATOR_TRACE_RENDERER_CONSOLE'] ?? '0', + }, + }); + app.mainWindow.setDefaultTimeout(120_000); + await assertRendererCdpTarget( + app.mainWindow, + config.rendererCdpPort + ); + await installMainCapture(app.electronApp); + const rendererWindowIdentity = await resolveRendererWindowIdentity(app); + const requestsBefore = server.requestCount(); + const diagnostic = + definition.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC; + renderer = await startM3uImportRendererCapture(app.mainWindow, { + diagnostic, + outputDirectory: iterationDirectory, + playlistTitle: `Synthetic M3U import ${definition.channelCount}`, + playlistUrl: server.resourceUrl, + }); + + await startMainCapture(app.electronApp, { + diagnostic, + outputDirectory: iterationDirectory, + rendererWindowIdentity, + }); + await renderer.triggerImport(); + await renderer.waitForTerminal(); + await waitForDatabaseSettlement(app); + assertM3uImportRequestDelta(requestsBefore, server.requestCount()); + + const [rendererMetrics, mainMetrics] = await Promise.all([ + renderer.stop(), + stopMainCapture(app.electronApp), + ]); + await Promise.all([ + writeJson( + join(iterationDirectory, 'main-capture.json'), + mainMetrics + ), + writeJson( + join(iterationDirectory, 'renderer-capture.json'), + rendererMetrics + ), + ]); + const result = createM3uImportIterationResult({ + channelCount: definition.channelCount, + fixtureBytes: fixture.bytes, + kind: definition.kind, + main: mainMetrics, + renderer: rendererMetrics, + runId: definition.runId, + scenarioId: definition.scenarioId, + }); + await writeJson(join(iterationDirectory, 'result.json'), result); + return result; + } finally { + if (renderer) { + await renderer.dispose().catch((error: unknown) => { + console.error( + '[performance] Renderer capture cleanup failed', + error + ); + }); + } + if (app) { + await closeElectronApp(app).catch((error: unknown) => { + console.error('[performance] Electron cleanup failed', error); + }); + } + await rm(dataDirectory, { force: true, recursive: true }); + } +} + +async function waitForDatabaseSettlement( + app: LaunchedElectronApp +): Promise { + await expect + .poll( + async () => { + const status = await readMainCaptureStatus(app.electronApp); + return ( + status.databaseRequests === 2 && + status.databaseUpsertsCompleted === 1 && + status.databaseGetsCompleted === 1 && + status.databasePending === 0 && + status.preloadSuccessMarkers === 2 + ); + }, + { timeout: 120_000 } + ) + .toBe(true); +} + +function createManifest( + config: M3uImportBenchmarkConfiguration, + scenario: M3uImportScenario, + fixture: SyntheticM3uFixture +): M3uImportBenchmarkManifest { + return Object.freeze({ + channelCount: fixture.channelCount, + fixtureBytes: fixture.bytes, + fixtureSha256: fixture.sha256, + gitCommit: config.sourceState.commit, + gitDiffSha256: config.sourceState.diffSha256, + gitDirty: config.sourceState.dirty, + measuredRuns: config.measuredRuns, + memoryAccounting: Object.freeze({ + databaseWorkerRss: 'unavailable-per-worker-thread', + mainRss: + 'electron-main-process-including-worker-threads-and-native-memory', + nativeAndSqliteMemory: + 'included-in-main-rss-not-separately-attributable', + rendererRss: 'separate-renderer-process', + workerJsHeap: 'separate-v8-isolate', + }), + runtime: Object.freeze({ + arch: process.arch, + cdpPort: config.rendererCdpPort, + electronVersion: config.electronVersion, + harnessNodeVersion: process.versions.node, + platform: process.platform, + }), + scenario: scenario.id, + syntheticSeed: SYNTHETIC_M3U_SEED, + variant: config.variant, + warmupRuns: 1, + }); +} + +function requireDiagnosticIteration( + iterations: readonly M3uImportIterationResult[] +): M3uImportIterationResult { + const diagnostic = iterations.filter( + (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.DIAGNOSTIC + ); + if (diagnostic.length !== 1 || !diagnostic[0]) { + throw new Error('M3U import diagnostic iteration is missing'); + } + return diagnostic[0]; +} + +export function assertM3uImportRequestDelta( + before: number, + after: number +): void { + const oneRequest = + Number.isSafeInteger(before) && + Number.isSafeInteger(after) && + after === before + 1; + if (!oneRequest) { + throw new Error( + 'Synthetic M3U import must issue exactly one application GET' + ); + } +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts index 217dd1ca3..a89f95dc8 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts @@ -1,7 +1,9 @@ +import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; +import type { RendererRssValidity } from './renderer-rss-validity'; import type { - RendererProcessRssCapture, - RendererProcessRssUnavailableReason, -} from './renderer-process-rss-capture'; + AppPlaylistPerformancePhase, + PerformancePhaseEvent, +} from '@iptvnator/shared/interfaces'; export const PERFORMANCE_ITERATION_KIND = { DIAGNOSTIC: 'diagnostic', @@ -54,6 +56,12 @@ export type WorkerPostGcHeapCapture = readonly postGcHeapUsedBytes: null; }; +export const WORKER_PEAK_MEMORY_UNAVAILABLE_REASON = { + EXTERNAL_MEMORY_SAMPLES_MISSING: + 'worker-external-memory-samples-missing', + HEAP_USED_SAMPLES_MISSING: 'worker-heap-used-samples-missing', +} as const; + export interface NumericDistribution { readonly count: number; readonly max: number | null; @@ -78,12 +86,19 @@ export interface ProcessMemoryMetrics { } export interface MainTimelineRecord { + readonly boundary?: 'end' | 'start'; + readonly byteCount?: number; + readonly durationMs?: number | null; readonly epochMs: number; + readonly ipcCallId?: number; + readonly itemCount?: number; readonly operation?: string; readonly operationId?: string; + readonly phase?: string; readonly playlistId?: string; readonly requestId?: string; readonly success?: boolean; + readonly sourceEpochMs?: number; readonly type: string; } @@ -93,15 +108,19 @@ export interface WorkerRequestPerformanceMetrics { readonly eventLoopUtilization: number | null; readonly eventLoopUtilizationUnavailableReason: string | null; readonly histogramFlushedEpochMs: number | null; + readonly ipcCallId: number | null; readonly invalidReason: string | null; readonly operation: string | null; readonly operationId: string | null; readonly operationIdUnavailableReason: string | null; readonly performanceCaptureUnavailableReason: string | null; + readonly phaseEvents: readonly PerformancePhaseEvent[]; readonly playlistId: string | null; readonly requestId: string | null; readonly requestReceivedEpochMs: number | null; readonly responseEpochMs: number; + readonly responsePostedEpochMs: number | null; + readonly sourceEpochMs: number | null; readonly success: boolean; readonly threadCpuSystemMicros: number | null; readonly threadCpuUnavailableReason: string | null; @@ -117,11 +136,19 @@ export type WorkerCaptureMetrics = { readonly eventLoopDelay: EventLoopDelayMetrics | null; readonly eventLoopDelayUnavailableReason: string | null; readonly eventLoopUtilization: number | null; + readonly externalMemorySampleCount: number; + readonly heapUsedSampleCount: number; readonly kind: PerformanceWorkerKind; readonly operationId: string | null; readonly ordinal: number; - readonly peakExternalBytes: number; - readonly peakHeapUsedBytes: number; + readonly peakExternalBytes: number | null; + readonly peakExternalUnavailableReason: + | typeof WORKER_PEAK_MEMORY_UNAVAILABLE_REASON.EXTERNAL_MEMORY_SAMPLES_MISSING + | null; + readonly peakHeapUsedBytes: number | null; + readonly peakHeapUsedUnavailableReason: + | typeof WORKER_PEAK_MEMORY_UNAVAILABLE_REASON.HEAP_USED_SAMPLES_MISSING + | null; readonly playlistId: string | null; readonly profilePath: string | null; readonly requests: readonly WorkerRequestPerformanceMetrics[]; @@ -257,22 +284,6 @@ export interface DatabaseWorkerPostGcValidity { readonly validMeasuredRunCount: number; } -export interface InvalidRendererRssMeasuredRun { - readonly missingSampleCount: number; - readonly reason: - RendererProcessRssUnavailableReason | 'renderer-rss-capture-invalid'; - readonly runId: string; - readonly validSampleCount: number; -} - -export interface RendererRssValidity { - readonly invalidMeasuredRuns: readonly InvalidRendererRssMeasuredRun[]; - readonly measuredRunCount: number; - readonly validForBenchmark: boolean; - readonly validForComparison: boolean; - readonly validMeasuredRunCount: number; -} - export interface CancellationBenchmarkSummary { readonly cancellationEffectRate: number; readonly iterations: readonly CancellationIterationResult[]; diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts index 6311fbd06..f95d075e6 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-report.ts @@ -9,7 +9,6 @@ import type { NumericDistribution, PerformanceWorkerKind, RendererCaptureMetrics, - RendererRssValidity, WorkerRequestPerformanceMetrics, } from './m3u-refresh-cancellation-contract'; import { @@ -21,6 +20,7 @@ import { summarizeNumbers, } from './performance-statistics'; import { assessDatabaseWorkerPostGcValidity } from './database-worker-post-gc-validity'; +import { assessRendererRssValidity } from './renderer-rss-validity'; export function createCancellationIterationResult(input: { readonly kind: CancellationIterationResult['kind']; @@ -316,50 +316,6 @@ export function createCancellationBenchmarkSummary( }); } -function assessRendererRssValidity( - iterations: readonly CancellationIterationResult[] -): RendererRssValidity { - const measured = iterations.filter( - (iteration) => iteration.kind === PERFORMANCE_ITERATION_KIND.MEASURED - ); - const invalidMeasuredRuns: RendererRssValidity['invalidMeasuredRuns'][number][] = - []; - let validMeasuredRunCount = 0; - - for (const iteration of measured) { - const rss = iteration.main.rendererWindow.rss; - if ( - rss.identity !== null && - Number.isSafeInteger(rss.peakRssBytes) && - Number(rss.peakRssBytes) > 0 && - rss.unavailableReason === null && - rss.missingSampleCount === 0 && - Number.isSafeInteger(rss.validSampleCount) && - rss.validSampleCount > 0 - ) { - validMeasuredRunCount += 1; - continue; - } - - invalidMeasuredRuns.push({ - missingSampleCount: rss.missingSampleCount, - reason: rss.unavailableReason ?? 'renderer-rss-capture-invalid', - runId: iteration.runId, - validSampleCount: rss.validSampleCount, - }); - } - - const valid = - measured.length > 0 && validMeasuredRunCount === measured.length; - return Object.freeze({ - invalidMeasuredRuns: Object.freeze(invalidMeasuredRuns), - measuredRunCount: measured.length, - validForBenchmark: valid, - validForComparison: valid, - validMeasuredRunCount, - }); -} - function derivePhases( main: MainCaptureMetrics, renderer: RendererCaptureMetrics diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts index 0931ac31a..2c4536f8e 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts @@ -1,5 +1,9 @@ /* eslint-disable max-lines -- The injected main-process protocol must remain self-contained for Playwright serialization. */ import type { ElectronApplication } from '@playwright/test'; +import { + M3U_IMPORT_PERFORMANCE_PHASE, + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, +} from '@iptvnator/shared/interfaces'; import { DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY, @@ -58,6 +62,7 @@ export interface MainCaptureStartOptions { } export interface MainCaptureStatus { + readonly databaseGetsCompleted: number; readonly databasePending: number; readonly databaseRequests: number; readonly databaseUpsertsCompleted: number; @@ -65,6 +70,7 @@ export interface MainCaptureStatus { readonly playlistResponsesFailed: number; readonly playlistResponses: number; readonly playlistResponsesSucceeded: number; + readonly preloadSuccessMarkers: number; } export interface MainCaptureRolloverResult { @@ -101,6 +107,9 @@ export async function installMainCapture( createRendererProcessRssCaptureApi.toString(), rendererWindowRssSessionApiFactorySource: createRendererWindowRssSessionApi.toString(), + m3uImportPerformancePhaseEventChannel: + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, + m3uImportPerformancePhases: Object.values(M3U_IMPORT_PERFORMANCE_PHASE), stateKey: MAIN_CAPTURE_STATE_KEY, workerTerminationGenerationApiFactorySource: createWorkerTerminationGenerationApi.toString(), @@ -158,12 +167,14 @@ export async function installMainCapture( cpuLast: WorkerCpuUsage | null; elu: number | null; eluStart: WorkerElu | null; + externalMemorySampleCount: number; externalPeak: number; finalized: boolean; finalizationKey: object; finalizationTimedOut: boolean; finalizing: Promise | null; heapPeak: number; + heapUsedSampleCount: number; kind: 'database.worker' | 'playlist-refresh.worker'; operationId: string | null; ordinal: number; @@ -185,12 +196,19 @@ export async function installMainCapture( worker: InstrumentedWorker; } interface TimelineRecord { + readonly boundary?: 'end' | 'start'; + readonly byteCount?: number; + readonly durationMs?: number | null; readonly epochMs: number; + readonly ipcCallId?: number; + readonly itemCount?: number; readonly operation?: string; readonly operationId?: string; + readonly phase?: string; readonly playlistId?: string; readonly requestId?: string; readonly success?: boolean; + readonly sourceEpochMs?: number; readonly type: string; } @@ -245,6 +263,9 @@ export async function installMainCapture( const inspector = runtimeProcess.getBuiltinModule( 'node:inspector' ) as typeof import('node:inspector'); + const diagnosticsChannel = runtimeProcess.getBuiltinModule( + 'node:diagnostics_channel' + ) as typeof import('node:diagnostics_channel'); const path = runtimeProcess.getBuiltinModule( 'node:path' ) as typeof import('node:path'); @@ -307,6 +328,60 @@ export async function installMainCapture( state.timeline.push({ epochMs: nowEpochMs(), ...record }); } }; + const m3uImportPhases = new Set( + input.m3uImportPerformancePhases + ); + diagnosticsChannel + .channel(input.m3uImportPerformancePhaseEventChannel) + .subscribe((incoming: unknown) => { + if (!state.active && !state.stopping) { + return; + } + if (typeof incoming !== 'object' || incoming === null) { + return; + } + const event = incoming as JsonRecord; + const boundary = event['boundary']; + const durationMs = event['durationMs']; + const epochMs = event['epochMs']; + const metadata = + typeof event['metadata'] === 'object' && + event['metadata'] !== null + ? (event['metadata'] as JsonRecord) + : null; + const phase = event['phase']; + const requestId = event['requestId']; + if ( + (boundary !== 'start' && boundary !== 'end') || + typeof epochMs !== 'number' || + !Number.isFinite(epochMs) || + typeof phase !== 'string' || + !m3uImportPhases.has(phase) || + typeof requestId !== 'string' || + requestId.length === 0 || + (boundary === 'start' && durationMs !== null) || + (boundary === 'end' && + (typeof durationMs !== 'number' || + !Number.isFinite(durationMs) || + durationMs < 0)) + ) { + return; + } + const byteCount = metadata?.['byteCount']; + const itemCount = metadata?.['itemCount']; + state.timeline.push({ + boundary, + ...(isCount(byteCount) ? { byteCount } : {}), + durationMs: durationMs as number | null, + epochMs, + ...(isCount(itemCount) ? { itemCount } : {}), + phase, + requestId, + type: 'm3u-import-phase', + }); + }); + const isCount = (value: unknown): value is number => + Number.isSafeInteger(value) && Number(value) >= 0; const classifyRequest = ( message: JsonRecord ): 'database.worker' | 'playlist-refresh.worker' | null => { @@ -348,12 +423,14 @@ export async function installMainCapture( cpuLast: null, elu: null, eluStart: null, + externalMemorySampleCount: 0, externalPeak: 0, finalized: false, finalizationKey: {}, finalizationTimedOut: false, finalizing: null, heapPeak: 0, + heapUsedSampleCount: 0, kind, operationId: null, ordinal: nextWorkerOrdinal, @@ -400,8 +477,10 @@ export async function installMainCapture( record.responseEpochMs = responseEpochMs; record.requestPerformance.push({ identity: { + ipcCallId: null, operationId: record.operationId, operationIdUnavailableReason: null, + sourceEpochMs: null, }, operation: null, performanceCapture: message['performance'] ?? null, @@ -466,14 +545,30 @@ export async function installMainCapture( try { const stats = await record.worker.getHeapStatistics?.(); if (stats) { - record.heapPeak = Math.max( - record.heapPeak, - Number(stats.used_heap_size ?? 0) - ); - record.externalPeak = Math.max( - record.externalPeak, - Number(stats.external_memory ?? 0) - ); + const heapUsedSample = stats.used_heap_size; + if ( + typeof heapUsedSample === 'number' && + Number.isFinite(heapUsedSample) && + heapUsedSample >= 0 + ) { + record.heapUsedSampleCount += 1; + record.heapPeak = Math.max( + record.heapPeak, + heapUsedSample + ); + } + const externalMemorySample = stats.external_memory; + if ( + typeof externalMemorySample === 'number' && + Number.isFinite(externalMemorySample) && + externalMemorySample >= 0 + ) { + record.externalMemorySampleCount += 1; + record.externalPeak = Math.max( + record.externalPeak, + externalMemorySample + ); + } } const cpu = await record.worker.cpuUsage?.(); if (cpu) { @@ -504,12 +599,14 @@ export async function installMainCapture( record.cpuLast = null; record.elu = null; record.eluStart = null; + record.externalMemorySampleCount = 0; record.externalPeak = 0; record.finalized = false; record.finalizationKey = {}; record.finalizationTimedOut = false; record.finalizing = null; record.heapPeak = 0; + record.heapUsedSampleCount = 0; record.operationId = null; record.pendingCount = 0; record.playlistId = null; @@ -863,8 +960,10 @@ export async function installMainCapture( value ) : { + ipcCallId: null, operationId: payloadOperationId, operationIdUnavailableReason: null, + sourceEpochMs: null, }; record.pendingCount += 1; dbRequests.set(value['requestId'], { @@ -874,11 +973,14 @@ export async function installMainCapture( record, }); recordTimeline({ + ipcCallId: identity.ipcCallId ?? undefined, operation: value['operation'], operationId: identity.operationId ?? undefined, playlistId: readDatabasePlaylistId(value) ?? undefined, requestId: value['requestId'], + sourceEpochMs: + identity.sourceEpochMs ?? undefined, type: 'db-request', }); } @@ -1052,6 +1154,12 @@ export async function installMainCapture( const api = { status: (): MainCaptureStatus => ({ + databaseGetsCompleted: state.timeline.filter( + (entry) => + entry['type'] === 'db-response' && + entry['operation'] === 'DB_GET_APP_PLAYLIST' && + entry['success'] === true + ).length, databasePending: dbRequests.size, databaseRequests: state.timeline.filter( (entry) => entry['type'] === 'db-request' @@ -1081,6 +1189,8 @@ export async function installMainCapture( entry['type'] === 'playlist-response' && entry['success'] === true ).length, + preloadSuccessMarkers: + databaseRequestIdentityCapture.successMarkerCount(), }), start: async (options: MainCaptureStartOptions): Promise => { databaseWorkerPostGcCutoffApi.beginCapture(); @@ -1092,6 +1202,18 @@ export async function installMainCapture( databaseWorkerPostGcCutoffApi.beginStop(); state.stopping = true; state.active = false; + for (const marker of databaseRequestIdentityCapture.takeSuccessMarkers()) { + state.timeline.push({ + epochMs: marker.sourceEpochMs, + ipcCallId: marker.ipcCallId, + operation: marker.operation, + operationId: marker.operationId ?? undefined, + playlistId: marker.playlistId, + sourceEpochMs: marker.sourceEpochMs, + success: true, + type: 'preload-performance-success', + }); + } databaseRequestIdentityCapture.stop(); if (state.sampleTimer) { clearInterval(state.sampleTimer); @@ -1230,11 +1352,28 @@ export async function installMainCapture( ? record.cpuLast.user - record.cpuFirst.user : null, eventLoopUtilization: record.elu, + externalMemorySampleCount: + record.externalMemorySampleCount, + heapUsedSampleCount: record.heapUsedSampleCount, kind: record.kind, operationId: record.operationId, ordinal: record.ordinal, - peakExternalBytes: record.externalPeak, - peakHeapUsedBytes: record.heapPeak, + peakExternalBytes: + record.externalMemorySampleCount > 0 + ? record.externalPeak + : null, + peakExternalUnavailableReason: + record.externalMemorySampleCount > 0 + ? null + : 'worker-external-memory-samples-missing', + peakHeapUsedBytes: + record.heapUsedSampleCount > 0 + ? record.heapPeak + : null, + peakHeapUsedUnavailableReason: + record.heapUsedSampleCount > 0 + ? null + : 'worker-heap-used-samples-missing', playlistId: record.playlistId, postGcHeapUnavailableReason: record.postGcHeapUnavailableReason, @@ -1245,6 +1384,7 @@ export async function installMainCapture( terminatedEpochMs: record.terminatedEpochMs, }, requests: record.requestPerformance.map((request) => ({ + ipcCallId: request.identity.ipcCallId, operation: request.operation, operationId: request.identity.operationId, operationIdUnavailableReason: @@ -1253,6 +1393,7 @@ export async function installMainCapture( playlistId: request.playlistId, requestId: request.requestId, responseEpochMs: request.responseEpochMs, + sourceEpochMs: request.identity.sourceEpochMs, success: request.success, })), })); diff --git a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts index 07a20c4d6..34f157d5a 100644 --- a/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -270,3 +270,34 @@ test('the cancellation benchmark exposes GC to Electron worker isolates', () => assert.match(source, /'--js-flags=--expose-gc'/); assert.doesNotMatch(source, /execArgv/); }); + +test('the initial M3U import benchmark uses the production-equivalent performance build', () => { + const target = e2eProject.targets['benchmark-m3u-import']; + + assert.ok(target, 'electron-backend-e2e must define benchmark-m3u-import'); + assert.deepEqual(target.dependsOn, ['electron-backend:build-performance']); +}); + +test('the initial M3U import benchmark command is pinned to its Playwright test file', () => { + const target = e2eProject.targets['benchmark-m3u-import']; + + assert.equal( + target.options?.['command'], + 'pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts' + ); +}); + +test('the initial M3U import benchmark enables opt-in capture and worker GC', () => { + const source = readFileSync( + join( + workspaceRoot, + 'apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts' + ), + 'utf8' + ); + + assert.match(source, /IPTVNATOR_PERF_CAPTURE:\s*'1'/); + assert.match(source, /IPTVNATOR_PERF_WORKER_PROFILING:\s*'1'/); + assert.match(source, /'--js-flags=--expose-gc'/); + assert.doesNotMatch(source, /execArgv/); +}); diff --git a/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts b/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts new file mode 100644 index 000000000..dd471db9b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + pairPerformancePhaseEvents, + requirePerformancePhase, +} from './performance-phase-events'; + +describe('performance phase event pairing', () => { + it('pairs request-scoped boundaries and preserves end metadata', () => { + const phases = pairPerformancePhaseEvents([ + { + boundary: 'start', + durationMs: null, + epochMs: 100, + phase: 'data.acquire', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 12.5, + epochMs: 113, + metadata: { byteCount: 2048, itemCount: 10 }, + phase: 'data.acquire', + requestId: 'request-1', + }, + ]); + + assert.deepEqual(phases, [ + { + durationMs: 12.5, + endedEpochMs: 113, + metadata: { byteCount: 2048, itemCount: 10 }, + phase: 'data.acquire', + requestId: 'request-1', + startedEpochMs: 100, + }, + ]); + assert.deepEqual( + requirePerformancePhase(phases, 'request-1', 'data.acquire'), + phases[0] + ); + }); + + it('pairs interleaved requests without cross-correlating them', () => { + const phases = pairPerformancePhaseEvents([ + { + boundary: 'start', + durationMs: null, + epochMs: 100, + phase: 'serialize.playlist', + requestId: 'request-a', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 101, + phase: 'serialize.playlist', + requestId: 'request-b', + }, + { + boundary: 'end', + durationMs: 3, + epochMs: 104, + phase: 'serialize.playlist', + requestId: 'request-b', + }, + { + boundary: 'end', + durationMs: 8, + epochMs: 108, + phase: 'serialize.playlist', + requestId: 'request-a', + }, + ]); + + assert.deepEqual( + phases.map(({ durationMs, requestId }) => ({ + durationMs, + requestId, + })), + [ + { durationMs: 8, requestId: 'request-a' }, + { durationMs: 3, requestId: 'request-b' }, + ] + ); + }); + + it('fails closed for orphaned, duplicate, malformed, or reversed boundaries', () => { + const validStart = { + boundary: 'start', + durationMs: null, + epochMs: 100, + phase: 'normalize', + requestId: 'request-1', + } as const; + const validEnd = { + boundary: 'end', + durationMs: 2, + epochMs: 102, + phase: 'normalize', + requestId: 'request-1', + } as const; + + for (const invalid of [ + [validStart], + [validStart, validStart, validEnd], + [validStart, { ...validEnd, durationMs: null }], + [validStart, { ...validEnd, epochMs: 99 }], + [{ ...validStart, durationMs: 0 }, validEnd], + [ + validStart, + { + ...validEnd, + metadata: { byteCount: -1 }, + }, + ], + ]) { + assert.throws( + () => pairPerformancePhaseEvents(invalid), + /performance-phase-events-invalid/ + ); + } + }); + + it('requires one exact request and phase match', () => { + const phases = pairPerformancePhaseEvents([ + { + boundary: 'start', + durationMs: null, + epochMs: 100, + phase: 'normalize', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 2, + epochMs: 102, + phase: 'normalize', + requestId: 'request-1', + }, + ]); + + assert.throws( + () => requirePerformancePhase(phases, 'request-2', 'normalize'), + /performance-phase-missing:request-2:normalize/ + ); + assert.throws( + () => + requirePerformancePhase( + [...phases, ...phases], + 'request-1', + 'normalize' + ), + /performance-phase-ambiguous:request-1:normalize/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/performance-phase-events.ts b/apps/electron-backend-e2e/src/performance/performance-phase-events.ts new file mode 100644 index 000000000..15468b018 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-phase-events.ts @@ -0,0 +1,166 @@ +import type { PerformancePhaseMetadata } from '@iptvnator/shared/interfaces'; + +export interface PairedPerformancePhase { + readonly durationMs: number; + readonly endedEpochMs: number; + readonly metadata: PerformancePhaseMetadata | null; + readonly phase: string; + readonly requestId: string; + readonly startedEpochMs: number; +} + +interface ParsedPerformancePhaseEvent { + readonly boundary: 'end' | 'start'; + readonly durationMs: number | null; + readonly epochMs: number; + readonly metadata: PerformancePhaseMetadata | null; + readonly phase: string; + readonly requestId: string; +} + +export function pairPerformancePhaseEvents( + input: readonly unknown[] +): readonly PairedPerformancePhase[] { + const grouped = new Map(); + + for (const value of input) { + const event = parsePerformancePhaseEvent(value); + const key = `${event.requestId}\u0000${event.phase}`; + const events = grouped.get(key) ?? []; + events.push(event); + if (events.length > 2) { + throw invalidPhaseEvents(); + } + grouped.set(key, events); + } + + const paired: PairedPerformancePhase[] = []; + for (const events of grouped.values()) { + const start = events.find((event) => event.boundary === 'start'); + const end = events.find((event) => event.boundary === 'end'); + if ( + events.length !== 2 || + !start || + !end || + start.durationMs !== null || + start.metadata !== null || + end.durationMs === null || + end.epochMs < start.epochMs + ) { + throw invalidPhaseEvents(); + } + paired.push( + Object.freeze({ + durationMs: end.durationMs, + endedEpochMs: end.epochMs, + metadata: end.metadata, + phase: start.phase, + requestId: start.requestId, + startedEpochMs: start.epochMs, + }) + ); + } + + paired.sort( + (left, right) => + left.startedEpochMs - right.startedEpochMs || + left.endedEpochMs - right.endedEpochMs || + left.requestId.localeCompare(right.requestId) || + left.phase.localeCompare(right.phase) + ); + return Object.freeze(paired); +} + +export function requirePerformancePhase( + phases: readonly PairedPerformancePhase[], + requestId: string, + phase: string +): PairedPerformancePhase { + const matches = phases.filter( + (candidate) => + candidate.requestId === requestId && candidate.phase === phase + ); + if (matches.length === 0) { + throw new Error(`performance-phase-missing:${requestId}:${phase}`); + } + if (matches.length !== 1 || !matches[0]) { + throw new Error(`performance-phase-ambiguous:${requestId}:${phase}`); + } + return matches[0]; +} + +function parsePerformancePhaseEvent( + value: unknown +): ParsedPerformancePhaseEvent { + if (!isRecord(value)) { + throw invalidPhaseEvents(); + } + const boundary = value['boundary']; + const durationMs = value['durationMs']; + const epochMs = value['epochMs']; + const phase = value['phase']; + const requestId = value['requestId']; + if ( + (boundary !== 'start' && boundary !== 'end') || + (durationMs !== null && !isFiniteNonNegativeNumber(durationMs)) || + !isFiniteNonNegativeNumber(epochMs) || + typeof phase !== 'string' || + phase.length === 0 || + typeof requestId !== 'string' || + requestId.length === 0 + ) { + throw invalidPhaseEvents(); + } + + return { + boundary, + durationMs, + epochMs, + metadata: parseMetadata(value['metadata']), + phase, + requestId, + }; +} + +function parseMetadata(value: unknown): PerformancePhaseMetadata | null { + if (value === undefined) { + return null; + } + if (!isRecord(value)) { + throw invalidPhaseEvents(); + } + const keys = Object.keys(value); + if ( + keys.some((key) => key !== 'byteCount' && key !== 'itemCount') || + keys.some((key) => !isSafeCount(value[key])) + ) { + throw invalidPhaseEvents(); + } + return Object.freeze({ + ...(value['byteCount'] === undefined + ? {} + : { byteCount: Number(value['byteCount']) }), + ...(value['itemCount'] === undefined + ? {} + : { itemCount: Number(value['itemCount']) }), + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isSafeCount(value: unknown): boolean { + return ( + value === undefined || + (Number.isSafeInteger(value) && Number(value) >= 0) + ); +} + +function invalidPhaseEvents(): Error { + return new Error('performance-phase-events-invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.spec.ts new file mode 100644 index 000000000..10c078015 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.spec.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + pairRendererPerformancePhaseEvents, + requireRendererPerformancePhase, +} from './renderer-performance-phase-events'; + +describe('renderer performance phase event pairing', () => { + it('pairs a successful renderer phase using monotonic duration', () => { + const phases = pairRendererPerformancePhaseEvents([ + { + boundary: 'start', + epochMs: 100, + metadata: { items: 10_000 }, + monotonicMs: 20, + phase: 'store.m3u-publish-channels', + phaseId: 'phase-1', + schemaVersion: 1, + }, + { + boundary: 'end', + epochMs: 106, + metadata: { items: 10_000 }, + monotonicMs: 25.5, + outcome: 'success', + phase: 'store.m3u-publish-channels', + phaseId: 'phase-1', + schemaVersion: 1, + }, + ]); + + assert.deepEqual(phases, [ + { + durationMs: 5.5, + endedEpochMs: 106, + metadata: { items: 10_000 }, + outcome: 'success', + phase: 'store.m3u-publish-channels', + phaseId: 'phase-1', + startedEpochMs: 100, + }, + ]); + assert.deepEqual( + requireRendererPerformancePhase( + phases, + 'store.m3u-publish-channels' + ), + phases[0] + ); + }); + + it('fails closed for inconsistent identity, metadata, outcome, and clocks', () => { + const start = { + boundary: 'start', + epochMs: 100, + metadata: { items: 50_000 }, + monotonicMs: 20, + phase: 'store.m3u-import-dispatch', + phaseId: 'phase-1', + schemaVersion: 1, + } as const; + const end = { + boundary: 'end', + epochMs: 102, + metadata: { items: 50_000 }, + monotonicMs: 22, + outcome: 'success', + phase: 'store.m3u-import-dispatch', + phaseId: 'phase-1', + schemaVersion: 1, + } as const; + + for (const invalid of [ + [start], + [start, start, end], + [start, { ...end, phase: 'store.m3u-publish-channels' }], + [start, { ...end, metadata: { items: 10_000 } }], + [{ ...start, outcome: 'success' }, end], + [start, { ...end, outcome: undefined }], + [start, { ...end, epochMs: 99 }], + [start, { ...end, monotonicMs: 19 }], + [start, { ...end, metadata: { items: -1 } }], + ]) { + assert.throws( + () => pairRendererPerformancePhaseEvents(invalid), + /renderer-performance-phase-events-invalid/ + ); + } + }); + + it('requires exactly one successful event for a named phase', () => { + const successful = { + durationMs: 2, + endedEpochMs: 102, + metadata: { items: 10_000 }, + outcome: 'success', + phase: 'store.m3u-import-dispatch', + phaseId: 'phase-1', + startedEpochMs: 100, + } as const; + const failed = { + ...successful, + outcome: 'error', + phaseId: 'phase-2', + } as const; + + assert.throws( + () => + requireRendererPerformancePhase( + [successful], + 'store.m3u-publish-channels' + ), + /renderer-performance-phase-missing:store.m3u-publish-channels/ + ); + assert.throws( + () => + requireRendererPerformancePhase( + [successful, successful], + 'store.m3u-import-dispatch' + ), + /renderer-performance-phase-ambiguous:store.m3u-import-dispatch/ + ); + assert.throws( + () => + requireRendererPerformancePhase( + [failed], + 'store.m3u-import-dispatch' + ), + /renderer-performance-phase-failed:store.m3u-import-dispatch/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.ts b/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.ts new file mode 100644 index 000000000..23cb976e8 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-performance-phase-events.ts @@ -0,0 +1,163 @@ +export interface PairedRendererPerformancePhase { + readonly durationMs: number; + readonly endedEpochMs: number; + readonly metadata: Readonly<{ items: number }> | null; + readonly outcome: 'error' | 'success'; + readonly phase: string; + readonly phaseId: string; + readonly startedEpochMs: number; +} + +interface ParsedRendererPerformancePhaseEvent { + readonly boundary: 'end' | 'start'; + readonly epochMs: number; + readonly metadata: Readonly<{ items: number }> | null; + readonly monotonicMs: number; + readonly outcome: 'error' | 'success' | null; + readonly phase: string; + readonly phaseId: string; +} + +export function pairRendererPerformancePhaseEvents( + input: readonly unknown[] +): readonly PairedRendererPerformancePhase[] { + const grouped = new Map(); + + for (const value of input) { + const event = parseRendererPerformancePhaseEvent(value); + const events = grouped.get(event.phaseId) ?? []; + events.push(event); + if (events.length > 2) { + throw invalidRendererEvents(); + } + grouped.set(event.phaseId, events); + } + + const paired: PairedRendererPerformancePhase[] = []; + for (const events of grouped.values()) { + const start = events.find((event) => event.boundary === 'start'); + const end = events.find((event) => event.boundary === 'end'); + if ( + events.length !== 2 || + !start || + !end || + start.outcome !== null || + end.outcome === null || + start.phase !== end.phase || + !sameMetadata(start.metadata, end.metadata) || + end.epochMs < start.epochMs || + end.monotonicMs < start.monotonicMs + ) { + throw invalidRendererEvents(); + } + paired.push( + Object.freeze({ + durationMs: end.monotonicMs - start.monotonicMs, + endedEpochMs: end.epochMs, + metadata: end.metadata, + outcome: end.outcome, + phase: end.phase, + phaseId: end.phaseId, + startedEpochMs: start.epochMs, + }) + ); + } + + paired.sort( + (left, right) => + left.startedEpochMs - right.startedEpochMs || + left.endedEpochMs - right.endedEpochMs || + left.phaseId.localeCompare(right.phaseId) + ); + return Object.freeze(paired); +} + +export function requireRendererPerformancePhase( + phases: readonly PairedRendererPerformancePhase[], + phase: string +): PairedRendererPerformancePhase { + const matches = phases.filter((candidate) => candidate.phase === phase); + if (matches.length === 0) { + throw new Error(`renderer-performance-phase-missing:${phase}`); + } + if (matches.length !== 1 || !matches[0]) { + throw new Error(`renderer-performance-phase-ambiguous:${phase}`); + } + if (matches[0].outcome !== 'success') { + throw new Error(`renderer-performance-phase-failed:${phase}`); + } + return matches[0]; +} + +function parseRendererPerformancePhaseEvent( + value: unknown +): ParsedRendererPerformancePhaseEvent { + if (!isRecord(value) || value['schemaVersion'] !== 1) { + throw invalidRendererEvents(); + } + const boundary = value['boundary']; + const epochMs = value['epochMs']; + const monotonicMs = value['monotonicMs']; + const outcome = value['outcome']; + const phase = value['phase']; + const phaseId = value['phaseId']; + if ( + (boundary !== 'start' && boundary !== 'end') || + !isFiniteNonNegativeNumber(epochMs) || + !isFiniteNonNegativeNumber(monotonicMs) || + typeof phase !== 'string' || + phase.length === 0 || + typeof phaseId !== 'string' || + phaseId.length === 0 || + (boundary === 'start' && outcome !== undefined) || + (boundary === 'end' && outcome !== 'success' && outcome !== 'error') + ) { + throw invalidRendererEvents(); + } + + return { + boundary, + epochMs, + metadata: parseMetadata(value['metadata']), + monotonicMs, + outcome: outcome === 'success' || outcome === 'error' ? outcome : null, + phase, + phaseId, + }; +} + +function parseMetadata(value: unknown): Readonly<{ items: number }> | null { + if (value === undefined) { + return null; + } + if ( + !isRecord(value) || + Object.keys(value).length !== 1 || + !Number.isSafeInteger(value['items']) || + Number(value['items']) < 0 + ) { + throw invalidRendererEvents(); + } + return Object.freeze({ items: Number(value['items']) }); +} + +function sameMetadata( + left: Readonly<{ items: number }> | null, + right: Readonly<{ items: number }> | null +): boolean { + return left === null || right === null + ? left === right + : left.items === right.items; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function invalidRendererEvents(): Error { + return new Error('renderer-performance-phase-events-invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/renderer-rss-validity.spec.ts b/apps/electron-backend-e2e/src/performance/renderer-rss-validity.spec.ts new file mode 100644 index 000000000..2e94cee01 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-rss-validity.spec.ts @@ -0,0 +1,172 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +interface TestIteration { + readonly kind: 'diagnostic' | 'measured' | 'warmup'; + readonly main: { + readonly rendererWindow: { + readonly rss: { + readonly identity: { + readonly creationTime: number; + readonly pid: number; + } | null; + readonly missingSampleCount: number; + readonly peakRssBytes: number | null; + readonly unavailableReason: string | null; + readonly validSampleCount: number; + }; + }; + }; + readonly runId: string; +} + +interface RendererRssValidity { + readonly invalidMeasuredRuns: readonly { + readonly missingSampleCount: number; + readonly reason: string; + readonly runId: string; + readonly validSampleCount: number; + }[]; + readonly measuredRunCount: number; + readonly validForBenchmark: boolean; + readonly validForComparison: boolean; + readonly validMeasuredRunCount: number; +} + +interface RendererRssValidityModule { + assessRendererRssValidity?: ( + iterations: readonly TestIteration[] + ) => RendererRssValidity; +} + +const validityModulePromise = import( + new URL('./renderer-rss-validity.ts', import.meta.url).href +) + .then((module) => module as RendererRssValidityModule) + .catch(() => null); + +function iteration( + runId: string, + kind: TestIteration['kind'], + input: Partial = {} +): TestIteration { + return { + kind, + main: { + rendererWindow: { + rss: { + identity: { + creationTime: 1_721_234_567_890, + pid: 42, + }, + missingSampleCount: 0, + peakRssBytes: 4_096, + unavailableReason: null, + validSampleCount: 3, + ...input, + }, + }, + }, + runId, + }; +} + +test('validates only measured renderer RSS runs and preserves the exact summary shape', async () => { + const module = await validityModulePromise; + assert.ok(module, 'generic renderer RSS validity helper must exist'); + const assess = module.assessRendererRssValidity; + assert.equal(typeof assess, 'function'); + + const result = assess([ + iteration('warmup-invalid', 'warmup', { identity: null }), + iteration('run-01', 'measured'), + iteration('diagnostic-invalid', 'diagnostic', { + unavailableReason: 'renderer-process-gone', + }), + ]); + + assert.deepEqual(result, { + invalidMeasuredRuns: [], + measuredRunCount: 1, + validForBenchmark: true, + validForComparison: true, + validMeasuredRunCount: 1, + }); + assert.ok(Object.isFrozen(result)); + assert.ok(Object.isFrozen(result.invalidMeasuredRuns)); +}); + +test('fails every incomplete measured capture without losing its raw reason and counts', async () => { + const module = await validityModulePromise; + assert.ok(module); + const assess = module.assessRendererRssValidity; + assert.equal(typeof assess, 'function'); + + const result = assess([ + iteration('run-no-identity', 'measured', { identity: null }), + iteration('run-missing-sample', 'measured', { + missingSampleCount: 1, + peakRssBytes: null, + unavailableReason: 'renderer-process-metric-missing-during-capture', + validSampleCount: 2, + }), + iteration('run-zero-peak', 'measured', { peakRssBytes: 0 }), + ]); + + assert.deepEqual(result, { + invalidMeasuredRuns: [ + { + missingSampleCount: 0, + reason: 'renderer-rss-capture-invalid', + runId: 'run-no-identity', + validSampleCount: 3, + }, + { + missingSampleCount: 1, + reason: 'renderer-process-metric-missing-during-capture', + runId: 'run-missing-sample', + validSampleCount: 2, + }, + { + missingSampleCount: 0, + reason: 'renderer-rss-capture-invalid', + runId: 'run-zero-peak', + validSampleCount: 3, + }, + ], + measuredRunCount: 3, + validForBenchmark: false, + validForComparison: false, + validMeasuredRunCount: 0, + }); +}); + +test('requires at least one measured run', async () => { + const module = await validityModulePromise; + assert.ok(module); + const assess = module.assessRendererRssValidity; + assert.equal(typeof assess, 'function'); + + assert.deepEqual(assess([iteration('warmup-01', 'warmup')]), { + invalidMeasuredRuns: [], + measuredRunCount: 0, + validForBenchmark: false, + validForComparison: false, + validMeasuredRunCount: 0, + }); +}); + +test('the cancellation report delegates renderer RSS validity to the generic helper', () => { + const source = readFileSync( + new URL('./m3u-refresh-cancellation-report.ts', import.meta.url), + 'utf8' + ); + + assert.match( + source, + /import \{ assessRendererRssValidity \} from '\.\/renderer-rss-validity';/ + ); + assert.doesNotMatch(source, /function assessRendererRssValidity\(/); +}); diff --git a/apps/electron-backend-e2e/src/performance/renderer-rss-validity.ts b/apps/electron-backend-e2e/src/performance/renderer-rss-validity.ts new file mode 100644 index 000000000..ccab61639 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/renderer-rss-validity.ts @@ -0,0 +1,72 @@ +import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; + +export interface RendererRssValidityIteration { + readonly kind: string; + readonly main: { + readonly rendererWindow: { + readonly rss: RendererProcessRssCapture; + }; + }; + readonly runId: string; +} + +export interface InvalidRendererRssMeasuredRun { + readonly missingSampleCount: number; + readonly reason: + | Exclude + | 'renderer-rss-capture-invalid'; + readonly runId: string; + readonly validSampleCount: number; +} + +export interface RendererRssValidity { + readonly invalidMeasuredRuns: readonly InvalidRendererRssMeasuredRun[]; + readonly measuredRunCount: number; + readonly validForBenchmark: boolean; + readonly validForComparison: boolean; + readonly validMeasuredRunCount: number; +} + +export function assessRendererRssValidity( + iterations: readonly RendererRssValidityIteration[] +): RendererRssValidity { + const measured = iterations.filter( + (iteration) => iteration.kind === 'measured' + ); + const invalidMeasuredRuns: RendererRssValidity['invalidMeasuredRuns'][number][] = + []; + let validMeasuredRunCount = 0; + + for (const iteration of measured) { + const rss = iteration.main.rendererWindow.rss; + if ( + rss.identity !== null && + Number.isSafeInteger(rss.peakRssBytes) && + Number(rss.peakRssBytes) > 0 && + rss.unavailableReason === null && + rss.missingSampleCount === 0 && + Number.isSafeInteger(rss.validSampleCount) && + rss.validSampleCount > 0 + ) { + validMeasuredRunCount += 1; + continue; + } + + invalidMeasuredRuns.push({ + missingSampleCount: rss.missingSampleCount, + reason: rss.unavailableReason ?? 'renderer-rss-capture-invalid', + runId: iteration.runId, + validSampleCount: rss.validSampleCount, + }); + } + + const valid = + measured.length > 0 && validMeasuredRunCount === measured.length; + return Object.freeze({ + invalidMeasuredRuns: Object.freeze(invalidMeasuredRuns), + measuredRunCount: measured.length, + validForBenchmark: valid, + validForComparison: valid, + validMeasuredRunCount, + }); +} diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.spec.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.spec.ts new file mode 100644 index 000000000..2d6e87a49 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.spec.ts @@ -0,0 +1,49 @@ +/* eslint-disable playwright/expect-expect -- Node assertions define the loopback-only fixture server contract. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createSyntheticM3uFixture } from './synthetic-m3u'; +import { startSyntheticM3uServer } from './synthetic-m3u-server'; + +test('serves and primes only the deterministic fixture on ephemeral IPv4 loopback', async () => { + const fixture = createSyntheticM3uFixture(10_000); + const server = await startSyntheticM3uServer(fixture); + + try { + const url = new URL(server.resourceUrl); + assert.equal(url.protocol, 'http:'); + assert.equal(url.hostname, '127.0.0.1'); + assert.notEqual(url.port, ''); + assert.equal(url.username, ''); + assert.equal(url.password, ''); + assert.equal(server.requestCount(), 0); + + await server.prime(); + assert.equal(server.requestCount(), 1); + + const response = await fetch(server.resourceUrl); + assert.equal(response.status, 200); + assert.equal( + response.headers.get('content-length'), + String(fixture.bytes) + ); + assert.equal(await response.text(), fixture.body); + assert.equal(server.requestCount(), 2); + + const missing = await fetch(`${server.origin}/not-the-fixture.m3u`); + assert.equal(missing.status, 404); + assert.equal(server.requestCount(), 2); + } finally { + await server.close(); + } +}); + +test('closes idempotently and rejects priming after close', async () => { + const server = await startSyntheticM3uServer( + createSyntheticM3uFixture(10_000) + ); + + await server.close(); + await server.close(); + await assert.rejects(server.prime(), /closed/i); +}); diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.ts new file mode 100644 index 000000000..269c27f45 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u-server.ts @@ -0,0 +1,114 @@ +import { once } from 'node:events'; +import { createServer, type Server } from 'node:http'; + +import type { SyntheticM3uFixture } from './synthetic-m3u'; + +export const SYNTHETIC_M3U_RESOURCE_PATH = '/synthetic-performance.m3u'; + +export interface SyntheticM3uServer { + readonly origin: string; + readonly resourceUrl: string; + close(): Promise; + prime(): Promise; + requestCount(): number; +} + +export async function startSyntheticM3uServer( + fixture: SyntheticM3uFixture +): Promise { + let successfulRequestCount = 0; + const server = createServer((request, response) => { + const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1'); + if ( + request.method !== 'GET' || + requestUrl.pathname !== SYNTHETIC_M3U_RESOURCE_PATH || + requestUrl.search.length > 0 + ) { + response.writeHead(404, { + 'Content-Length': '0', + 'Content-Type': 'text/plain; charset=utf-8', + }); + response.end(); + return; + } + + successfulRequestCount += 1; + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Length': String(fixture.bytes), + 'Content-Type': 'application/vnd.apple.mpegurl; charset=utf-8', + }); + response.end(fixture.body); + }); + server.listen({ + exclusive: true, + host: '127.0.0.1', + port: 0, + }); + try { + await once(server, 'listening'); + } catch (error: unknown) { + await closeServer(server).catch(() => undefined); + throw error; + } + + const address = server.address(); + if ( + address === null || + typeof address === 'string' || + address.address !== '127.0.0.1' || + !Number.isSafeInteger(address.port) || + address.port <= 0 + ) { + await closeServer(server); + throw new Error( + 'Synthetic M3U server did not bind exact IPv4 loopback' + ); + } + + const origin = `http://127.0.0.1:${address.port}`; + const resourceUrl = `${origin}${SYNTHETIC_M3U_RESOURCE_PATH}`; + let closePromise: Promise | null = null; + + return Object.freeze({ + origin, + resourceUrl, + close: () => { + closePromise ??= closeServer(server); + return closePromise; + }, + prime: async () => { + if (closePromise !== null) { + throw new Error('Synthetic M3U server is closed'); + } + const response = await fetch(resourceUrl); + const body = await response.arrayBuffer(); + if ( + response.status !== 200 || + Number(response.headers.get('content-length')) !== + fixture.bytes || + body.byteLength !== fixture.bytes + ) { + throw new Error( + 'Synthetic M3U server priming response was invalid' + ); + } + }, + requestCount: () => successfulRequestCount, + }); +} + +async function closeServer(server: Server): Promise { + if (!server.listening) { + return; + } + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => { + if (error) { + rejectPromise(error); + return; + } + resolvePromise(); + }); + }); +} diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u.spec.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u.spec.ts new file mode 100644 index 000000000..094f4f80d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u.spec.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { describe, it } from 'node:test'; + +import { + createSyntheticM3uFixture, + SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS, + SYNTHETIC_M3U_SEED, +} from './synthetic-m3u'; + +const GOLDEN_SHA256 = { + 10_000: '1d151ab2a878db8818f2bbfbdd689007cbe390182cdd1520c316bd163b7e7db3', + 50_000: '4a5336a5d1c66b8b706752784815617a012499cb7a231ab35c88df9bd55ee78a', + 100_000: '2ffe912f61ea106ba8de3414b0a927cdaa3264ac637d44c4b219f199eb94c353', +} as const; + +describe('synthetic M3U performance fixture', () => { + it('generates each supported catalog deterministically with exact metadata', () => { + assert.deepEqual( + SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS, + [10_000, 50_000, 100_000] + ); + assert.equal(SYNTHETIC_M3U_SEED, 240_724); + + for (const channelCount of SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS) { + const fixture = createSyntheticM3uFixture(channelCount); + const lines = fixture.body.trimEnd().split('\n'); + + assert.equal(fixture.channelCount, channelCount); + assert.equal( + fixture.bytes, + Buffer.byteLength(fixture.body, 'utf8') + ); + assert.equal( + fixture.sha256, + createHash('sha256').update(fixture.body, 'utf8').digest('hex') + ); + assert.equal(fixture.sha256, GOLDEN_SHA256[channelCount]); + assert.equal(lines[0], '#EXTM3U'); + assert.equal(lines.length, 1 + channelCount * 2); + assert.equal( + lines.filter((line) => line.startsWith('#EXTINF:')).length, + channelCount + ); + assert.doesNotMatch(fixture.body, /\b(?:tvg-|logo|provider)\b/i); + assertOnlyLoopbackUrls(fixture.body); + } + }); + + it('rejects unsupported channel counts', () => { + assert.throws( + () => createSyntheticM3uFixture(9_999), + /Unsupported synthetic M3U channel count/ + ); + }); +}); + +function assertOnlyLoopbackUrls(body: string): void { + const urls = body.match(/https?:\/\/[^\s"]+/g) ?? []; + + for (const rawUrl of urls) { + const url = new URL(rawUrl); + assert.equal(url.protocol, 'http:'); + assert.equal(url.hostname, '127.0.0.1'); + assert.equal(url.username, ''); + assert.equal(url.password, ''); + } +} diff --git a/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts b/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts index 14e65387d..1cdf8d18b 100644 --- a/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts +++ b/apps/electron-backend-e2e/src/performance/synthetic-m3u.ts @@ -2,19 +2,30 @@ import { createHash } from 'node:crypto'; export const SYNTHETIC_M3U_SEED = 240_724; export const SYNTHETIC_M3U_CHANNEL_COUNT = 100_000; +export const SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS = [ + 10_000, + 50_000, + SYNTHETIC_M3U_CHANNEL_COUNT, +] as const; + +export type SyntheticM3uChannelCount = + (typeof SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS)[number]; export interface SyntheticM3uFixture { readonly body: string; readonly bytes: number; - readonly channelCount: number; + readonly channelCount: SyntheticM3uChannelCount; readonly sha256: string; } -export function createSyntheticM3uFixture(): SyntheticM3uFixture { - const lines = new Array(1 + SYNTHETIC_M3U_CHANNEL_COUNT * 2); +export function createSyntheticM3uFixture( + channelCount: number = SYNTHETIC_M3U_CHANNEL_COUNT +): SyntheticM3uFixture { + assertSupportedChannelCount(channelCount); + const lines = new Array(1 + channelCount * 2); lines[0] = '#EXTM3U'; - for (let offset = 0; offset < SYNTHETIC_M3U_CHANNEL_COUNT; offset += 1) { + for (let offset = 0; offset < channelCount; offset += 1) { const index = offset + 1; const group = (offset % 100) + 1; const lineOffset = 1 + offset * 2; @@ -34,7 +45,21 @@ export function createSyntheticM3uFixture(): SyntheticM3uFixture { return Object.freeze({ body, bytes: Buffer.byteLength(body, 'utf8'), - channelCount: SYNTHETIC_M3U_CHANNEL_COUNT, + channelCount, sha256: createHash('sha256').update(body, 'utf8').digest('hex'), }); } + +function assertSupportedChannelCount( + channelCount: number +): asserts channelCount is SyntheticM3uChannelCount { + if ( + !SUPPORTED_SYNTHETIC_M3U_CHANNEL_COUNTS.some( + (supported) => supported === channelCount + ) + ) { + throw new Error( + `Unsupported synthetic M3U channel count: ${channelCount}` + ); + } +} diff --git a/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts b/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts new file mode 100644 index 000000000..1423da627 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts @@ -0,0 +1,149 @@ +import { + APP_PLAYLIST_GET_PERFORMANCE_PHASE, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + type AppPlaylistPerformancePhase, + type PerformancePhaseEvent, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; + +const PHASE_SEQUENCES = { + DB_GET_APP_PLAYLIST: [ + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST, + ], + DB_UPSERT_APP_PLAYLIST: [ + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + ], +} as const satisfies Readonly< + Record +>; +const EVENT_KEYS = new Set([ + 'boundary', + 'durationMs', + 'epochMs', + 'metadata', + 'phase', + 'requestId', +]); + +export function parseWorkerPerformancePhaseEvents( + operation: string | null, + input: unknown, + workStartedEpochMs: number, + workEndedEpochMs: number +): readonly PerformancePhaseEvent[] | null { + const phaseSequence = getPhaseSequence(operation); + if (phaseSequence.length === 0) { + return input === undefined || + (Array.isArray(input) && input.length === 0) + ? [] + : null; + } + if ( + !Array.isArray(input) || + input.length !== phaseSequence.length * 2 + ) { + return null; + } + + const parsed: PerformancePhaseEvent[] = + []; + let lastEpochMs = workStartedEpochMs; + for (let index = 0; index < input.length; index += 1) { + const raw = input[index]; + if ( + !isRecord(raw) || + Object.keys(raw).some((key) => !EVENT_KEYS.has(key)) + ) { + return null; + } + + const boundary = raw['boundary']; + const durationMs = raw['durationMs']; + const epochMs = raw['epochMs']; + const metadata = parseMetadata(raw['metadata']); + const phase = raw['phase']; + const requestId = raw['requestId']; + if ( + boundary !== (index % 2 === 0 ? 'start' : 'end') || + phase !== phaseSequence[Math.floor(index / 2)] || + typeof requestId !== 'string' || + requestId.length === 0 || + !isFiniteNonNegativeNumber(epochMs) || + epochMs < lastEpochMs || + epochMs > workEndedEpochMs || + (boundary === 'start' && + (durationMs !== null || metadata !== undefined)) || + (boundary === 'end' && !isFiniteNonNegativeNumber(durationMs)) || + metadata === null || + (parsed.length > 0 && parsed[0]?.requestId !== requestId) + ) { + return null; + } + + parsed.push({ + boundary: boundary as 'end' | 'start', + durationMs: durationMs as number | null, + epochMs, + ...(metadata === undefined ? {} : { metadata }), + phase: phase as AppPlaylistPerformancePhase, + requestId, + }); + lastEpochMs = epochMs; + } + return parsed; +} + +function getPhaseSequence( + operation: string | null +): readonly AppPlaylistPerformancePhase[] { + if (operation === 'DB_GET_APP_PLAYLIST') { + return PHASE_SEQUENCES.DB_GET_APP_PLAYLIST; + } + if (operation === 'DB_UPSERT_APP_PLAYLIST') { + return PHASE_SEQUENCES.DB_UPSERT_APP_PLAYLIST; + } + return []; +} + +function parseMetadata( + input: unknown +): PerformancePhaseMetadata | undefined | null { + if (input === undefined) { + return undefined; + } + if ( + !isRecord(input) || + Object.keys(input).some( + (key) => key !== 'byteCount' && key !== 'itemCount' + ) + ) { + return null; + } + + const byteCount = input['byteCount']; + const itemCount = input['itemCount']; + if ( + (byteCount !== undefined && !isSafeCount(byteCount)) || + (itemCount !== undefined && !isSafeCount(itemCount)) + ) { + return null; + } + return { + ...(byteCount === undefined ? {} : { byteCount: Number(byteCount) }), + ...(itemCount === undefined ? {} : { itemCount: Number(itemCount) }), + }; +} + +function isRecord(input: unknown): input is Record { + return typeof input === 'object' && input !== null && !Array.isArray(input); +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isSafeCount(value: unknown): boolean { + return Number.isSafeInteger(value) && Number(value) >= 0; +} diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts index e87e3c345..59bc4ecc6 100644 --- a/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts @@ -13,6 +13,7 @@ const validCapture = { histogramFlushedEpochMs: 1_050, invalidReason: null, requestReceivedEpochMs: 1_000, + responsePostedEpochMs: 1_055, threadCpuSystemMicros: 50, threadCpuUnavailableReason: null, threadCpuUserMicros: 100, @@ -20,19 +21,28 @@ const validCapture = { workStartedEpochMs: 1_010, } as const; -function normalize(performanceCapture: unknown) { +function normalizeForOperation( + operation: string, + performanceCapture: unknown +) { return normalizeWorkerRequestPerformanceOutcome({ - operation: 'DB_GET_APP_PLAYLIST', + ipcCallId: 2, + operation, operationId: 'operation-42', operationIdUnavailableReason: null, performanceCapture, playlistId: 'playlist-1', requestId: 'request-1', responseEpochMs: 1_060, + sourceEpochMs: 990, success: true, }); } +function normalize(performanceCapture: unknown) { + return normalizeForOperation('DB_GET_CONTENT', performanceCapture); +} + function assertClosedCapture( capture: WorkerRequestPerformanceMetrics, reason: string @@ -47,6 +57,7 @@ function assertClosedCapture( capture.histogramFlushedEpochMs, capture.invalidReason, capture.requestReceivedEpochMs, + capture.responsePostedEpochMs, capture.threadCpuSystemMicros, capture.threadCpuUnavailableReason, capture.threadCpuUserMicros, @@ -55,6 +66,7 @@ function assertClosedCapture( ]) { assert.equal(value, null); } + assert.deepEqual(capture.phaseEvents, []); } test('absent captures are missing while present non-record captures are invalid', () => { @@ -118,6 +130,9 @@ test('malformed nested fields and cross-field invariants fail the whole capture histogramFlushedEpochMs: null, }, { ...validCapture, histogramFlushedEpochMs: 1_039 }, + { ...validCapture, responsePostedEpochMs: undefined }, + { ...validCapture, responsePostedEpochMs: 1_039 }, + { ...validCapture, responsePostedEpochMs: 1_061 }, { ...validCapture, workStartedEpochMs: 999 }, { ...validCapture, @@ -170,3 +185,143 @@ test('coherent metric-unavailable and invalid worker results remain valid raw ca assert.equal(legitimateInvalid.performanceCaptureUnavailableReason, null); assert.equal(legitimateInvalid.invalidReason, invalidReason); }); + +test('requires the exact operation-specific worker phase sequence', () => { + const upsertPhaseEvents = [ + { + boundary: 'start', + durationMs: null, + epochMs: 1_015, + phase: 'serialize.playlist', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 5, + epochMs: 1_020, + metadata: { byteCount: 2048, itemCount: 10_000 }, + phase: 'serialize.playlist', + requestId: 'request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 1_021, + phase: 'sqlite.write', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 12, + epochMs: 1_033, + metadata: { byteCount: 2048, itemCount: 1 }, + phase: 'sqlite.write', + requestId: 'request-1', + }, + ] as const; + const getPhaseEvents = [ + { + boundary: 'start', + durationMs: null, + epochMs: 1_015, + phase: 'sqlite.read', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 5, + epochMs: 1_020, + metadata: { itemCount: 1 }, + phase: 'sqlite.read', + requestId: 'request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 1_021, + phase: 'deserialize.playlist', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 12, + epochMs: 1_033, + metadata: { itemCount: 10_000 }, + phase: 'deserialize.playlist', + requestId: 'request-1', + }, + ] as const; + + assert.deepEqual( + normalizeForOperation('DB_UPSERT_APP_PLAYLIST', { + ...validCapture, + phaseEvents: upsertPhaseEvents, + }).phaseEvents, + upsertPhaseEvents + ); + assert.deepEqual( + normalizeForOperation('DB_GET_APP_PLAYLIST', { + ...validCapture, + phaseEvents: getPhaseEvents, + }).phaseEvents, + getPhaseEvents + ); + + for (const invalidPhaseEvents of [ + getPhaseEvents.slice(0, 2), + getPhaseEvents.slice(1), + [...getPhaseEvents].reverse(), + getPhaseEvents.map((event) => ({ + ...event, + requestId: 'other-request', + })), + [ + getPhaseEvents[0], + { + ...getPhaseEvents[1], + metadata: { itemCount: 1, secret: 'not allowed' }, + }, + ], + [ + getPhaseEvents[0], + { + ...getPhaseEvents[1], + phase: 'arbitrary.phase', + }, + ], + ]) { + assertClosedCapture( + normalizeForOperation('DB_GET_APP_PLAYLIST', { + ...validCapture, + phaseEvents: invalidPhaseEvents, + }), + 'worker-performance-capture-invalid' + ); + } + + for (const [operation, phaseEvents] of [ + ['DB_GET_APP_PLAYLIST', upsertPhaseEvents], + ['DB_UPSERT_APP_PLAYLIST', getPhaseEvents], + ['DB_GET_CONTENT', getPhaseEvents], + ] as const) { + assertClosedCapture( + normalizeForOperation(operation, { + ...validCapture, + phaseEvents, + }), + 'worker-performance-capture-invalid' + ); + } + + for (const operation of [ + 'DB_GET_APP_PLAYLIST', + 'DB_UPSERT_APP_PLAYLIST', + ]) { + assertClosedCapture( + normalizeForOperation(operation, validCapture), + 'worker-performance-capture-invalid' + ); + } + + assert.deepEqual(normalize(validCapture).phaseEvents, []); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts index c96ec7f03..e5149e442 100644 --- a/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts @@ -35,9 +35,10 @@ function requestPerformance( eventLoopDelayUnavailableReason: null, eventLoopUtilization: p95Ms / 100, eventLoopUtilizationUnavailableReason: null, - histogramFlushedEpochMs: 1_050 + p95Ms, + histogramFlushedEpochMs: 1_050, invalidReason: null, requestReceivedEpochMs: 1_000, + responsePostedEpochMs: 1_055, threadCpuSystemMicros: threadCpuUserMicros / 2, threadCpuUnavailableReason: null, threadCpuUserMicros, @@ -53,13 +54,15 @@ function requestTransport( requestId = 'request-1' ) { return { - operation: 'DB_GET_APP_PLAYLIST', + ipcCallId: 7, + operation: 'DB_GET_CONTENT', operationId: 'operation-42', operationIdUnavailableReason: null, performanceCapture, playlistId: 'playlist-1', requestId, responseEpochMs: 1_060, + sourceEpochMs: 990, success: true, } as const; } @@ -77,6 +80,7 @@ function assertClosedCapture( capture.histogramFlushedEpochMs, capture.invalidReason, capture.requestReceivedEpochMs, + capture.responsePostedEpochMs, capture.threadCpuSystemMicros, capture.threadCpuUnavailableReason, capture.threadCpuUserMicros, @@ -220,8 +224,10 @@ test('main capture retains raw request identity, timestamps, metrics, and reason assert.match(source, /requestPerformance: \[\]/); assert.match(source, /record\.requestPerformance\.map/); assert.match(source, /operationId: request\.identity\.operationId/); + assert.match(source, /ipcCallId: request\.identity\.ipcCallId/); assert.match(source, /identity: request\.identity/); assert.match(source, /performanceCapture:\s+message\['performance'\]/); + assert.match(source, /sourceEpochMs: request\.identity\.sourceEpochMs/); assert.match(source, /captureGeneration: state\.captureGeneration/); assert.doesNotMatch( source, @@ -229,6 +235,13 @@ test('main capture retains raw request identity, timestamps, metrics, and reason ); }); +test('normalized raw request metrics retain the exact preload IPC call identity', () => { + const capture = requestPerformance('request-1', 5, 100); + + assert.equal(capture.ipcCallId, 7); + assert.equal(capture.sourceEpochMs, 990); +}); + test('benchmark resolves one exact BrowserWindow and main capture never scans all windows', () => { const benchmarkSource = readFileSync( new URL('./m3u-refresh-cancellation.benchmark.ts', import.meta.url), diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts index d348cd71c..4a5cc00ac 100644 --- a/apps/electron-backend-e2e/src/performance/worker-request-performance.ts +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts @@ -6,8 +6,10 @@ import type { WorkerRequestPerformanceMetrics, } from './m3u-refresh-cancellation-contract'; import { WORKER_POST_GC_HEAP_UNAVAILABLE_REASON } from './m3u-refresh-cancellation-contract'; +import { parseWorkerPerformancePhaseEvents } from './worker-performance-phase-events'; export interface WorkerRequestPerformanceOutcomeTransport { + readonly ipcCallId: number | null; readonly operation: string | null; readonly operationId: string | null; readonly operationIdUnavailableReason: string | null; @@ -15,6 +17,7 @@ export interface WorkerRequestPerformanceOutcomeTransport { readonly playlistId: string | null; readonly requestId: string | null; readonly responseEpochMs: number; + readonly sourceEpochMs: number | null; readonly success: boolean; } @@ -39,7 +42,9 @@ type ParsedWorkerPerformanceCapture = Pick< | 'eventLoopUtilizationUnavailableReason' | 'histogramFlushedEpochMs' | 'invalidReason' + | 'phaseEvents' | 'requestReceivedEpochMs' + | 'responsePostedEpochMs' | 'threadCpuSystemMicros' | 'threadCpuUnavailableReason' | 'threadCpuUserMicros' @@ -146,7 +151,8 @@ function parseEventLoopDelay( } function parseWorkerPerformanceCapture( - input: unknown + input: unknown, + operation: string | null ): ParsedWorkerPerformanceCapture | null { if (!isRecord(input)) { return null; @@ -161,6 +167,7 @@ function parseWorkerPerformanceCapture( const histogramFlushedEpochMs = input['histogramFlushedEpochMs']; const invalidReason = input['invalidReason']; const requestReceivedEpochMs = input['requestReceivedEpochMs']; + const responsePostedEpochMs = input['responsePostedEpochMs']; const threadCpuSystemMicros = input['threadCpuSystemMicros']; const threadCpuUnavailableReason = input['threadCpuUnavailableReason']; const threadCpuUserMicros = input['threadCpuUserMicros']; @@ -182,6 +189,7 @@ function parseWorkerPerformanceCapture( !isNullableFiniteNonNegativeNumber(histogramFlushedEpochMs) || !isNullableReason(invalidReason, INVALID_REASONS) || !isFiniteNonNegativeNumber(requestReceivedEpochMs) || + !isFiniteNonNegativeNumber(responsePostedEpochMs) || !isNullableFiniteNonNegativeNumber(threadCpuSystemMicros) || !isNullableReason(threadCpuUnavailableReason, THREAD_CPU_REASONS) || !isNullableFiniteNonNegativeNumber(threadCpuUserMicros) || @@ -191,11 +199,23 @@ function parseWorkerPerformanceCapture( return null; } + const phaseEvents = parseWorkerPerformancePhaseEvents( + operation, + input['phaseEvents'], + workStartedEpochMs, + workEndedEpochMs + ); + if (phaseEvents === null) { + return null; + } + const timestampsAreOrdered = requestReceivedEpochMs <= workStartedEpochMs && workStartedEpochMs <= workEndedEpochMs && + workEndedEpochMs <= responsePostedEpochMs && (histogramFlushedEpochMs === null || - workEndedEpochMs <= histogramFlushedEpochMs); + (workEndedEpochMs <= histogramFlushedEpochMs && + histogramFlushedEpochMs <= responsePostedEpochMs)); const eventLoopDelayIsCoherent = eventLoopDelay === null ? eventLoopDelayUnavailableReason !== null && @@ -241,7 +261,9 @@ function parseWorkerPerformanceCapture( eventLoopUtilizationUnavailableReason, histogramFlushedEpochMs, invalidReason, + phaseEvents, requestReceivedEpochMs, + responsePostedEpochMs, threadCpuSystemMicros, threadCpuUnavailableReason, threadCpuUserMicros, @@ -254,12 +276,14 @@ export function normalizeWorkerRequestPerformanceOutcome( input: WorkerRequestPerformanceOutcomeTransport ): WorkerRequestPerformanceMetrics { const identity = { + ipcCallId: input.ipcCallId, operation: input.operation, operationId: input.operationId, operationIdUnavailableReason: input.operationIdUnavailableReason, playlistId: input.playlistId, requestId: input.requestId, responseEpochMs: input.responseEpochMs, + sourceEpochMs: input.sourceEpochMs, success: input.success, }; const unavailable = ( @@ -275,7 +299,9 @@ export function normalizeWorkerRequestPerformanceOutcome( invalidReason: null, ...identity, performanceCaptureUnavailableReason: reason, + phaseEvents: [], requestReceivedEpochMs: null, + responsePostedEpochMs: null, threadCpuSystemMicros: null, threadCpuUnavailableReason: null, threadCpuUserMicros: null, @@ -291,11 +317,27 @@ export function normalizeWorkerRequestPerformanceOutcome( } const performanceCapture = parseWorkerPerformanceCapture( - input.performanceCapture + input.performanceCapture, + input.operation ); if (!performanceCapture) { return unavailable('worker-performance-capture-invalid'); } + if ( + !isFiniteNonNegativeNumber(input.responseEpochMs) || + performanceCapture.responsePostedEpochMs > input.responseEpochMs + ) { + return unavailable('worker-performance-capture-invalid'); + } + if ( + performanceCapture.phaseEvents.length > 0 && + (input.requestId === null || + performanceCapture.phaseEvents.some( + (event) => event.requestId !== input.requestId + )) + ) { + return unavailable('worker-performance-capture-invalid'); + } return { ...performanceCapture, diff --git a/apps/electron-backend/src/app/database/operations/playlist-get-performance.spec.ts b/apps/electron-backend/src/app/database/operations/playlist-get-performance.spec.ts new file mode 100644 index 000000000..d4b6d49b8 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playlist-get-performance.spec.ts @@ -0,0 +1,123 @@ +import type { + AppPlaylistGetPerformancePhase, + PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; +import { + APP_PLAYLIST_GET_PERFORMANCE_PHASE, +} from '@iptvnator/shared/interfaces'; + +import type { AppDatabase } from '../database.types'; +import { getAppPlaylist } from './playlist.operations'; + +type TestGetPhaseCapture = { + captureAsync: ( + phase: AppPlaylistGetPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => Promise; + captureSync: ( + phase: AppPlaylistGetPerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => TResult; +}; + +function createDatabaseMock(rows: unknown[]) { + const limit = jest.fn().mockResolvedValue(rows); + const where = jest.fn(() => ({ limit })); + const from = jest.fn(() => ({ where })); + const select = jest.fn(() => ({ from })); + + return { + db: { select } as unknown as AppDatabase, + from, + limit, + select, + where, + }; +} + +const playlistRow = { + id: 'synthetic-playlist', + name: 'Synthetic playlist', + type: 'm3u-url', + payload: JSON.stringify({ + _id: 'synthetic-playlist', + playlist: { + items: [{ id: 'channel-1' }, { id: 'channel-2' }], + }, + title: 'Synthetic playlist', + }), + favorites: null, + recentlyViewed: null, +} as never; + +describe('app playlist GET performance boundaries', () => { + it('keeps the default read and parse path unchanged', async () => { + const { db, limit, select } = createDatabaseMock([playlistRow]); + + await expect( + getAppPlaylist(db, 'synthetic-playlist') + ).resolves.toEqual( + expect.objectContaining({ + _id: 'synthetic-playlist', + playlist: { + items: [{ id: 'channel-1' }, { id: 'channel-2' }], + }, + }) + ); + expect(select).toHaveBeenCalledTimes(1); + expect(limit).toHaveBeenCalledWith(1); + }); + + it('separates the awaited SQLite read from playlist deserialization', async () => { + const { db, limit } = createDatabaseMock([playlistRow]); + const phases: Array<{ + metadata: PerformancePhaseMetadata | undefined; + phase: AppPlaylistGetPerformancePhase; + }> = []; + const capture: TestGetPhaseCapture = { + async captureAsync(phase, execute, metadata) { + const result = await execute(); + phases.push({ + metadata: metadata?.(result), + phase, + }); + return result; + }, + captureSync(phase, execute, metadata) { + const result = execute(); + phases.push({ + metadata: metadata?.(result), + phase, + }); + return result; + }, + }; + const getWithCapture = getAppPlaylist as unknown as ( + database: AppDatabase, + playlistId: string, + phaseCapture: TestGetPhaseCapture + ) => Promise | null>; + + await expect( + getWithCapture(db, 'synthetic-playlist', capture) + ).resolves.toEqual( + expect.objectContaining({ + _id: 'synthetic-playlist', + }) + ); + + expect(phases).toEqual([ + { + metadata: { itemCount: 1 }, + phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + }, + { + metadata: { itemCount: 2 }, + phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST, + }, + ]); + expect(limit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/playlist-upsert-performance.spec.ts b/apps/electron-backend/src/app/database/operations/playlist-upsert-performance.spec.ts new file mode 100644 index 000000000..278d2bfb6 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playlist-upsert-performance.spec.ts @@ -0,0 +1,101 @@ +import { + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + type AppPlaylistUpsertPerformancePhase, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; + +import type { AppDatabase } from '../database.types'; +import { + type AppPlaylistUpsertPhaseCapture, + upsertAppPlaylist, +} from './playlist.operations'; + +function createDatabaseMock(runImplementation: () => unknown = () => ({})) { + const run = jest.fn(runImplementation); + const then = jest.fn( + ( + onFulfilled: (value: unknown) => unknown, + onRejected: (error: unknown) => unknown + ) => Promise.resolve().then(run).then(onFulfilled, onRejected) + ); + const onConflictDoUpdate = jest.fn(() => ({ run, then })); + const values = jest.fn(() => ({ onConflictDoUpdate })); + const insert = jest.fn(() => ({ values })); + + return { + db: { insert } as unknown as AppDatabase, + insert, + onConflictDoUpdate, + run, + values, + }; +} + +describe('app playlist upsert performance boundaries', () => { + const playlist = { + _id: 'synthetic-playlist', + playlist: { + items: [{ id: 'channel-1' }, { id: 'channel-2' }], + }, + title: 'Synthetic playlist', + type: 'm3u-url', + url: 'http://127.0.0.1:43210/synthetic-performance.m3u', + }; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('keeps the default path free of phase hooks and executes one autocommit run', async () => { + const { db, run } = createDatabaseMock(); + + await expect(upsertAppPlaylist(db, playlist)).resolves.toEqual({ + success: true, + }); + expect(run).toHaveBeenCalledTimes(1); + }); + + it('separates row/JSON serialization from SQLite without rescanning the payload for metadata', async () => { + const { db, run } = createDatabaseMock(); + const byteLength = jest.spyOn(Buffer, 'byteLength'); + const phases: Array<{ + metadata: PerformancePhaseMetadata | undefined; + phase: AppPlaylistUpsertPerformancePhase; + }> = []; + const capture: AppPlaylistUpsertPhaseCapture = { + async captureAsync(phase, execute, metadata) { + const result = await execute(); + phases.push({ + metadata: metadata?.(result), + phase, + }); + return result; + }, + captureSync(phase, execute, metadata) { + const result = execute(); + phases.push({ + metadata: metadata?.(result), + phase, + }); + return result; + }, + }; + + await expect(upsertAppPlaylist(db, playlist, capture)).resolves.toEqual( + { success: true } + ); + + expect(phases).toEqual([ + { + metadata: { itemCount: 2 }, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + }, + { + metadata: { itemCount: 1 }, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + }, + ]); + expect(byteLength).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index 5b6333dc9..c8f5843db 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -1,6 +1,13 @@ import { eq, inArray } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces'; +import { + APP_PLAYLIST_GET_PERFORMANCE_PHASE, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + type AppPlaylistGetPerformancePhase, + type AppPlaylistUpsertPerformancePhase, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; import { checkpointOperation, @@ -19,6 +26,32 @@ const PLAYLIST_TYPES = { const DEFAULT_BATCH_SIZE = 100; +export interface AppPlaylistUpsertPhaseCapture { + captureAsync: ( + phase: AppPlaylistUpsertPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => Promise; + captureSync: ( + phase: AppPlaylistUpsertPerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => TResult; +} + +export interface AppPlaylistGetPhaseCapture { + captureAsync: ( + phase: AppPlaylistGetPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => Promise; + captureSync: ( + phase: AppPlaylistGetPerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => TResult; +} + type PlaylistType = (typeof PLAYLIST_TYPES)[keyof typeof PLAYLIST_TYPES]; const PLAYLIST_TYPE_VALUES = new Set( Object.values(PLAYLIST_TYPES) @@ -196,6 +229,21 @@ function buildPlaylistRow( }; } +function getPlaylistItemCount( + playlist: Record +): number | undefined { + try { + const value = playlist.playlist; + if (typeof value !== 'object' || value === null) { + return undefined; + } + const items = Reflect.get(value, 'items'); + return Array.isArray(items) ? items.length : undefined; + } catch { + return undefined; + } +} + export function parseAppPlaylist( row: schema.Playlist ): Record { @@ -300,17 +348,42 @@ export async function createPlaylist( export async function upsertAppPlaylist( db: AppDatabase, - playlist: Record + playlist: Record, + capturePhase?: AppPlaylistUpsertPhaseCapture ): Promise<{ success: boolean }> { - const row = buildPlaylistRow(playlist); + const row = capturePhase + ? capturePhase.captureSync( + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + () => buildPlaylistRow(playlist), + () => ({ + itemCount: getPlaylistItemCount(playlist), + }) + ) + : buildPlaylistRow(playlist); if (!row) { throw new Error('Playlist ID is required for upsert'); } - await db.insert(schema.playlists).values(row).onConflictDoUpdate({ - target: schema.playlists.id, - set: row, - }); + const write = async () => { + await db + .insert(schema.playlists) + .values(row) + .onConflictDoUpdate({ + target: schema.playlists.id, + set: row, + }); + }; + if (capturePhase) { + await capturePhase.captureAsync( + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + write, + () => ({ + itemCount: 1, + }) + ); + } else { + await write(); + } return { success: true }; } @@ -392,14 +465,38 @@ export async function getAppPlaylistMetas(db: AppDatabase) { ); } -export async function getAppPlaylist(db: AppDatabase, playlistId: string) { - const rows = await db - .select() - .from(schema.playlists) - .where(eq(schema.playlists.id, playlistId)) - .limit(1); +export async function getAppPlaylist( + db: AppDatabase, + playlistId: string, + capturePhase?: AppPlaylistGetPhaseCapture +) { + const read = () => + db + .select() + .from(schema.playlists) + .where(eq(schema.playlists.id, playlistId)) + .limit(1); + const rows = capturePhase + ? await capturePhase.captureAsync( + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + read, + (value) => ({ itemCount: value.length }) + ) + : await read(); + const deserialize = () => + rows[0] ? parseAppPlaylist(rows[0]) : null; - return rows[0] ? parseAppPlaylist(rows[0]) : null; + return capturePhase + ? capturePhase.captureSync( + APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST, + deserialize, + (value) => ({ + itemCount: value + ? (getPlaylistItemCount(value) ?? 0) + : 0, + }) + ) + : deserialize(); } export async function getAppPlaylistFavoriteChannels( diff --git a/apps/electron-backend/src/app/events/playlist-import-performance.ts b/apps/electron-backend/src/app/events/playlist-import-performance.ts new file mode 100644 index 000000000..f5c6dc82d --- /dev/null +++ b/apps/electron-backend/src/app/events/playlist-import-performance.ts @@ -0,0 +1,215 @@ +import { + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, + PERFORMANCE_PHASE_BOUNDARY, + type M3uImportPerformancePhase, + type PerformancePhaseEvent, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; +import { randomUUID } from 'node:crypto'; +import { channel, type Channel } from 'node:diagnostics_channel'; +import { performance } from 'node:perf_hooks'; + +export interface M3uImportPerformanceCaptureRuntime { + readonly createRequestId: () => string; + readonly readEpochMs: () => number; + readonly readMonotonicMs: () => number; +} + +export interface M3uImportPerformanceCapture { + measure( + phase: M3uImportPerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ): TResult; + measureAsync( + phase: M3uImportPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ): Promise; +} + +interface CaptureOptions { + readonly emit?: ( + event: PerformancePhaseEvent + ) => void; + readonly enabled: boolean; + readonly requestId?: string; + readonly runtime?: M3uImportPerformanceCaptureRuntime; +} + +interface PhaseStart { + readonly monotonicMs: number; + readonly phase: M3uImportPerformancePhase; +} + +const DEFAULT_RUNTIME: M3uImportPerformanceCaptureRuntime = { + createRequestId: () => randomUUID(), + readEpochMs: () => performance.timeOrigin + performance.now(), + readMonotonicMs: () => performance.now(), +}; + +let performanceChannel: Channel | null = null; +let fallbackRequestSequence = 0; + +export function publishM3uImportPerformancePhaseEvent( + event: PerformancePhaseEvent +): void { + try { + performanceChannel ??= channel( + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + performanceChannel.publish(event); + } catch { + // Development-only instrumentation must never affect playlist import. + } +} + +export function createM3uImportPerformanceCapture( + options: CaptureOptions +): M3uImportPerformanceCapture | null { + if (!options.enabled) { + return null; + } + + const runtime = options.runtime ?? DEFAULT_RUNTIME; + const requestId = + normalizeRequestId(options.requestId) ?? createRequestId(runtime); + const emit = options.emit ?? publishM3uImportPerformancePhaseEvent; + + const begin = (phase: M3uImportPerformancePhase): PhaseStart => { + const monotonicMs = safeReadMonotonicMs(runtime); + safeEmit(emit, { + boundary: PERFORMANCE_PHASE_BOUNDARY.START, + durationMs: null, + epochMs: safeReadEpochMs(runtime), + metadata: undefined, + phase, + requestId, + }); + return { monotonicMs, phase }; + }; + + const end = ( + start: PhaseStart, + createMetadata?: () => PerformancePhaseMetadata + ): void => { + const endedMonotonicMs = safeReadMonotonicMs(runtime); + const durationMs = Math.max(0, endedMonotonicMs - start.monotonicMs); + const epochMs = safeReadEpochMs(runtime); + safeEmit(emit, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: Number.isFinite(durationMs) ? durationMs : 0, + epochMs, + metadata: sanitizeMetadata(safelyCreateMetadata(createMetadata)), + phase: start.phase, + requestId, + }); + }; + + return { + measure: (phase, execute, metadata) => { + const start = begin(phase); + try { + const result = execute(); + end(start, metadata ? () => metadata(result) : undefined); + return result; + } catch (error) { + end(start); + throw error; + } + }, + measureAsync: async (phase, execute, metadata) => { + const start = begin(phase); + try { + const result = await execute(); + end(start, metadata ? () => metadata(result) : undefined); + return result; + } catch (error) { + end(start); + throw error; + } + }, + }; +} + +function createRequestId(runtime: M3uImportPerformanceCaptureRuntime): string { + try { + const requestId = normalizeRequestId(runtime.createRequestId()); + if (requestId) { + return requestId; + } + } catch { + // Fall through to a process-local, credential-free identifier. + } + fallbackRequestSequence += 1; + return `m3u-import-${Date.now()}-${fallbackRequestSequence}`; +} + +function normalizeRequestId(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function safeReadEpochMs(runtime: M3uImportPerformanceCaptureRuntime): number { + try { + const value = runtime.readEpochMs(); + return Number.isFinite(value) ? value : Date.now(); + } catch { + return Date.now(); + } +} + +function safeReadMonotonicMs( + runtime: M3uImportPerformanceCaptureRuntime +): number { + try { + const value = runtime.readMonotonicMs(); + return Number.isFinite(value) ? value : 0; + } catch { + return 0; + } +} + +function safelyCreateMetadata( + createMetadata: (() => PerformancePhaseMetadata) | undefined +): PerformancePhaseMetadata | undefined { + if (!createMetadata) { + return undefined; + } + try { + return createMetadata(); + } catch { + return undefined; + } +} + +function sanitizeMetadata( + metadata: PerformancePhaseMetadata | undefined +): PerformancePhaseMetadata | undefined { + if (!metadata) { + return undefined; + } + + const sanitized: PerformancePhaseMetadata = {}; + if (isCount(metadata.byteCount)) { + (sanitized as { byteCount?: number }).byteCount = metadata.byteCount; + } + if (isCount(metadata.itemCount)) { + (sanitized as { itemCount?: number }).itemCount = metadata.itemCount; + } + return Object.keys(sanitized).length > 0 ? sanitized : undefined; +} + +function isCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function safeEmit( + emit: (event: PerformancePhaseEvent) => void, + event: PerformancePhaseEvent +): void { + try { + emit(event); + } catch { + // Instrumentation delivery is deliberately fail-neutral. + } +} diff --git a/apps/electron-backend/src/app/events/playlist-source.performance.spec.ts b/apps/electron-backend/src/app/events/playlist-source.performance.spec.ts new file mode 100644 index 000000000..b3ac6cd25 --- /dev/null +++ b/apps/electron-backend/src/app/events/playlist-source.performance.spec.ts @@ -0,0 +1,312 @@ +import { + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + M3U_IMPORT_PERFORMANCE_PHASE, + type PerformancePhaseEvent, +} from '@iptvnator/shared/interfaces'; + +import { + createM3uImportPerformanceCapture, + type M3uImportPerformanceCaptureRuntime, +} from './playlist-import-performance'; +import { fetchPlaylistFromUrl } from './playlist-source'; + +const mockCreatePlaylistObject = jest.fn(); +const mockGetFilenameFromUrl = jest.fn(); +const mockParse = jest.fn(); +const mockRequestWithValidatedRedirects = jest.fn(); + +jest.mock('@iptvnator/shared/m3u-utils', () => ({ + createPlaylistObject: (...args: unknown[]) => + mockCreatePlaylistObject(...args), + getFilenameFromUrl: (...args: unknown[]) => mockGetFilenameFromUrl(...args), +})); + +jest.mock('iptv-playlist-parser', () => ({ + parse: (...args: unknown[]) => mockParse(...args), +})); + +jest.mock('../util/secure-https', () => ({ + createPlaylistAgentFactory: jest.fn(() => jest.fn()), +})); + +jest.mock('../util/validated-axios', () => ({ + requestWithValidatedRedirects: (...args: unknown[]) => + mockRequestWithValidatedRedirects(...args), +})); + +type ImportPhaseEvent = PerformancePhaseEvent< + (typeof M3U_IMPORT_PERFORMANCE_PHASE)[keyof typeof M3U_IMPORT_PERFORMANCE_PHASE] +>; + +function createRuntime(): M3uImportPerformanceCaptureRuntime { + const epochValues = [100, 110, 120, 130, 140, 150]; + const monotonicValues = [10, 14, 20, 23, 30, 36]; + + return { + createRequestId: jest.fn(() => 'm3u-request-1'), + readEpochMs: jest.fn(() => epochValues.shift() ?? 150), + readMonotonicMs: jest.fn(() => monotonicValues.shift() ?? 36), + }; +} + +describe('URL playlist import performance phases', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + mockCreatePlaylistObject.mockReset(); + mockGetFilenameFromUrl.mockReset(); + mockParse.mockReset(); + mockRequestWithValidatedRedirects.mockReset(); + }); + + it('does not allocate phase state or read clocks while capture is disabled', async () => { + const runtime = { + createRequestId: jest.fn(() => { + throw new Error('request id must stay untouched'); + }), + readEpochMs: jest.fn(() => { + throw new Error('epoch clock must stay untouched'); + }), + readMonotonicMs: jest.fn(() => { + throw new Error('monotonic clock must stay untouched'); + }), + }; + const emit = jest.fn(); + const playlist = { playlist: { items: [] }, title: 'Synthetic' }; + + mockRequestWithValidatedRedirects.mockResolvedValue({ + data: '#EXTM3U', + }); + mockParse.mockReturnValue({ items: [] }); + mockGetFilenameFromUrl.mockReturnValue('synthetic.m3u'); + mockCreatePlaylistObject.mockReturnValue(playlist); + + const capture = createM3uImportPerformanceCapture({ + emit, + enabled: false, + runtime, + }); + const result = await fetchPlaylistFromUrl( + 'http://127.0.0.1:43210/synthetic-performance.m3u', + undefined, + { performanceCapture: capture } + ); + + expect(capture).toBeNull(); + expect(result).toBe(playlist); + expect(emit).not.toHaveBeenCalled(); + expect(runtime.createRequestId).not.toHaveBeenCalled(); + expect(runtime.readEpochMs).not.toHaveBeenCalled(); + expect(runtime.readMonotonicMs).not.toHaveBeenCalled(); + }); + + it('emits ordered epoch-aligned pairs with durations and only byte/item metadata', async () => { + const events: ImportPhaseEvent[] = []; + const body = '#EXTM3U\n#EXTINF:-1,One\nhttps://stream.test/1'; + const bodyBytes = Buffer.byteLength(body, 'utf8'); + const parsed = { items: [{ name: 'One' }, { name: 'Two' }] }; + const playlist = { + playlist: { items: parsed.items }, + title: 'Synthetic', + }; + + mockRequestWithValidatedRedirects.mockResolvedValue({ + data: body, + headers: { + 'content-length': String(bodyBytes), + }, + }); + mockParse.mockReturnValue(parsed); + mockGetFilenameFromUrl.mockReturnValue('synthetic.m3u'); + mockCreatePlaylistObject.mockReturnValue(playlist); + + const capture = createM3uImportPerformanceCapture({ + emit: (event) => events.push(event), + enabled: true, + requestId: 'm3u-request-1', + runtime: createRuntime(), + }); + const byteLength = jest.spyOn(Buffer, 'byteLength'); + const result = await fetchPlaylistFromUrl( + 'http://127.0.0.1:43210/synthetic-performance.m3u', + undefined, + { performanceCapture: capture } + ); + + expect(result).toBe(playlist); + expect(byteLength).not.toHaveBeenCalled(); + expect( + events.map( + ({ + boundary, + durationMs, + epochMs, + metadata, + phase, + requestId, + }) => ({ + boundary, + durationMs, + epochMs, + metadata, + phase, + requestId, + }) + ) + ).toEqual([ + { + boundary: 'start', + durationMs: null, + epochMs: 100, + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + requestId: 'm3u-request-1', + }, + { + boundary: 'end', + durationMs: 4, + epochMs: 110, + metadata: { byteCount: bodyBytes }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + requestId: 'm3u-request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 120, + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U, + requestId: 'm3u-request-1', + }, + { + boundary: 'end', + durationMs: 3, + epochMs: 130, + metadata: { itemCount: 2 }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U, + requestId: 'm3u-request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 140, + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + requestId: 'm3u-request-1', + }, + { + boundary: 'end', + durationMs: 6, + epochMs: 150, + metadata: { itemCount: 2 }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + requestId: 'm3u-request-1', + }, + ]); + const serialized = JSON.stringify(events); + expect(serialized).not.toContain('127.0.0.1'); + expect(serialized).not.toContain('stream.test'); + expect(serialized).not.toContain('Synthetic'); + expect(serialized).not.toContain('username'); + expect(serialized).not.toContain('password'); + expect(serialized).not.toContain( + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE + ); + }); + + it('preserves exact results and errors when event or metadata hooks fail', async () => { + const resultIdentity = { result: 'same-object' }; + const capture = createM3uImportPerformanceCapture({ + emit: () => { + throw new Error('event sink failed'); + }, + enabled: true, + requestId: 'm3u-request-2', + runtime: createRuntime(), + }); + + expect( + capture?.measure( + M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + () => resultIdentity, + () => { + throw new Error('metadata failed'); + } + ) + ).toBe(resultIdentity); + + const businessError = new Error('download failed'); + const events: ImportPhaseEvent[] = []; + mockRequestWithValidatedRedirects.mockRejectedValue(businessError); + const errorCapture = createM3uImportPerformanceCapture({ + emit: (event) => events.push(event), + enabled: true, + requestId: 'm3u-request-3', + runtime: createRuntime(), + }); + + await expect( + fetchPlaylistFromUrl( + 'http://127.0.0.1:43210/failure.m3u', + undefined, + { + performanceCapture: errorCapture, + } + ) + ).rejects.toBe(businessError); + expect( + events.map(({ boundary, phase }) => ({ boundary, phase })) + ).toEqual([ + { + boundary: 'start', + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + }, + { + boundary: 'end', + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + }, + ]); + }); + + it('samples the end boundary before creating phase metadata', () => { + const events: ImportPhaseEvent[] = []; + let epochMs = 100; + let monotonicMs = 10; + const resultIdentity = { itemCount: 2 }; + const capture = createM3uImportPerformanceCapture({ + emit: (event) => events.push(event), + enabled: true, + requestId: 'm3u-request-boundary', + runtime: { + createRequestId: jest.fn(() => 'unused-request-id'), + readEpochMs: jest.fn(() => epochMs), + readMonotonicMs: jest.fn(() => monotonicMs), + }, + }); + + const result = capture?.measure( + M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + () => { + epochMs = 110; + monotonicMs = 20; + return resultIdentity; + }, + ({ itemCount }) => { + epochMs = 1_110; + monotonicMs = 1_020; + return { itemCount }; + } + ); + + expect(result).toBe(resultIdentity); + expect(events).toHaveLength(2); + expect(events[1]).toMatchObject({ + boundary: 'end', + durationMs: 10, + epochMs: 110, + metadata: { itemCount: 2 }, + }); + }); +}); diff --git a/apps/electron-backend/src/app/events/playlist-source.ts b/apps/electron-backend/src/app/events/playlist-source.ts index a55b9a96d..e147ae806 100644 --- a/apps/electron-backend/src/app/events/playlist-source.ts +++ b/apps/electron-backend/src/app/events/playlist-source.ts @@ -13,8 +13,11 @@ import { isInvalidTlsCertificateError, } from '../util/security-errors'; import { requestWithValidatedRedirects } from '../util/validated-axios'; +import type { M3uImportPerformanceCapture } from './playlist-import-performance'; +import { M3U_IMPORT_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; export interface PlaylistFetchOptions { + performanceCapture?: M3uImportPerformanceCapture | null; trustedInsecureTlsHosts?: readonly string[]; } @@ -26,14 +29,67 @@ export interface PlaylistFetchOptions { */ export const PLAYLIST_FETCH_TIMEOUT_MS = 30000; +function countPlaylistItems(value: unknown): number | undefined { + try { + if (typeof value !== 'object' || value === null) { + return undefined; + } + const items = Reflect.get(value, 'items'); + return Array.isArray(items) ? items.length : undefined; + } catch { + return undefined; + } +} + +function countNormalizedPlaylistItems(value: unknown): number | undefined { + try { + if (typeof value !== 'object' || value === null) { + return undefined; + } + return countPlaylistItems(Reflect.get(value, 'playlist')); + } catch { + return undefined; + } +} + +function readResponseContentLength(response: unknown): number | undefined { + try { + if (typeof response !== 'object' || response === null) { + return undefined; + } + const headers = Reflect.get(response, 'headers'); + if (typeof headers !== 'object' || headers === null) { + return undefined; + } + const directValue = Reflect.get(headers, 'content-length'); + const getHeader = Reflect.get(headers, 'get'); + const value = + directValue ?? + (typeof getHeader === 'function' + ? Reflect.apply(getHeader, headers, ['content-length']) + : undefined); + const numericValue = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim().length > 0 + ? Number(value) + : Number.NaN; + return Number.isSafeInteger(numericValue) && numericValue >= 0 + ? numericValue + : undefined; + } catch { + return undefined; + } +} + export async function fetchPlaylistFromUrl( url: string, title?: string, options: PlaylistFetchOptions = {} ): Promise { - let result; - try { - result = await requestWithValidatedRedirects( + const performanceCapture = options.performanceCapture; + const request = () => + requestWithValidatedRedirects( url, { agentFactory: createPlaylistAgentFactory({ @@ -44,6 +100,17 @@ export async function fetchPlaylistFromUrl( }, { allowPrivateNetworks: true } ); + let result; + try { + result = performanceCapture + ? await performanceCapture.measureAsync( + M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + request, + (response) => ({ + byteCount: readResponseContentLength(response), + }) + ) + : await request(); } catch (error) { if (isInvalidTlsCertificateError(error)) { throw createInvalidTlsCertificateError( @@ -53,19 +120,38 @@ export async function fetchPlaylistFromUrl( throw error; } - const parsedPlaylist = parse(result.data); - const extractedName = url && url.length > 1 ? getFilenameFromUrl(url) : ''; - const playlistName = - !extractedName || extractedName === 'Untitled playlist' - ? 'Imported from URL' - : extractedName; + const parsedPlaylist = performanceCapture + ? performanceCapture.measure( + M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U, + () => parse(result.data), + (parsed) => ({ itemCount: countPlaylistItems(parsed) }) + ) + : parse(result.data); + const normalize = (): Playlist => { + const extractedName = + url && url.length > 1 ? getFilenameFromUrl(url) : ''; + const playlistName = + !extractedName || extractedName === 'Untitled playlist' + ? 'Imported from URL' + : extractedName; - return createPlaylistObject( - title ?? playlistName, - parsedPlaylist, - url, - 'URL' - ); + return createPlaylistObject( + title ?? playlistName, + parsedPlaylist, + url, + 'URL' + ); + }; + + return performanceCapture + ? performanceCapture.measure( + M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + normalize, + (playlist) => ({ + itemCount: countNormalizedPlaylistItems(playlist), + }) + ) + : normalize(); } export async function fetchPlaylistFromFile( diff --git a/apps/electron-backend/src/app/events/playlist.events.spec.ts b/apps/electron-backend/src/app/events/playlist.events.spec.ts index f1300f5a6..0b12a65b1 100644 --- a/apps/electron-backend/src/app/events/playlist.events.spec.ts +++ b/apps/electron-backend/src/app/events/playlist.events.spec.ts @@ -6,10 +6,13 @@ import type { } from '@iptvnator/shared/interfaces'; import { AUTO_UPDATE_PLAYLISTS, + M3U_IMPORT_PERFORMANCE_PHASE, + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, PLAYLIST_CANCEL_REFRESH, PLAYLIST_REFRESH, PLAYLIST_REFRESH_EVENT, } from '@iptvnator/shared/interfaces'; +import { channel } from 'node:diagnostics_channel'; import { resolve } from 'node:path'; type IpcHandler = (event: MockIpcEvent, ...args: unknown[]) => Promise; @@ -41,6 +44,8 @@ const mockCreatePlaylistObject = jest.fn(); const mockGetFilenameFromUrl = jest.fn(); const mockResolveWorkerRuntimeBootstrap = jest.fn(); const mockWorkerInstances: MockWorker[] = []; +const PERF_CAPTURE_ENV = 'IPTVNATOR_PERF_CAPTURE'; +const originalPerformanceCaptureValue = process.env[PERF_CAPTURE_ENV]; jest.mock('electron', () => ({ app: { @@ -169,6 +174,11 @@ describe('playlist IPC events', () => { }); afterEach(() => { + if (originalPerformanceCaptureValue === undefined) { + delete process.env[PERF_CAPTURE_ENV]; + } else { + process.env[PERF_CAPTURE_ENV] = originalPerformanceCaptureValue; + } consoleErrorSpy.mockRestore(); consoleLogSpy.mockRestore(); consoleWarnSpy.mockRestore(); @@ -210,6 +220,101 @@ describe('playlist IPC events', () => { expect(result).toEqual(playlist); }); + it('publishes request-scoped URL import phases only through the opt-in internal channel', async () => { + process.env[PERF_CAPTURE_ENV] = '1'; + const body = '#EXTM3U\n#EXTINF:-1,News\nhttps://stream.test/news'; + const parsedPlaylist = { items: [{ name: 'News' }] }; + const playlist = createPlaylist({ + title: 'synthetic-performance.m3u', + url: 'http://127.0.0.1:43210/synthetic-performance.m3u', + }); + const events: unknown[] = []; + const performanceChannel = channel( + M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + const subscriber = (event: unknown) => events.push(event); + + mockAxiosGet.mockResolvedValue({ + data: body, + headers: { + 'content-length': String(Buffer.byteLength(body, 'utf8')), + }, + }); + mockParse.mockReturnValue(parsedPlaylist); + mockGetFilenameFromUrl.mockReturnValue('synthetic-performance.m3u'); + mockCreatePlaylistObject.mockReturnValue(playlist); + performanceChannel.subscribe(subscriber); + + try { + await expect( + getHandler('fetch-playlist-by-url')( + createIpcEvent(), + 'http://127.0.0.1:43210/synthetic-performance.m3u' + ) + ).resolves.toBe(playlist); + } finally { + performanceChannel.unsubscribe(subscriber); + } + + expect( + events.map((event) => { + const marker = event as { + boundary: string; + metadata?: { byteCount?: number; itemCount?: number }; + phase: string; + requestId: string; + }; + return { + boundary: marker.boundary, + metadata: marker.metadata, + phase: marker.phase, + requestIdType: typeof marker.requestId, + }; + }) + ).toEqual([ + { + boundary: 'start', + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + requestIdType: 'string', + }, + { + boundary: 'end', + metadata: { byteCount: Buffer.byteLength(body, 'utf8') }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.DATA_ACQUIRE, + requestIdType: 'string', + }, + { + boundary: 'start', + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U, + requestIdType: 'string', + }, + { + boundary: 'end', + metadata: { itemCount: 1 }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.PARSE_M3U, + requestIdType: 'string', + }, + { + boundary: 'start', + metadata: undefined, + phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + requestIdType: 'string', + }, + { + boundary: 'end', + metadata: { itemCount: 1 }, + phase: M3U_IMPORT_PERFORMANCE_PHASE.NORMALIZE, + requestIdType: 'string', + }, + ]); + expect( + new Set(events.map((event) => (event as any).requestId)).size + ).toBe(1); + expect(JSON.stringify(events)).not.toContain('stream.test'); + }); + it('returns null when the open playlist dialog is cancelled', async () => { mockShowOpenDialog.mockResolvedValue({ canceled: true, diff --git a/apps/electron-backend/src/app/events/playlist.events.ts b/apps/electron-backend/src/app/events/playlist.events.ts index ddd273d5a..71ea32af1 100644 --- a/apps/electron-backend/src/app/events/playlist.events.ts +++ b/apps/electron-backend/src/app/events/playlist.events.ts @@ -32,6 +32,8 @@ import { fetchPlaylistFromUrl, } from './playlist-source'; import { PlaylistWriteAuthorizer } from './playlist-write-authorization'; +import { createM3uImportPerformanceCapture } from './playlist-import-performance'; +import { isPerformanceCaptureEnabled } from '../services/debug-trace'; export default class PlaylistEvents { static bootstrapPlaylistEvents(): Electron.IpcMain { @@ -95,7 +97,11 @@ ipcMain.handle( options?: ElectronBridgeTrustOptions ) => { try { + const performanceCapture = createM3uImportPerformanceCapture({ + enabled: isPerformanceCaptureEnabled(), + }); return await fetchPlaylistFromUrl(url, title, { + performanceCapture, trustedInsecureTlsHosts: options?.trustedInsecureTlsHosts, }); } catch (error) { diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index fc709c959..61ca7f54d 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -64,6 +64,8 @@ import { getAppState, getPlaylist, setAppState, + type AppPlaylistGetPhaseCapture, + type AppPlaylistUpsertPhaseCapture, updatePlaylist, upsertAppPlaylist, upsertAppPlaylists, @@ -93,9 +95,14 @@ import { executeWithWorkerPerformanceCapture, registerDatabaseWorkerPerformanceCapture, releaseDatabaseWorkerPerformanceCapture, + stampWorkerPerformanceResponsePostedEpoch, startWorkerPerformanceCapture, type WorkerPerformanceCapture, } from './worker-performance-capture'; +import { + captureWorkerPerformancePhase, + captureWorkerPerformancePhaseAsync, +} from './worker-performance-phase'; import { handleDatabaseWorkerPostGcHeapRequest, isDatabaseWorkerPostGcHeapRequest, @@ -349,7 +356,8 @@ function releasePreRegisteredOperation( async function executeRequest( message: DbWorkerRequestMessage, - preRegisteredState?: ActiveOperationState + preRegisteredState?: ActiveOperationState, + performanceCapture: WorkerPerformanceCapture | null = null ) { const db = await getWorkerDatabase(); @@ -430,11 +438,7 @@ async function executeRequest( kind?: 'all' | 'vod' | 'series'; limit?: number; playlistType?: - | 'xtream' - | 'stalker' - | 'm3u-file' - | 'm3u-text' - | 'm3u-url'; + 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'; }; return getGlobalRecentlyAdded( db, @@ -575,9 +579,29 @@ async function executeRequest( } case 'DB_UPSERT_APP_PLAYLIST': { + const capturePhase: AppPlaylistUpsertPhaseCapture | undefined = + performanceCapture + ? { + captureAsync: (phase, execute, metadata) => + captureWorkerPerformancePhaseAsync( + performanceCapture, + phase, + execute, + metadata + ), + captureSync: (phase, execute, metadata) => + captureWorkerPerformancePhase( + performanceCapture, + phase, + execute, + metadata + ), + } + : undefined; return upsertAppPlaylist( db, - message.payload as Record + message.payload as Record, + capturePhase ); } @@ -596,7 +620,26 @@ async function executeRequest( case 'DB_GET_APP_PLAYLIST': { const payload = message.payload as { playlistId: string }; - return getAppPlaylist(db, payload.playlistId); + const capturePhase: AppPlaylistGetPhaseCapture | undefined = + performanceCapture + ? { + captureAsync: (phase, execute, metadata) => + captureWorkerPerformancePhaseAsync( + performanceCapture, + phase, + execute, + metadata + ), + captureSync: (phase, execute, metadata) => + captureWorkerPerformancePhase( + performanceCapture, + phase, + execute, + metadata + ), + } + : undefined; + return getAppPlaylist(db, payload.playlistId, capturePhase); } case 'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS': { @@ -1013,7 +1056,9 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { let performanceCapture: WorkerPerformanceCapture | null = null; const execution = await (async () => { try { - performanceCapture = startWorkerPerformanceCapture(); + performanceCapture = startWorkerPerformanceCapture({ + requestId: message.requestId, + }); registerDatabaseWorkerPerformanceCapture( activePerformanceCaptures, performanceCapture @@ -1021,7 +1066,12 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { await armWorkerPerformanceCapture(performanceCapture); return await executeWithWorkerPerformanceCapture( performanceCapture, - () => executeRequest(message, preRegisteredOperation?.state) + () => + executeRequest( + message, + preRegisteredOperation?.state, + performanceCapture + ) ); } finally { releaseDatabaseWorkerPerformanceCapture( @@ -1038,7 +1088,10 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { requestId: message.requestId, success: true, result: execution.result, - performance: execution.performance, + performance: stampWorkerPerformanceResponsePostedEpoch( + performanceCapture, + execution.performance + ), }); } else { console.error( @@ -1051,7 +1104,10 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { requestId: message.requestId, success: false, error: serializeError(execution.error), - performance: execution.performance, + performance: stampWorkerPerformanceResponsePostedEpoch( + performanceCapture, + execution.performance + ), }); } }); diff --git a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts index f6062de87..a541d8baf 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -25,6 +25,7 @@ import { PLAYLIST_FETCH_TIMEOUT_MS } from '../events/playlist-source'; import { armWorkerPerformanceCapture, executeWithWorkerPerformanceCapture, + stampWorkerPerformanceResponsePostedEpoch, startWorkerPerformanceCapture, } from './worker-performance-capture'; @@ -229,7 +230,10 @@ parentPort.on( type: 'response', success: true, result: execution.result, - performance: execution.performance, + performance: stampWorkerPerformanceResponsePostedEpoch( + performanceCapture, + execution.performance + ), }); } else { const error = execution.error; @@ -237,7 +241,10 @@ parentPort.on( type: 'response', success: false, error: serializeError(error), - performance: execution.performance, + performance: stampWorkerPerformanceResponsePostedEpoch( + performanceCapture, + execution.performance + ), }); } } finally { diff --git a/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts index 58b8c836b..eadd2617f 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts @@ -82,6 +82,9 @@ function mockPerformanceCapture( ), registerDatabaseWorkerPerformanceCapture: jest.fn(), releaseDatabaseWorkerPerformanceCapture: jest.fn(), + stampWorkerPerformanceResponsePostedEpoch: jest.fn( + (_capture: unknown, performance: unknown) => performance + ), startWorkerPerformanceCapture: jest.fn(() => ({})), })); } diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts index 4d1fb3a1f..4f1e95ca4 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts @@ -177,6 +177,7 @@ function createFallbackResult( WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE, histogramFlushedEpochMs: null, invalidReason, + ...copyPhaseEvents(capture), requestReceivedEpochMs: capture.requestReceivedEpochMs, threadCpuSystemMicros: null, threadCpuUnavailableReason: @@ -213,6 +214,7 @@ export function createWorkerPerformanceCaptureResult( capture.eventLoopUtilizationUnavailableReason, histogramFlushedEpochMs: capture.histogramFlushedEpochMs, invalidReason: capture.invalidReason, + ...copyPhaseEvents(capture), requestReceivedEpochMs: capture.requestReceivedEpochMs, ...threadCpu, threadCpuUnavailableReason: capture.threadCpuUnavailableReason, @@ -226,6 +228,18 @@ export function createWorkerPerformanceCaptureResult( } } +function copyPhaseEvents( + capture: WorkerPerformanceCapture +): Pick { + try { + return capture.phaseEvents.length > 0 + ? { phaseEvents: Object.freeze([...capture.phaseEvents]) } + : {}; + } catch { + return {}; + } +} + export function finishWorkerPerformanceMetricBoundaries( capture: WorkerPerformanceCapture ): void { diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts index f86576eda..dff916076 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts @@ -1,4 +1,8 @@ import type { EventLoopUtilization } from 'node:perf_hooks'; +import type { + AppPlaylistPerformancePhase, + PerformancePhaseEvent, +} from '@iptvnator/shared/interfaces'; export const WORKER_PERFORMANCE_INVALID_REASON = { OVERLAPPING_DATABASE_WORKER_REQUESTS: @@ -46,7 +50,9 @@ export interface WorkerPerformanceCaptureResult { eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; histogramFlushedEpochMs: number | null; invalidReason: WorkerPerformanceInvalidReason | null; + phaseEvents?: readonly PerformancePhaseEvent[]; requestReceivedEpochMs: number; + responsePostedEpochMs?: number; threadCpuSystemMicros: number | null; threadCpuUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; threadCpuUserMicros: number | null; @@ -65,6 +71,7 @@ export interface WorkerPerformanceCaptureRuntime { export interface WorkerPerformanceCaptureOptions { enabled?: boolean; + requestId?: string; runtime?: WorkerPerformanceCaptureRuntime; } @@ -84,7 +91,9 @@ export interface WorkerPerformanceCapture { histogramDisabled: boolean; histogramFlushedEpochMs: number | null; invalidReason: WorkerPerformanceInvalidReason | null; + phaseEvents: PerformancePhaseEvent[]; requestReceivedEpochMs: number; + requestId: string | null; runtime: WorkerPerformanceCaptureRuntime; threadCpuEnd: NodeJS.CpuUsage | null; threadCpuStart: NodeJS.CpuUsage | null; diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts index 7bebd4fe9..d6438dec3 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts @@ -2,6 +2,7 @@ import { WORKER_PERFORMANCE_UNAVAILABLE_REASON, armWorkerPerformanceCapture, executeWithWorkerPerformanceCapture, + stampWorkerPerformanceResponsePostedEpoch, startWorkerPerformanceCapture, } from './worker-performance-capture'; import { createFakeRuntime } from './worker-performance-capture.test-harness'; @@ -86,6 +87,34 @@ describe('worker performance capture', () => { expect('reset' in harness.histogram).toBe(false); }); + it('stamps the worker-send boundary only after request profiling has finished', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + const stamped = stampWorkerPerformanceResponsePostedEpoch( + capture, + execution.performance + ); + + expect(stamped).toMatchObject({ + histogramFlushedEpochMs: 145, + responsePostedEpochMs: 149, + workEndedEpochMs: 140, + }); + expect(harness.lifecycle.at(-1)).toBe('epoch:149'); + expect( + stampWorkerPerformanceResponsePostedEpoch(null, undefined) + ).toBeUndefined(); + }); + it('finishes profiling before returning a rejected execution to worker side effects', async () => { const harness = createFakeRuntime(); const capture = startWorkerPerformanceCapture({ diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.ts index a29d97f0a..3fa1a8baa 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.ts @@ -57,7 +57,13 @@ export function startWorkerPerformanceCapture( histogramDisabled: false, histogramFlushedEpochMs: null, invalidReason: null, + phaseEvents: [], requestReceivedEpochMs: safeReadEpochMs(runtime), + requestId: + typeof options.requestId === 'string' && + options.requestId.length > 0 + ? options.requestId + : null, runtime, threadCpuEnd: null, threadCpuStart: null, @@ -269,3 +275,21 @@ export function releaseDatabaseWorkerPerformanceCapture( activeCaptures.delete(capture); } } + +export function stampWorkerPerformanceResponsePostedEpoch( + capture: WorkerPerformanceCapture | null, + performance: WorkerPerformanceCaptureResult | undefined +): WorkerPerformanceCaptureResult | undefined { + if (!capture || !performance) { + return performance; + } + + try { + return { + ...performance, + responsePostedEpochMs: safeReadEpochMs(capture.runtime), + }; + } catch { + return performance; + } +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts index 6ecaac4bd..f10fe3af9 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts @@ -33,6 +33,12 @@ describe('worker performance integration order', () => { expect( handler.indexOf('executeWithWorkerPerformanceCapture') ).toBeLessThan(handler.indexOf('postMessage')); + expect( + handler.match(/stampWorkerPerformanceResponsePostedEpoch/g) + ).toHaveLength(2); + expect(handler).toMatch( + /postMessage\(\{[\s\S]*?performance:\s*stampWorkerPerformanceResponsePostedEpoch\([\s\S]*?execution\.performance[\s\S]*?\),[\s\S]*?\}\)/ + ); expect(handler).not.toContain('await previous'); expect(handler).not.toContain('requestQueue'); }); @@ -52,6 +58,34 @@ describe('worker performance integration order', () => { expect(executionIndex).toBeLessThan( handler.indexOf('postMessage', executionIndex) ); + expect( + handler.match(/stampWorkerPerformanceResponsePostedEpoch/g) + ).toHaveLength(2); + }); + + it('passes the request capture into the single-playlist upsert boundary', () => { + const source = readWorkerSource('database.worker.ts'); + const upsertCase = source.slice( + source.indexOf("case 'DB_UPSERT_APP_PLAYLIST':"), + source.indexOf("case 'DB_UPSERT_APP_PLAYLISTS':") + ); + + expect(upsertCase).toContain('captureWorkerPerformancePhase'); + expect(upsertCase).toContain('performanceCapture'); + expect(upsertCase).toContain('upsertAppPlaylist'); + }); + + it('passes async read and sync deserialize capture into the single-playlist GET boundary', () => { + const source = readWorkerSource('database.worker.ts'); + const getCase = source.slice( + source.indexOf("case 'DB_GET_APP_PLAYLIST':"), + source.indexOf("case 'DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS':") + ); + + expect(getCase).toContain('captureWorkerPerformancePhaseAsync'); + expect(getCase).toContain('captureWorkerPerformancePhase'); + expect(getCase).toContain('performanceCapture'); + expect(getCase).toContain('getAppPlaylist'); }); it('preserves success and business errors when the finish seam throws once', async () => { diff --git a/apps/electron-backend/src/app/workers/worker-performance-phase.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-phase.spec.ts new file mode 100644 index 000000000..f8da24464 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-phase.spec.ts @@ -0,0 +1,245 @@ +import { + APP_PLAYLIST_GET_PERFORMANCE_PHASE, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; + +import { + finishWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; +import { + captureWorkerPerformancePhase, + captureWorkerPerformancePhaseAsync, +} from './worker-performance-phase'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +describe('worker request performance phases', () => { + it('executes directly without phase allocation or metadata work when disabled', () => { + const resultIdentity = { success: true }; + const execute = jest.fn(() => resultIdentity); + const metadata = jest.fn(() => { + throw new Error('metadata must remain untouched'); + }); + + const result = captureWorkerPerformancePhase( + null, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + execute, + metadata + ); + + expect(result).toBe(resultIdentity); + expect(execute).toHaveBeenCalledTimes(1); + expect(metadata).not.toHaveBeenCalled(); + }); + + it('adds ordered request-scoped pairs to the existing performance envelope', async () => { + const harness = createFakeRuntime(); + const monotonicValues = [10, 17, 20, 31]; + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'db-request-1', + runtime: { + ...harness.runtime, + readMonotonicMs: () => monotonicValues.shift() ?? 31, + }, + }); + const rowIdentity = { payload: '{"playlist":"synthetic"}' }; + + expect( + captureWorkerPerformancePhase( + capture, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + () => rowIdentity, + () => + ({ + byteCount: 24, + itemCount: 100_000, + secret: 'must-not-be-copied', + }) as PerformancePhaseMetadata + ) + ).toBe(rowIdentity); + captureWorkerPerformancePhase( + capture, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + () => undefined, + () => ({ byteCount: 24, itemCount: 1 }) + ); + + const result = await finishWorkerPerformanceCapture(capture); + + expect(result?.phaseEvents).toEqual([ + { + boundary: 'start', + durationMs: null, + epochMs: 110, + metadata: undefined, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + requestId: 'db-request-1', + }, + { + boundary: 'end', + durationMs: 7, + epochMs: 140, + metadata: { byteCount: 24, itemCount: 100_000 }, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SERIALIZE_PLAYLIST, + requestId: 'db-request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 145, + metadata: undefined, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + requestId: 'db-request-1', + }, + { + boundary: 'end', + durationMs: 11, + epochMs: 149, + metadata: { byteCount: 24, itemCount: 1 }, + phase: APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + requestId: 'db-request-1', + }, + ]); + expect(JSON.stringify(result?.phaseEvents)).not.toContain('secret'); + }); + + it('preserves exact error identity and still closes the phase pair', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'db-request-error', + runtime: harness.runtime, + }); + const businessError = new Error('sqlite write failed'); + + expect(() => + captureWorkerPerformancePhase( + capture, + APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE.SQLITE_WRITE, + () => { + throw businessError; + }, + () => { + throw new Error('metadata failed'); + } + ) + ).toThrow(businessError); + + const result = await finishWorkerPerformanceCapture(capture); + expect( + result?.phaseEvents?.map(({ boundary, metadata }) => ({ + boundary, + metadata, + })) + ).toEqual([ + { boundary: 'start', metadata: undefined }, + { boundary: 'end', metadata: undefined }, + ]); + }); + + it('returns the exact business promise without metadata work when async capture is disabled', async () => { + const resultIdentity = { rows: 1 }; + const businessPromise = Promise.resolve(resultIdentity); + const execute = jest.fn(() => businessPromise); + const metadata = jest.fn(() => { + throw new Error('metadata must remain untouched'); + }); + + const result = captureWorkerPerformancePhaseAsync( + null, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + execute, + metadata + ); + + expect(result).toBe(businessPromise); + await expect(result).resolves.toBe(resultIdentity); + expect(execute).toHaveBeenCalledTimes(1); + expect(metadata).not.toHaveBeenCalled(); + }); + + it('closes an async phase only after the awaited operation settles', async () => { + const harness = createFakeRuntime(); + const monotonicValues = [10, 27]; + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'db-get-request', + runtime: { + ...harness.runtime, + readMonotonicMs: () => monotonicValues.shift() ?? 27, + }, + }); + const rows = [{ id: 'synthetic-playlist' }]; + let resolveRows!: (value: typeof rows) => void; + const pendingRows = new Promise((resolve) => { + resolveRows = resolve; + }); + + const result = captureWorkerPerformancePhaseAsync( + capture, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + () => pendingRows, + (value) => ({ itemCount: value.length }) + ); + + expect(capture?.phaseEvents).toHaveLength(1); + resolveRows(rows); + await expect(result).resolves.toBe(rows); + expect(capture?.phaseEvents).toHaveLength(2); + + const performance = await finishWorkerPerformanceCapture(capture); + expect(performance?.phaseEvents).toEqual([ + { + boundary: 'start', + durationMs: null, + epochMs: 110, + metadata: undefined, + phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + requestId: 'db-get-request', + }, + { + boundary: 'end', + durationMs: 17, + epochMs: 140, + metadata: { itemCount: 1 }, + phase: APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + requestId: 'db-get-request', + }, + ]); + }); + + it('preserves async rejection identity and closes the phase without metadata', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'db-get-error', + runtime: harness.runtime, + }); + const businessError = new Error('sqlite read failed'); + + await expect( + captureWorkerPerformancePhaseAsync( + capture, + APP_PLAYLIST_GET_PERFORMANCE_PHASE.SQLITE_READ, + () => Promise.reject(businessError), + () => { + throw new Error('metadata must not run for errors'); + } + ) + ).rejects.toBe(businessError); + + const result = await finishWorkerPerformanceCapture(capture); + expect( + result?.phaseEvents?.map(({ boundary, metadata }) => ({ + boundary, + metadata, + })) + ).toEqual([ + { boundary: 'start', metadata: undefined }, + { boundary: 'end', metadata: undefined }, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-phase.ts b/apps/electron-backend/src/app/workers/worker-performance-phase.ts new file mode 100644 index 000000000..85fca3209 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-phase.ts @@ -0,0 +1,172 @@ +import { + PERFORMANCE_PHASE_BOUNDARY, + type AppPlaylistPerformancePhase, + type PerformancePhaseEvent, + type PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; +import type { WorkerPerformanceCapture } from './worker-performance-capture.model'; +import { safeReadEpochMs } from './worker-performance-capture.runtime'; + +export function captureWorkerPerformancePhase( + capture: WorkerPerformanceCapture | null, + phase: AppPlaylistPerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata +): TResult { + if (!capture || !capture.requestId) { + return execute(); + } + + const startMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.START, + durationMs: null, + epochMs: safeReadEpochMs(capture.runtime), + metadata: undefined, + phase, + requestId: capture.requestId, + }); + + try { + const result = execute(); + const endMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: safeDuration(endMonotonicMs - startMonotonicMs), + epochMs: safeReadEpochMs(capture.runtime), + metadata: sanitizeMetadata(safelyCreateMetadata(metadata, result)), + phase, + requestId: capture.requestId, + }); + return result; + } catch (error) { + const endMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: safeDuration(endMonotonicMs - startMonotonicMs), + epochMs: safeReadEpochMs(capture.runtime), + metadata: undefined, + phase, + requestId: capture.requestId, + }); + throw error; + } +} + +export function captureWorkerPerformancePhaseAsync( + capture: WorkerPerformanceCapture | null, + phase: AppPlaylistPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata +): Promise { + const requestId = capture?.requestId; + if (!capture || !requestId) { + return execute(); + } + + return captureAsync(capture, requestId, phase, execute, metadata); +} + +async function captureAsync( + capture: WorkerPerformanceCapture, + requestId: string, + phase: AppPlaylistPerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata +): Promise { + const startMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.START, + durationMs: null, + epochMs: safeReadEpochMs(capture.runtime), + metadata: undefined, + phase, + requestId, + }); + + try { + const result = await execute(); + const endMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: safeDuration(endMonotonicMs - startMonotonicMs), + epochMs: safeReadEpochMs(capture.runtime), + metadata: sanitizeMetadata( + safelyCreateMetadata(metadata, result) + ), + phase, + requestId, + }); + return result; + } catch (error) { + const endMonotonicMs = safeReadMonotonicMs(capture); + appendPhaseEvent(capture, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: safeDuration(endMonotonicMs - startMonotonicMs), + epochMs: safeReadEpochMs(capture.runtime), + metadata: undefined, + phase, + requestId, + }); + throw error; + } +} + +function safeReadMonotonicMs(capture: WorkerPerformanceCapture): number { + try { + const value = capture.runtime.readMonotonicMs(); + return Number.isFinite(value) ? value : 0; + } catch { + return 0; + } +} + +function safeDuration(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +function safelyCreateMetadata( + createMetadata: ((result: TResult) => PerformancePhaseMetadata) | undefined, + result: TResult +): PerformancePhaseMetadata | undefined { + if (!createMetadata) { + return undefined; + } + try { + return createMetadata(result); + } catch { + return undefined; + } +} + +function sanitizeMetadata( + metadata: PerformancePhaseMetadata | undefined +): PerformancePhaseMetadata | undefined { + if (!metadata) { + return undefined; + } + + const sanitized: { byteCount?: number; itemCount?: number } = {}; + if (isCount(metadata.byteCount)) { + sanitized.byteCount = metadata.byteCount; + } + if (isCount(metadata.itemCount)) { + sanitized.itemCount = metadata.itemCount; + } + return Object.keys(sanitized).length > 0 ? sanitized : undefined; +} + +function isCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function appendPhaseEvent( + capture: WorkerPerformanceCapture, + event: PerformancePhaseEvent +): void { + try { + capture.phaseEvents.push(event); + } catch { + // Phase instrumentation is deliberately fail-neutral. + } +} diff --git a/apps/web/src/app/services/electron.service.performance.spec.ts b/apps/web/src/app/services/electron.service.performance.spec.ts new file mode 100644 index 000000000..a7e10ffe2 --- /dev/null +++ b/apps/web/src/app/services/electron.service.performance.spec.ts @@ -0,0 +1,123 @@ +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Store } from '@ngrx/store'; +import { TranslateService } from '@ngx-translate/core'; +import { PlaylistActions } from '@iptvnator/m3u-state'; +import { SettingsStore } from '@iptvnator/services'; +import { + PLAYLIST_PARSE_BY_URL, + type Playlist, +} from '@iptvnator/shared/interfaces'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; +import { DialogService } from '@iptvnator/ui/components'; +import { ElectronService } from './electron.service'; + +describe('ElectronService performance phases', () => { + const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); + const electronBridge = { + fetchPlaylistByUrl: jest.fn(), + onPlayerError: jest.fn(), + }; + const store = { dispatch: jest.fn() }; + let service: ElectronService; + + beforeEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: electronBridge, + }); + electronBridge.fetchPlaylistByUrl.mockReset(); + electronBridge.onPlayerError.mockReset(); + store.dispatch.mockReset(); + + TestBed.configureTestingModule({ + providers: [ + ElectronService, + { + provide: DialogService, + useValue: { openConfirmDialog: jest.fn() }, + }, + { + provide: MatSnackBar, + useValue: { open: jest.fn() }, + }, + { + provide: SettingsStore, + useValue: { + getTrustOptions: jest.fn(() => ({ + trustedInsecureTlsHosts: [], + trustedPrivateNetworkEpgUrls: [], + })), + }, + }, + { provide: Store, useValue: store }, + { + provide: TranslateService, + useValue: { instant: jest.fn((key: string) => key) }, + }, + ], + }); + service = TestBed.inject(ElectronService); + }); + + afterEach(() => { + delete (globalThis as unknown as Record)[hookSymbol]; + Object.defineProperty(window, 'electron', { + configurable: true, + value: undefined, + }); + }); + + it('marks the initial URL import store dispatch without exposing playlist data', async () => { + const events: RendererPerformancePhaseEvent[] = []; + ( + globalThis as unknown as Record< + symbol, + (event: RendererPerformancePhaseEvent) => void + > + )[hookSymbol] = (event) => events.push(event); + const playlist = { + playlist: { items: [{ name: 'Sensitive channel' }] }, + title: 'Sensitive title', + url: 'https://user:secret@example.test/list.m3u', + } as Playlist; + electronBridge.fetchPlaylistByUrl.mockResolvedValue(playlist); + + await service.sendIpcEvent(PLAYLIST_PARSE_BY_URL, { + title: playlist.title, + url: playlist.url, + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(store.dispatch).toHaveBeenCalledWith( + PlaylistActions.handleAddingPlaylistByUrl({ + isTemporary: false, + playlist, + }) + ); + expect( + events.map(({ boundary, outcome, phase }) => ({ + boundary, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + outcome: undefined, + phase: 'store.m3u-import-dispatch', + }, + { + boundary: 'end', + outcome: 'success', + phase: 'store.m3u-import-dispatch', + }, + ]); + expect(JSON.stringify(events)).not.toContain('Sensitive'); + expect(JSON.stringify(events)).not.toContain('secret'); + }); +}); diff --git a/apps/web/src/app/services/electron.service.ts b/apps/web/src/app/services/electron.service.ts index 688e80844..cef656806 100644 --- a/apps/web/src/app/services/electron.service.ts +++ b/apps/web/src/app/services/electron.service.ts @@ -20,6 +20,10 @@ import { XTREAM_RESPONSE, XtreamCodeActions, } from '@iptvnator/shared/interfaces'; +import { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, +} from '@iptvnator/shared/logging'; import { AppConfig } from '../../environments/environment'; import { buildAutoUpdatePlaylistsFeedback } from './auto-update-playlists-feedback'; import { @@ -316,11 +320,15 @@ export class ElectronService extends DataService { this.settingsStore.getTrustOptions() ) .then((result) => { - this.store.dispatch( - PlaylistActions.handleAddingPlaylistByUrl({ - isTemporary: !!payload?.isTemporary, - playlist: result, - }) + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.M3U_IMPORT_DISPATCH, + () => + this.store.dispatch( + PlaylistActions.handleAddingPlaylistByUrl({ + isTemporary: !!payload?.isTemporary, + playlist: result, + }) + ) ); }) .catch((error: unknown) => { diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index df6d257a0..6c177858f 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -56,6 +56,95 @@ There is intentionally **no URL validation** (upstream removed it in 0.15.0): an The behavioral contract is guarded by `apps/web/src/app/iptv-playlist-parser.contract.spec.ts` (jest maps the module to the real parser source) and by the fork's own test suite. +## Initial URL Import Performance Benchmark (Electron) + +The Electron E2E project has a deterministic initial-import benchmark for +10,000-, 50,000-, and 100,000-channel M3U playlists. It uses only generated +fixtures served by an ephemeral `127.0.0.1` HTTP server; provider URLs, +credentials, and playlist data must never be used. Each formal scenario runs +one warm-up, five measured iterations, and one diagnostic iteration in a fresh +Electron process and data directory: + +```bash +perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-m3u-import" +IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \ +IPTVNATOR_PERF_VARIANT=baseline \ +pnpm nx run electron-backend-e2e:benchmark-m3u-import +``` + +Use a new output directory and `IPTVNATOR_PERF_VARIANT=after` for the identical +post-change run. Formal runs require a clean worktree and record the commit, +source-state hash, runtime, and exact fixture identity. A development smoke run +uses one warm-up, one measured iteration, and one diagnostic iteration per +size: + +```bash +IPTVNATOR_PERF_OUTPUT_DIR="$PWD/dist/performance/-m3u-import-smoke" \ +IPTVNATOR_PERF_VARIANT=smoke \ +IPTVNATOR_PERF_SMOKE=1 \ +pnpm nx run electron-backend-e2e:benchmark-m3u-import +``` + +Smoke runs may use a dirty worktree and validate only the harness; they cannot +support a performance claim. If another local application owns port 9222, smoke +only may set `IPTVNATOR_PERF_CDP_PORT` to an unused loopback port; formal runs +fail closed unless CDP uses `127.0.0.1:9222`. Raw captures and JSON results stay +under the gitignored `dist/performance/` tree; preflight rejects a symbolic +link in any existing output-path component before creating artifacts. Headline +distributions contain only the five measured runs: warm-up and diagnostic +profiles are never mixed into them. + +The benchmark attributes these non-additive intervals: + +| Field | Boundary | +| --- | --- | +| `dataAcquireMs` | loopback response acquisition | +| `m3uParsingMs` | parser call | +| `normalizationMs` | parsed-item normalization | +| `mainToRendererCloneProxyMs` | sum of normalized import-result delivery plus the upsert and GET main-response-to-preload-success legs | +| `storeImportDispatchMs` | renderer import dispatch | +| `rendererToMainCloneProxyMs` | sum of the upsert and GET preload-source-to-main-request legs | +| `mainToDatabaseWorkerCloneProxyMs` | sum of the upsert and GET main-request-to-worker-receive legs | +| `playlistSerializationMs` | database-worker playlist JSON serialization | +| `sqliteWriteMs` | SQLite upsert, including its autocommit | +| `sqliteReadMs` | SQLite read of the newly persisted playlist | +| `playlistDeserializationMs` | database-worker `parseAppPlaylist`, including playlist JSON parsing | +| `databaseWorkerToMainCloneProxyMs` | sum of the upsert and GET worker-response-post-to-main-response legs | +| `storePublishChannelsMs` | renderer channel publication | +| `angularRenderingMs` | publication end to the terminal two-frame paint proof | + +`ipcStructuredCloneProxyMs` is the explicit sum of the four directional proxy +fields. Each directional field can combine the applicable initial-result, +upsert, and `DB_GET_APP_PLAYLIST` legs. The worker stamps +`responsePostedEpochMs` after request profiling is finalized and immediately +before each response is posted; the database-worker-to-main proxy ends when +main receives that response. These fields are attribution aids, not an additive +waterfall: scheduler work and gaps can remain inside total wall time, and a +proxy can include response construction, dispatch overhead, and structured +clone work. Initial import does not create indexes; `indexAndCommitMs` is +therefore `N/A` with +`indexes-not-created-during-import;sqlite-autocommit-included-in-sqlite-write`. + +Instrumentation is development-only, opt-in, fail-neutral, and count-only. It +must not scan or log playlist payloads to generate metadata. Renderer +long-task, frame-gap, and heartbeat samples are clipped to the measured +operation boundary. Main capture stops only after the upsert and route-reload +GET responses plus both asynchronous preload success markers have arrived, so +return-clone attribution cannot race capture shutdown. Formal comparison fails +closed after writing raw results if exact-window renderer RSS, database-worker +peak heap/external samples, or the database worker's explicit post-GC heap is +missing or incoherent. Both initial-import database requests in every measured +run must also contain coherent event-loop delay, event-loop utilization, and +thread-CPU metrics; the validity record exposes the exact expected and valid +request counts rather than silently dropping nullable samples. An iteration +failure idempotently stops renderer timers, closes trace listeners/output, and +starts best-effort probe/session teardown without waiting on a wedged renderer +before Electron is closed. A partial capture-start failure performs the same +rollback before it escapes to the benchmark lifecycle. +Diagnostic artifacts require separate renderer, main, and database-worker CPU +profiles plus renderer/main/database-worker heap snapshots and a Chromium +trace; raw profiles remain ignored. + ## Playlist Refresh And Startup Auto-Update (Electron) Two paths re-download an M3U playlist from its original source: diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 5526d97f9..54f8bc266 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -128,6 +128,8 @@ Each enabled request gets a fresh event-loop-delay histogram and records: - `requestReceivedEpochMs`, `workStartedEpochMs`, `workEndedEpochMs`, and `histogramFlushedEpochMs` +- `responsePostedEpochMs`, sampled after profiling finalization and immediately + before the worker posts the response to main - worker-thread CPU user/system microseconds from `process.threadCpuUsage()` - event-loop utilization across the exact work interval - event-loop-delay max/p95/p99 from the request's own histogram @@ -146,6 +148,46 @@ profiling queue. If captures overlap, every overlapping response carries `invalidReason: "overlapping-database-worker-requests"` and all attributable CPU, ELU, and event-loop-delay values are `null`. This avoids assigning shared worker activity to one request while preserving normal worker concurrency. +`responsePostedEpochMs` remains a separate response boundary: the initial M3U +benchmark subtracts it from main's response receipt to attribute the +database-worker-to-main structured-clone proxy without folding that interval +into worker execution. + +Initial M3U import profiling requires exact operation-specific phase pairs: + +- `DB_UPSERT_APP_PLAYLIST`: `serialize.playlist`, then `sqlite.write`. The + first covers playlist JSON serialization; the second covers the SQLite + upsert and its autocommit. +- `DB_GET_APP_PLAYLIST`: `sqlite.read`, then `deserialize.playlist`. The first + covers the awaited single-row SQLite select; the second covers + `parseAppPlaylist`, including JSON parsing and persisted-field hydration. + +Missing, partial, reordered, or cross-operation phase sequences fail closed. +Markers carry item counts only and do not scan or copy the payload to compute +profiling metadata. The import creates no indexes, so there is no separate +index/transaction-commit phase. Across the upsert and GET requests, the formal +benchmark reports renderer-to-main, main-to-database-worker, +database-worker-to-main, and main-to-renderer structured-clone proxies; their +explicit sum is `ipcStructuredCloneProxyMs`. See +[M3U Playlist Module Architecture](./m3u-playlist-module.md#initial-url-import-performance-benchmark-electron) +for the complete cross-process attribution. + +Formal initial-import comparison also requires both request-scoped captures in +every measured run to have coherent event-loop delay, event-loop utilization, +and worker-thread CPU values with no unavailable or invalid reason. Summary +validity records the exact expected and valid request counts; nullable metrics +remain in raw results but cannot be silently omitted from comparison +distributions. + +The main-process benchmark samples the database worker's V8 +`used_heap_size` and `external_memory` independently. Raw output includes a +valid-sample count for each metric. A peak is numeric only after at least one +finite, non-negative isolate sample; otherwise it is `null` with a fixed +unavailability reason. An initialized zero is never used as evidence of a +successful sample. Formal initial-import comparisons require valid peak +samples from exactly one database worker in every measured run. Worker RSS is +not available per thread and remains part of the separately reported Electron +main-process RSS together with native and SQLite memory. ### Opt-in post-GC heap capture diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.performance.spec.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.performance.spec.ts new file mode 100644 index 000000000..ae41228fa --- /dev/null +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.performance.spec.ts @@ -0,0 +1,123 @@ +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { Store } from '@ngrx/store'; +import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { PlaylistsService } from '@iptvnator/services'; +import { type Channel, type Playlist } from '@iptvnator/shared/interfaces'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; +import { of, Subject } from 'rxjs'; +import { M3uWorkspaceRouteSession } from './m3u-workspace-route-session.service'; + +const PLAYLIST_ID = 'performance-playlist'; +const PRIMARY_CHANNEL = { + id: 'channel-1', + name: 'Sensitive channel', + url: 'https://stream-secret.example.test/1', +} as Channel; +const NEXT_CHANNEL = { + id: 'channel-2', + name: 'Next sensitive channel', + url: 'https://stream-secret.example.test/2', +} as Channel; + +async function flushEffects(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('M3uWorkspaceRouteSession performance phases', () => { + const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); + const store = { dispatch: jest.fn() }; + + beforeEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: { setUserAgent: jest.fn().mockResolvedValue(true) }, + }); + store.dispatch.mockReset(); + TestBed.configureTestingModule({ + providers: [ + M3uWorkspaceRouteSession, + { + provide: PlaylistContextFacade, + useValue: { + syncFromUrl: jest.fn(() => ({ + inWorkspace: true, + playlistId: PLAYLIST_ID, + provider: 'playlists', + section: 'all', + })), + }, + }, + { + provide: PlaylistsService, + useValue: { + getPlaylist: jest.fn(() => + of({ + playlist: { + items: [PRIMARY_CHANNEL, NEXT_CHANNEL], + }, + title: 'Sensitive title', + url: 'https://user:secret@example.test/list.m3u', + } as Playlist) + ), + }, + }, + { + provide: Router, + useValue: { + events: new Subject().asObservable(), + url: `/workspace/playlists/${PLAYLIST_ID}/all`, + }, + }, + { provide: Store, useValue: store }, + ], + }); + }); + + afterEach(() => { + delete (globalThis as unknown as Record)[hookSymbol]; + delete (window as unknown as { electron?: unknown }).electron; + }); + + it('marks channel publication with only the imported item count', async () => { + const events: RendererPerformancePhaseEvent[] = []; + ( + globalThis as unknown as Record< + symbol, + (event: RendererPerformancePhaseEvent) => void + > + )[hookSymbol] = (event) => events.push(event); + + TestBed.inject(M3uWorkspaceRouteSession); + await flushEffects(); + + expect( + events.map(({ boundary, metadata, outcome, phase }) => ({ + boundary, + metadata, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + metadata: { items: 2 }, + outcome: undefined, + phase: 'store.m3u-publish-channels', + }, + { + boundary: 'end', + metadata: { items: 2 }, + outcome: 'success', + phase: 'store.m3u-publish-channels', + }, + ]); + expect(JSON.stringify(events)).not.toContain('Sensitive'); + expect(JSON.stringify(events)).not.toContain('secret'); + expect(JSON.stringify(events)).not.toContain(PRIMARY_CHANNEL.url); + }); +}); diff --git a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts index 0db0dcd87..c0bba152b 100644 --- a/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts +++ b/libs/playlist/m3u/feature-player/src/lib/m3u-workspace-route-session.service.ts @@ -9,6 +9,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NavigationEnd, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { ChannelActions, FavoritesActions } from '@iptvnator/m3u-state'; +import { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, +} from '@iptvnator/shared/logging'; import { filter, firstValueFrom } from 'rxjs'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PlaylistsService } from '@iptvnator/services'; @@ -107,10 +111,16 @@ export class M3uWorkspaceRouteSession { ); }); - this.store.dispatch( - ChannelActions.setChannels({ - channels: playlist.playlist?.items ?? [], - }) + const channels = playlist.playlist?.items ?? []; + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.M3U_PUBLISH_CHANNELS, + () => + this.store.dispatch( + ChannelActions.setChannels({ + channels, + }) + ), + () => ({ items: channels.length }) ); const favorites = (playlist.favorites ?? []).filter( diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index c3aa8bf8c..e8bf168f0 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -19,6 +19,7 @@ export * from './lib/ipc-commands'; export * from './lib/language.enum'; export * from './lib/m3u-favorite-channel.interface'; export * from './lib/parsed-playlist.interface'; +export * from './lib/performance-phase.interface'; export * from './lib/playback-position.interface'; export * from './lib/playlist-auto-update.interface'; export * from './lib/playlist-backup.interface'; diff --git a/libs/shared/interfaces/src/lib/performance-phase.interface.ts b/libs/shared/interfaces/src/lib/performance-phase.interface.ts new file mode 100644 index 000000000..bbca5daf3 --- /dev/null +++ b/libs/shared/interfaces/src/lib/performance-phase.interface.ts @@ -0,0 +1,53 @@ +export const PERFORMANCE_PHASE_BOUNDARY = { + END: 'end', + START: 'start', +} as const; + +export type PerformancePhaseBoundary = + (typeof PERFORMANCE_PHASE_BOUNDARY)[keyof typeof PERFORMANCE_PHASE_BOUNDARY]; + +export const M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL = + 'IPTVNATOR_M3U_IMPORT_PERFORMANCE_PHASE_EVENT'; + +export const M3U_IMPORT_PERFORMANCE_PHASE = { + DATA_ACQUIRE: 'data.acquire', + NORMALIZE: 'normalize', + PARSE_M3U: 'parse.m3u', +} as const; + +export type M3uImportPerformancePhase = + (typeof M3U_IMPORT_PERFORMANCE_PHASE)[keyof typeof M3U_IMPORT_PERFORMANCE_PHASE]; + +export const APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE = { + SERIALIZE_PLAYLIST: 'serialize.playlist', + SQLITE_WRITE: 'sqlite.write', +} as const; + +export type AppPlaylistUpsertPerformancePhase = + (typeof APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE)[keyof typeof APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE]; + +export const APP_PLAYLIST_GET_PERFORMANCE_PHASE = { + DESERIALIZE_PLAYLIST: 'deserialize.playlist', + SQLITE_READ: 'sqlite.read', +} as const; + +export type AppPlaylistGetPerformancePhase = + (typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE)[keyof typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE]; + +export type AppPlaylistPerformancePhase = + | AppPlaylistGetPerformancePhase + | AppPlaylistUpsertPerformancePhase; + +export interface PerformancePhaseMetadata { + readonly byteCount?: number; + readonly itemCount?: number; +} + +export interface PerformancePhaseEvent { + readonly boundary: PerformancePhaseBoundary; + readonly durationMs: number | null; + readonly epochMs: number; + readonly metadata?: PerformancePhaseMetadata; + readonly phase: TPhase; + readonly requestId: string; +} diff --git a/libs/shared/logging/src/index.ts b/libs/shared/logging/src/index.ts index 1a61e0b26..99feec49b 100644 --- a/libs/shared/logging/src/index.ts +++ b/libs/shared/logging/src/index.ts @@ -3,3 +3,16 @@ export { redactSensitiveData, } from './lib/redact-sensitive-data'; export type { RedactionOptions } from './lib/redact-sensitive-data'; +export { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, +} from './lib/renderer-performance-phase'; +export type { + RendererPerformancePhase, + RendererPerformancePhaseBoundary, + RendererPerformancePhaseEvent, + RendererPerformancePhaseHook, + RendererPerformancePhaseMetadata, + RendererPerformancePhaseOutcome, +} from './lib/renderer-performance-phase'; diff --git a/libs/shared/logging/src/lib/renderer-performance-phase.spec.ts b/libs/shared/logging/src/lib/renderer-performance-phase.spec.ts new file mode 100644 index 000000000..ded5179e4 --- /dev/null +++ b/libs/shared/logging/src/lib/renderer-performance-phase.spec.ts @@ -0,0 +1,199 @@ +import * as sharedLogging from '../index'; + +type RendererPerformancePhase = + 'store.m3u-import-dispatch' | 'store.m3u-publish-channels'; +type RendererPerformancePhaseOutcome = 'error' | 'success'; + +interface RendererPerformancePhaseEvent { + readonly boundary: 'end' | 'start'; + readonly epochMs: number; + readonly metadata?: Readonly<{ items: number }>; + readonly monotonicMs: number; + readonly outcome?: RendererPerformancePhaseOutcome; + readonly phase: RendererPerformancePhase; + readonly phaseId: string; + readonly schemaVersion: 1; +} + +type RendererPerformancePhaseHook = ( + event: RendererPerformancePhaseEvent +) => void; + +interface RendererPerformancePhaseModule { + readonly measureRendererPerformancePhase?: ( + phase: RendererPerformancePhase, + execute: () => T, + metadata?: () => Readonly<{ items: number }> + ) => T; + readonly RENDERER_PERFORMANCE_PHASE?: Readonly<{ + M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch'; + M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels'; + }>; + readonly RENDERER_PERFORMANCE_PHASE_HOOK_KEY?: string; +} + +const rendererPerformance = sharedLogging as RendererPerformancePhaseModule; +const EXPECTED_HOOK_KEY = 'iptvnator.renderer.performance-phase-hook.v1'; +const hookSymbol = Symbol.for(EXPECTED_HOOK_KEY); + +function measure( + phase: RendererPerformancePhase, + execute: () => T, + metadata?: () => Readonly<{ items: number }> +): T { + const measurePhase = rendererPerformance.measureRendererPerformancePhase; + expect(typeof measurePhase).toBe('function'); + if (measurePhase === undefined) { + throw new Error('renderer performance phase helper is unavailable'); + } + return measurePhase(phase, execute, metadata); +} + +function setHook(hook: RendererPerformancePhaseHook | null): void { + const target = globalThis as unknown as Record; + if (hook === null) { + delete target[hookSymbol]; + return; + } + target[hookSymbol] = hook; +} + +describe('renderer performance phases', () => { + afterEach(() => { + setHook(null); + jest.restoreAllMocks(); + }); + + it('does not evaluate metadata while the global opt-in hook is absent', () => { + const metadata = jest.fn(() => ({ items: 3 })); + const resultIdentity = { imported: true }; + + const result = measure( + 'store.m3u-publish-channels', + () => resultIdentity, + metadata + ); + + expect(result).toBe(resultIdentity); + expect(metadata).not.toHaveBeenCalled(); + }); + + it('emits an immutable successful pair with epoch and monotonic timestamps', () => { + const events: RendererPerformancePhaseEvent[] = []; + setHook((event) => events.push(event)); + const resultIdentity = { imported: true }; + + const result = measure( + 'store.m3u-publish-channels', + () => resultIdentity, + () => ({ items: 3 }) + ); + + expect(result).toBe(resultIdentity); + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + boundary: 'start', + metadata: { items: 3 }, + phase: 'store.m3u-publish-channels', + schemaVersion: 1, + }); + expect(events[1]).toMatchObject({ + boundary: 'end', + metadata: { items: 3 }, + outcome: 'success', + phase: 'store.m3u-publish-channels', + schemaVersion: 1, + }); + expect(events[1].phaseId).toBe(events[0].phaseId); + expect(events[0].epochMs).toEqual(expect.any(Number)); + expect(events[0].monotonicMs).toEqual(expect.any(Number)); + expect(events[1].epochMs).toBeGreaterThanOrEqual(events[0].epochMs); + expect(events[1].monotonicMs).toBeGreaterThanOrEqual( + events[0].monotonicMs + ); + expect(Object.isFrozen(events[0])).toBe(true); + expect(Object.isFrozen(events[0].metadata)).toBe(true); + expect(events[1].metadata).toBe(events[0].metadata); + }); + + it('emits an error boundary and rethrows the exact product error', () => { + const events: RendererPerformancePhaseEvent[] = []; + setHook((event) => events.push(event)); + const productError = new Error('dispatch failed'); + + let thrown: unknown; + try { + measure('store.m3u-import-dispatch', () => { + throw productError; + }); + } catch (error: unknown) { + thrown = error; + } + + expect(thrown).toBe(productError); + expect(events).toHaveLength(2); + expect(events[1]).toMatchObject({ + boundary: 'end', + outcome: 'error', + phase: 'store.m3u-import-dispatch', + }); + expect(events[1].phaseId).toBe(events[0].phaseId); + }); + + it('keeps product results and errors unchanged when metadata or hooks fail', () => { + setHook(() => { + throw new Error('benchmark hook failed'); + }); + const resultIdentity = { imported: true }; + + expect( + measure( + 'store.m3u-publish-channels', + () => resultIdentity, + () => { + throw new Error('metadata failed'); + } + ) + ).toBe(resultIdentity); + + const productError = new Error('product failed'); + let thrown: unknown; + try { + measure('store.m3u-import-dispatch', () => { + throw productError; + }); + } catch (error: unknown) { + thrown = error; + } + expect(thrown).toBe(productError); + }); + + it('exposes only the fixed phase vocabulary and item-count metadata', () => { + expect(rendererPerformance.RENDERER_PERFORMANCE_PHASE_HOOK_KEY).toBe( + EXPECTED_HOOK_KEY + ); + expect(rendererPerformance.RENDERER_PERFORMANCE_PHASE).toEqual({ + M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch', + M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels', + }); + + const events: RendererPerformancePhaseEvent[] = []; + setHook((event) => events.push(event)); + measure( + 'store.m3u-publish-channels', + () => undefined, + () => + ({ + items: 2, + playlistUrl: 'https://user:secret@example.test/list.m3u', + }) as { items: number } + ); + + expect(events.map((event) => event.metadata)).toEqual([ + { items: 2 }, + { items: 2 }, + ]); + expect(JSON.stringify(events)).not.toContain('secret'); + expect(JSON.stringify(events)).not.toContain('playlistUrl'); + }); +}); diff --git a/libs/shared/logging/src/lib/renderer-performance-phase.ts b/libs/shared/logging/src/lib/renderer-performance-phase.ts new file mode 100644 index 000000000..d9a8b64ae --- /dev/null +++ b/libs/shared/logging/src/lib/renderer-performance-phase.ts @@ -0,0 +1,181 @@ +export const RENDERER_PERFORMANCE_PHASE_HOOK_KEY = + 'iptvnator.renderer.performance-phase-hook.v1'; + +export const RENDERER_PERFORMANCE_PHASE = { + M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch', + M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels', +} as const; + +export type RendererPerformancePhase = + (typeof RENDERER_PERFORMANCE_PHASE)[keyof typeof RENDERER_PERFORMANCE_PHASE]; + +export type RendererPerformancePhaseBoundary = 'end' | 'start'; +export type RendererPerformancePhaseOutcome = 'error' | 'success'; + +export interface RendererPerformancePhaseMetadata { + readonly items: number; +} + +export interface RendererPerformancePhaseEvent { + readonly boundary: RendererPerformancePhaseBoundary; + readonly epochMs: number; + readonly metadata?: Readonly; + readonly monotonicMs: number; + readonly outcome?: RendererPerformancePhaseOutcome; + readonly phase: RendererPerformancePhase; + readonly phaseId: string; + readonly schemaVersion: 1; +} + +export type RendererPerformancePhaseHook = ( + event: RendererPerformancePhaseEvent +) => void; + +interface PhaseStart { + readonly epochMs: number; + readonly metadata?: Readonly; + readonly monotonicMs: number; + readonly phase: RendererPerformancePhase; + readonly phaseId: string; +} + +interface PhaseTimestamp { + readonly epochMs: number; + readonly monotonicMs: number; +} + +const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); +let nextPhaseId = 0; + +export function measureRendererPerformancePhase( + phase: RendererPerformancePhase, + execute: () => T, + createMetadata?: () => RendererPerformancePhaseMetadata +): T { + const hook = readHook(); + if (hook === null) { + return execute(); + } + + const start = beginPhase(phase, createMetadata); + emit(hook, { + boundary: 'start', + epochMs: start.epochMs, + metadata: start.metadata, + monotonicMs: start.monotonicMs, + phase: start.phase, + phaseId: start.phaseId, + schemaVersion: 1, + }); + + try { + const result = execute(); + endPhase(hook, start, 'success'); + return result; + } catch (error) { + endPhase(hook, start, 'error'); + throw error; + } +} + +function beginPhase( + phase: RendererPerformancePhase, + createMetadata: (() => RendererPerformancePhaseMetadata) | undefined +): PhaseStart { + const timestamp = sampleTimestamp(); + return { + ...timestamp, + metadata: readMetadata(createMetadata), + phase, + phaseId: `renderer-performance-${++nextPhaseId}`, + }; +} + +function endPhase( + hook: RendererPerformancePhaseHook, + start: PhaseStart, + outcome: RendererPerformancePhaseOutcome +): void { + const sampled = sampleTimestamp(); + emit(hook, { + boundary: 'end', + epochMs: Math.max(start.epochMs, sampled.epochMs), + metadata: start.metadata, + monotonicMs: Math.max(start.monotonicMs, sampled.monotonicMs), + outcome, + phase: start.phase, + phaseId: start.phaseId, + schemaVersion: 1, + }); +} + +function readHook(): RendererPerformancePhaseHook | null { + try { + const hook = (globalThis as unknown as Record)[ + hookSymbol + ]; + return typeof hook === 'function' + ? (hook as RendererPerformancePhaseHook) + : null; + } catch { + return null; + } +} + +function readMetadata( + createMetadata: (() => RendererPerformancePhaseMetadata) | undefined +): Readonly | undefined { + if (createMetadata === undefined) { + return undefined; + } + + try { + const items = createMetadata().items; + return Number.isSafeInteger(items) && items >= 0 + ? Object.freeze({ items }) + : undefined; + } catch { + return undefined; + } +} + +function emit( + hook: RendererPerformancePhaseHook, + event: RendererPerformancePhaseEvent +): void { + try { + hook(Object.freeze(event)); + } catch { + // Development-only benchmark instrumentation must stay fail-neutral. + } +} + +function sampleTimestamp(): PhaseTimestamp { + const fallbackMs = readDateNow(); + try { + const monotonicMs = globalThis.performance?.now(); + const timeOrigin = globalThis.performance?.timeOrigin; + if (!Number.isFinite(monotonicMs)) { + return { epochMs: fallbackMs, monotonicMs: fallbackMs }; + } + const epochMs = + Number.isFinite(timeOrigin) && timeOrigin !== undefined + ? timeOrigin + monotonicMs + : fallbackMs; + return { + epochMs: Number.isFinite(epochMs) ? epochMs : fallbackMs, + monotonicMs, + }; + } catch { + return { epochMs: fallbackMs, monotonicMs: fallbackMs }; + } +} + +function readDateNow(): number { + try { + const value = Date.now(); + return Number.isFinite(value) ? value : 0; + } catch { + return 0; + } +} From 518964c57fc92048538f36e1d740ecf93d766424 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:21:24 +0200 Subject: [PATCH 03/12] refactor(electron-backend): split the last four files over the max-lines cap (#1288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #1278, which split four of the files the electron-backend lint target had been silently skipping. Four were left over; this splits them, so no file in the project sits above the 400-line cap outside the baseline. None are added to tools/eslint/max-lines-baseline.mjs — the baseline only shrinks. Shared setup moves to *.test-helpers.ts, the suffix tsconfig.app.json already excludes, so the production build never sees jest globals. - remote-control.events.spec.ts 588 -> 188, plus remote-control-http.spec.ts. The mock registry moves to remote-control.test-helpers.ts; each spec keeps its own jest.mock() factories, which resolve the mock-prefixed exports. - downloads.events.spec.ts 530 -> 182, plus downloads-actions.spec.ts. The jest.doMock setup is not hoisted, so the whole harness moves to downloads.test-helpers.ts behind setupDownloadsEventsHarness(). - http-server.spec.ts 448 -> 377, plus resolve-static-file-path.spec.ts. The resolveStaticFilePath cases were already self-contained. - worker-performance-capture.spec.ts 408 -> 149, plus worker-performance-capture.resilience.spec.ts, matching the .concurrency and .histogram siblings. Test bodies are unchanged; the helpers keep the same identifiers in scope so the splits are a move, not a rewrite. Verified with the glob quoted locally (that quoting is #1176's, not part of this change): 245 files, 0 max-lines errors, and the only remaining lint errors are the five #1176 fixes. Both tsconfig.app.json and tsconfig.spec.json typecheck clean. Note: worker-performance-capture.concurrency.spec.ts is flaky on master independently of this change — it asserts a real 20ms event-loop block and failed 4/4 clean-master runs here. Co-authored-by: Claude Opus 5 --- .../events/database/downloads-actions.spec.ts | 196 ++++++++ .../events/database/downloads.events.spec.ts | 372 +-------------- .../events/database/downloads.test-helpers.ts | 192 ++++++++ .../app/events/remote-control-http.spec.ts | 264 +++++++++++ .../app/events/remote-control.events.spec.ts | 448 +----------------- .../app/events/remote-control.test-helpers.ts | 249 ++++++++++ .../src/app/server/http-server.spec.ts | 75 +-- .../server/resolve-static-file-path.spec.ts | 74 +++ ...ker-performance-capture.resilience.spec.ts | 285 +++++++++++ .../worker-performance-capture.spec.ts | 259 ---------- 10 files changed, 1298 insertions(+), 1116 deletions(-) create mode 100644 apps/electron-backend/src/app/events/database/downloads-actions.spec.ts create mode 100644 apps/electron-backend/src/app/events/database/downloads.test-helpers.ts create mode 100644 apps/electron-backend/src/app/events/remote-control-http.spec.ts create mode 100644 apps/electron-backend/src/app/events/remote-control.test-helpers.ts create mode 100644 apps/electron-backend/src/app/server/resolve-static-file-path.spec.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-capture.resilience.spec.ts diff --git a/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts b/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts new file mode 100644 index 000000000..51234846f --- /dev/null +++ b/apps/electron-backend/src/app/events/database/downloads-actions.spec.ts @@ -0,0 +1,196 @@ +import { + expectManagedPathLookup, + getHandler, + MANAGED_PATH_STATE, + mockExistsSync, + mockManagedPath, + mockOpenPath, + mockPauseDownload, + mockResumeDownloadRequest, + mockShowItemInFolder, + setupDownloadsEventsHarness, +} from './downloads.test-helpers'; + +describe('downloads events: pause, resume, and reveal', () => { + beforeEach(async () => { + await setupDownloadsEventsHarness(); + }); + + it('maps a successful runtime pause to a success response', async () => { + mockPauseDownload.mockResolvedValue(true); + const consoleLog = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + + try { + await expect( + getHandler('DOWNLOADS_PAUSE')(null, 42) + ).resolves.toEqual({ success: true }); + } finally { + consoleLog.mockRestore(); + } + + expect(mockPauseDownload).toHaveBeenCalledWith(42); + }); + + it('maps an unknown pause target to an error response', async () => { + mockPauseDownload.mockResolvedValue(false); + const consoleLog = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + + try { + await expect( + getHandler('DOWNLOADS_PAUSE')(null, 42) + ).resolves.toEqual({ + error: 'Download not found in queue', + success: false, + }); + } finally { + consoleLog.mockRestore(); + } + }); + + it('forwards resume requests with the download folder and returns the result', async () => { + mockResumeDownloadRequest.mockResolvedValue({ + error: 'Can only resume paused downloads', + success: false, + }); + + await expect( + getHandler('DOWNLOADS_RESUME')(null, 42, '/downloads') + ).resolves.toEqual({ + error: 'Can only resume paused downloads', + success: false, + }); + + expect(mockResumeDownloadRequest).toHaveBeenCalledWith( + 42, + '/downloads', + expect.anything() + ); + }); + + describe.each([ + { + channel: 'DOWNLOADS_REVEAL_FILE', + filePath: '/downloads/reveal-boundary.mp4', + operation: 'reveal', + }, + { + channel: 'DOWNLOADS_PLAY_FILE', + filePath: '/downloads/play-boundary.mp4', + operation: 'play', + }, + ])('$operation managed-path boundary', ({ channel, filePath }) => { + it('rejects an unmanaged database path before accessing the filesystem', async () => { + const lookup = mockManagedPath(MANAGED_PATH_STATE.UNMANAGED); + mockExistsSync.mockReturnValue(true); + + await expect(getHandler(channel)(null, filePath)).resolves.toEqual({ + error: 'File not found', + success: false, + }); + + expectManagedPathLookup(lookup, filePath); + expect(mockExistsSync).not.toHaveBeenCalled(); + expect(mockOpenPath).not.toHaveBeenCalled(); + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + }); + + it('rejects a managed database path that is missing from disk', async () => { + const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); + mockExistsSync.mockReturnValue(false); + + await expect(getHandler(channel)(null, filePath)).resolves.toEqual({ + error: 'File not found', + success: false, + }); + + expectManagedPathLookup(lookup, filePath); + expect(mockExistsSync).toHaveBeenCalledTimes(1); + expect(mockExistsSync).toHaveBeenCalledWith(filePath); + expect(mockOpenPath).not.toHaveBeenCalled(); + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + }); + + it('fails closed when the managed-path database query rejects', async () => { + const lookup = mockManagedPath(MANAGED_PATH_STATE.ERROR); + mockExistsSync.mockReturnValue(true); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + await expect( + getHandler(channel)(null, filePath) + ).resolves.toEqual({ + error: 'File not found', + success: false, + }); + expect(consoleError).toHaveBeenCalledTimes(1); + expect(consoleError).toHaveBeenCalledWith( + 'Error verifying managed download path:', + expect.objectContaining({ + message: 'database unavailable', + }) + ); + } finally { + consoleError.mockRestore(); + } + + expectManagedPathLookup(lookup, filePath); + expect(mockExistsSync).not.toHaveBeenCalled(); + expect(mockOpenPath).not.toHaveBeenCalled(); + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + }); + }); + + it('reveals a managed file that exists on disk', async () => { + const filePath = '/downloads/reveal-success.mp4'; + const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); + mockExistsSync.mockReturnValue(true); + + await expect( + getHandler('DOWNLOADS_REVEAL_FILE')(null, filePath) + ).resolves.toEqual({ success: true }); + + expectManagedPathLookup(lookup, filePath); + expect(mockExistsSync).toHaveBeenCalledTimes(1); + expect(mockExistsSync).toHaveBeenCalledWith(filePath); + expect(mockShowItemInFolder).toHaveBeenCalledTimes(1); + expect(mockShowItemInFolder).toHaveBeenCalledWith(filePath); + expect(mockOpenPath).not.toHaveBeenCalled(); + }); + + it('waits for the native shell before reporting a managed file as played', async () => { + const filePath = '/downloads/play-success.mp4'; + const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); + mockExistsSync.mockReturnValue(true); + let resolveOpenPath!: (value: string) => void; + const openPathResult = new Promise((resolve) => { + resolveOpenPath = resolve; + }); + mockOpenPath.mockReturnValue(openPathResult); + + let responseSettled = false; + const response = getHandler('DOWNLOADS_PLAY_FILE')(null, filePath).then( + (result) => { + responseSettled = true; + return result; + } + ); + await new Promise((resolve) => setImmediate(resolve)); + + expectManagedPathLookup(lookup, filePath); + expect(mockExistsSync).toHaveBeenCalledTimes(1); + expect(mockExistsSync).toHaveBeenCalledWith(filePath); + expect(mockOpenPath).toHaveBeenCalledTimes(1); + expect(mockOpenPath).toHaveBeenCalledWith(filePath); + expect(mockShowItemInFolder).not.toHaveBeenCalled(); + expect(responseSettled).toBe(false); + + resolveOpenPath(''); + await expect(response).resolves.toEqual({ success: true }); + }); +}); diff --git a/apps/electron-backend/src/app/events/database/downloads.events.spec.ts b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts index 090eecabb..a68e70fdf 100644 --- a/apps/electron-backend/src/app/events/database/downloads.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts @@ -1,189 +1,19 @@ -type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise; +import { + createDownloadRow, + getHandler, + mockBroadcastDownloadUpdate, + mockDownloadRow, + mockRemoveDownloadFromRuntime, + mockRemovePartialDownloadFile, + mockTerminalRows, + setupDownloadsEventsHarness, +} from './downloads.test-helpers'; -const mockRegisteredHandlers = new Map(); -const mockGetDatabase = jest.fn(); -const mockRemoveDownloadFromRuntime = jest.fn(); -const mockBroadcastDownloadUpdate = jest.fn(); -const mockRemovePartialDownloadFile = jest.fn(); -const mockPauseDownload = jest.fn(); -const mockResumeDownloadRequest = jest.fn(); -const mockExistsSync = jest.fn(); -const mockOpenPath = jest.fn(); -const mockShowItemInFolder = jest.fn(); -const mockEq = jest.fn(); -let downloadsFilePathColumn: unknown; - -const MANAGED_PATH_STATE = { - ERROR: 'error', - MANAGED: 'managed', - UNMANAGED: 'unmanaged', -} as const; - -type ManagedPathState = - (typeof MANAGED_PATH_STATE)[keyof typeof MANAGED_PATH_STATE]; - -function getHandler(channel: string): IpcHandler { - const handler = mockRegisteredHandlers.get(channel); - if (!handler) { - throw new Error(`Expected IPC handler for ${channel}`); - } - - return handler; -} - -function createDownloadRow(status: string) { - return { - filePath: '/downloads/resume.mp4', - status, - }; -} - -describe('downloads events', () => { +describe('downloads events: partial-file cleanup', () => { beforeEach(async () => { - jest.resetModules(); - mockRegisteredHandlers.clear(); - mockGetDatabase.mockReset(); - mockRemoveDownloadFromRuntime.mockReset(); - mockBroadcastDownloadUpdate.mockReset(); - mockRemovePartialDownloadFile.mockReset(); - mockPauseDownload.mockReset(); - mockResumeDownloadRequest.mockReset(); - mockExistsSync.mockReset(); - mockOpenPath.mockReset().mockResolvedValue(''); - mockShowItemInFolder.mockReset(); - mockEq.mockReset(); - - jest.doMock('node:fs', () => ({ - ...jest.requireActual('node:fs'), - existsSync: mockExistsSync, - })); - jest.doMock('drizzle-orm', () => { - const actual = - jest.requireActual('drizzle-orm'); - mockEq.mockImplementation(actual.eq); - return { - ...actual, - eq: mockEq, - }; - }); - jest.doMock('electron', () => ({ - app: { - getPath: jest.fn((name: string) => - name === 'userData' ? '/user-data' : '/downloads' - ), - }, - dialog: { - showOpenDialog: jest.fn(), - }, - ipcMain: { - handle: jest.fn((channel: string, handler: IpcHandler) => { - mockRegisteredHandlers.set(channel, handler); - }), - }, - shell: { - openPath: mockOpenPath, - showItemInFolder: mockShowItemInFolder, - }, - })); - jest.doMock('../../database/connection', () => ({ - getDatabase: mockGetDatabase, - })); - jest.doMock('./download-file-path', () => ({ - removePartialDownloadFile: mockRemovePartialDownloadFile, - })); - jest.doMock('./download-runtime', () => ({ - broadcastDownloadUpdate: mockBroadcastDownloadUpdate, - cancelDownload: jest.fn(), - pauseDownload: mockPauseDownload, - removeDownloadFromRuntime: mockRemoveDownloadFromRuntime, - setMainWindow: jest.fn(), - })); - jest.doMock('./download-requests', () => ({ - resumeDownloadRequest: mockResumeDownloadRequest, - retryDownloadRequest: jest.fn(), - startDownloadRequest: jest.fn(), - })); - jest.doMock('./download-recovery', () => ({ - resetStaleDownloads: jest.fn(), - })); - - await import('./downloads.events'); - const schema = await import('../../database/schema'); - downloadsFilePathColumn = schema.downloads.filePath; + await setupDownloadsEventsHarness(); }); - function mockManagedPath(state: ManagedPathState) { - const limit = jest.fn(() => { - if (state === MANAGED_PATH_STATE.ERROR) { - return Promise.reject(new Error('database unavailable')); - } - return Promise.resolve( - state === MANAGED_PATH_STATE.MANAGED ? [{ id: 42 }] : [] - ); - }); - const where = jest.fn((_predicate: unknown) => ({ limit })); - const from = jest.fn(() => ({ where })); - const select = jest.fn(() => ({ from })); - const db = { select }; - mockGetDatabase.mockResolvedValue(db); - return { from, limit, select, where }; - } - - function expectManagedPathLookup( - lookup: ReturnType, - filePath: string - ) { - expect(mockGetDatabase).toHaveBeenCalledTimes(1); - expect(lookup.select).toHaveBeenCalledTimes(1); - expect(lookup.from).toHaveBeenCalledTimes(1); - expect(lookup.where).toHaveBeenCalledTimes(1); - expect(mockEq).toHaveBeenCalledTimes(1); - expect(mockEq.mock.calls[0][0] === downloadsFilePathColumn).toBe(true); - expect(mockEq.mock.calls[0][1]).toBe(filePath); - expect( - lookup.where.mock.calls[0][0] === mockEq.mock.results[0].value - ).toBe(true); - expect(lookup.limit).toHaveBeenCalledTimes(1); - expect(lookup.limit).toHaveBeenCalledWith(1); - } - - function mockDownloadRow(row: { filePath: string | null; status: string }) { - const deleteWhere = jest.fn().mockResolvedValue(undefined); - const db = { - delete: jest.fn(() => ({ where: deleteWhere })), - select: jest.fn(() => ({ - from: jest.fn(() => ({ - where: jest.fn(() => ({ - limit: jest.fn().mockResolvedValue([row]), - })), - })), - })), - }; - mockGetDatabase.mockResolvedValue(db); - return { db, deleteWhere }; - } - - function mockTerminalRows( - rows: Array<{ filePath: string | null; status: string }> - ) { - const deleteWhere = jest.fn().mockResolvedValue(undefined); - const selectWhere = jest - .fn() - .mockResolvedValue( - rows.map((row, index) => ({ id: index + 1, ...row })) - ); - const db = { - delete: jest.fn(() => ({ where: deleteWhere })), - select: jest.fn(() => ({ - from: jest.fn(() => ({ - where: selectWhere, - })), - })), - }; - mockGetDatabase.mockResolvedValue(db); - return { db, deleteWhere, selectWhere }; - } - it('removes queued resumed partial files before deleting the row', async () => { const { deleteWhere } = mockDownloadRow(createDownloadRow('queued')); @@ -349,182 +179,4 @@ describe('downloads events', () => { expect(mockRemovePartialDownloadFile).toHaveBeenCalledTimes(2); expect(deleteWhere).toHaveBeenCalledTimes(1); }); - - it('maps a successful runtime pause to a success response', async () => { - mockPauseDownload.mockResolvedValue(true); - const consoleLog = jest - .spyOn(console, 'log') - .mockImplementation(() => undefined); - - try { - await expect( - getHandler('DOWNLOADS_PAUSE')(null, 42) - ).resolves.toEqual({ success: true }); - } finally { - consoleLog.mockRestore(); - } - - expect(mockPauseDownload).toHaveBeenCalledWith(42); - }); - - it('maps an unknown pause target to an error response', async () => { - mockPauseDownload.mockResolvedValue(false); - const consoleLog = jest - .spyOn(console, 'log') - .mockImplementation(() => undefined); - - try { - await expect( - getHandler('DOWNLOADS_PAUSE')(null, 42) - ).resolves.toEqual({ - error: 'Download not found in queue', - success: false, - }); - } finally { - consoleLog.mockRestore(); - } - }); - - it('forwards resume requests with the download folder and returns the result', async () => { - mockResumeDownloadRequest.mockResolvedValue({ - error: 'Can only resume paused downloads', - success: false, - }); - - await expect( - getHandler('DOWNLOADS_RESUME')(null, 42, '/downloads') - ).resolves.toEqual({ - error: 'Can only resume paused downloads', - success: false, - }); - - expect(mockResumeDownloadRequest).toHaveBeenCalledWith( - 42, - '/downloads', - expect.anything() - ); - }); - - describe.each([ - { - channel: 'DOWNLOADS_REVEAL_FILE', - filePath: '/downloads/reveal-boundary.mp4', - operation: 'reveal', - }, - { - channel: 'DOWNLOADS_PLAY_FILE', - filePath: '/downloads/play-boundary.mp4', - operation: 'play', - }, - ])('$operation managed-path boundary', ({ channel, filePath }) => { - it('rejects an unmanaged database path before accessing the filesystem', async () => { - const lookup = mockManagedPath(MANAGED_PATH_STATE.UNMANAGED); - mockExistsSync.mockReturnValue(true); - - await expect(getHandler(channel)(null, filePath)).resolves.toEqual({ - error: 'File not found', - success: false, - }); - - expectManagedPathLookup(lookup, filePath); - expect(mockExistsSync).not.toHaveBeenCalled(); - expect(mockOpenPath).not.toHaveBeenCalled(); - expect(mockShowItemInFolder).not.toHaveBeenCalled(); - }); - - it('rejects a managed database path that is missing from disk', async () => { - const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); - mockExistsSync.mockReturnValue(false); - - await expect(getHandler(channel)(null, filePath)).resolves.toEqual({ - error: 'File not found', - success: false, - }); - - expectManagedPathLookup(lookup, filePath); - expect(mockExistsSync).toHaveBeenCalledTimes(1); - expect(mockExistsSync).toHaveBeenCalledWith(filePath); - expect(mockOpenPath).not.toHaveBeenCalled(); - expect(mockShowItemInFolder).not.toHaveBeenCalled(); - }); - - it('fails closed when the managed-path database query rejects', async () => { - const lookup = mockManagedPath(MANAGED_PATH_STATE.ERROR); - mockExistsSync.mockReturnValue(true); - const consoleError = jest - .spyOn(console, 'error') - .mockImplementation(() => undefined); - - try { - await expect( - getHandler(channel)(null, filePath) - ).resolves.toEqual({ - error: 'File not found', - success: false, - }); - expect(consoleError).toHaveBeenCalledTimes(1); - expect(consoleError).toHaveBeenCalledWith( - 'Error verifying managed download path:', - expect.objectContaining({ - message: 'database unavailable', - }) - ); - } finally { - consoleError.mockRestore(); - } - - expectManagedPathLookup(lookup, filePath); - expect(mockExistsSync).not.toHaveBeenCalled(); - expect(mockOpenPath).not.toHaveBeenCalled(); - expect(mockShowItemInFolder).not.toHaveBeenCalled(); - }); - }); - - it('reveals a managed file that exists on disk', async () => { - const filePath = '/downloads/reveal-success.mp4'; - const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); - mockExistsSync.mockReturnValue(true); - - await expect( - getHandler('DOWNLOADS_REVEAL_FILE')(null, filePath) - ).resolves.toEqual({ success: true }); - - expectManagedPathLookup(lookup, filePath); - expect(mockExistsSync).toHaveBeenCalledTimes(1); - expect(mockExistsSync).toHaveBeenCalledWith(filePath); - expect(mockShowItemInFolder).toHaveBeenCalledTimes(1); - expect(mockShowItemInFolder).toHaveBeenCalledWith(filePath); - expect(mockOpenPath).not.toHaveBeenCalled(); - }); - - it('waits for the native shell before reporting a managed file as played', async () => { - const filePath = '/downloads/play-success.mp4'; - const lookup = mockManagedPath(MANAGED_PATH_STATE.MANAGED); - mockExistsSync.mockReturnValue(true); - let resolveOpenPath!: (value: string) => void; - const openPathResult = new Promise((resolve) => { - resolveOpenPath = resolve; - }); - mockOpenPath.mockReturnValue(openPathResult); - - let responseSettled = false; - const response = getHandler('DOWNLOADS_PLAY_FILE')(null, filePath).then( - (result) => { - responseSettled = true; - return result; - } - ); - await new Promise((resolve) => setImmediate(resolve)); - - expectManagedPathLookup(lookup, filePath); - expect(mockExistsSync).toHaveBeenCalledTimes(1); - expect(mockExistsSync).toHaveBeenCalledWith(filePath); - expect(mockOpenPath).toHaveBeenCalledTimes(1); - expect(mockOpenPath).toHaveBeenCalledWith(filePath); - expect(mockShowItemInFolder).not.toHaveBeenCalled(); - expect(responseSettled).toBe(false); - - resolveOpenPath(''); - await expect(response).resolves.toEqual({ success: true }); - }); }); diff --git a/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts b/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts new file mode 100644 index 000000000..21f2dd4ab --- /dev/null +++ b/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts @@ -0,0 +1,192 @@ +type IpcHandler = (_event: unknown, ...args: unknown[]) => Promise; + +/** + * Shared harness for the downloads-events specs. + * + * The module registry is reset and re-mocked per test via `jest.doMock`, which + * is deliberately not hoisted — so the whole setup can live here and each spec + * just calls `setupDownloadsEventsHarness()` from its own `beforeEach`. + */ + +export const mockRegisteredHandlers = new Map(); +export const mockGetDatabase = jest.fn(); +export const mockRemoveDownloadFromRuntime = jest.fn(); +export const mockBroadcastDownloadUpdate = jest.fn(); +export const mockRemovePartialDownloadFile = jest.fn(); +export const mockPauseDownload = jest.fn(); +export const mockResumeDownloadRequest = jest.fn(); +export const mockExistsSync = jest.fn(); +export const mockOpenPath = jest.fn(); +export const mockShowItemInFolder = jest.fn(); +export const mockEq = jest.fn(); +let downloadsFilePathColumn: unknown; + +export const MANAGED_PATH_STATE = { + ERROR: 'error', + MANAGED: 'managed', + UNMANAGED: 'unmanaged', +} as const; + +export type ManagedPathState = + (typeof MANAGED_PATH_STATE)[keyof typeof MANAGED_PATH_STATE]; + +export function getHandler(channel: string): IpcHandler { + const handler = mockRegisteredHandlers.get(channel); + if (!handler) { + throw new Error(`Expected IPC handler for ${channel}`); + } + + return handler; +} + +export function createDownloadRow(status: string) { + return { + filePath: '/downloads/resume.mp4', + status, + }; +} + +export async function setupDownloadsEventsHarness(): Promise { + jest.resetModules(); + mockRegisteredHandlers.clear(); + mockGetDatabase.mockReset(); + mockRemoveDownloadFromRuntime.mockReset(); + mockBroadcastDownloadUpdate.mockReset(); + mockRemovePartialDownloadFile.mockReset(); + mockPauseDownload.mockReset(); + mockResumeDownloadRequest.mockReset(); + mockExistsSync.mockReset(); + mockOpenPath.mockReset().mockResolvedValue(''); + mockShowItemInFolder.mockReset(); + mockEq.mockReset(); + + jest.doMock('node:fs', () => ({ + ...jest.requireActual('node:fs'), + existsSync: mockExistsSync, + })); + jest.doMock('drizzle-orm', () => { + const actual = + jest.requireActual('drizzle-orm'); + mockEq.mockImplementation(actual.eq); + return { + ...actual, + eq: mockEq, + }; + }); + jest.doMock('electron', () => ({ + app: { + getPath: jest.fn((name: string) => + name === 'userData' ? '/user-data' : '/downloads' + ), + }, + dialog: { + showOpenDialog: jest.fn(), + }, + ipcMain: { + handle: jest.fn((channel: string, handler: IpcHandler) => { + mockRegisteredHandlers.set(channel, handler); + }), + }, + shell: { + openPath: mockOpenPath, + showItemInFolder: mockShowItemInFolder, + }, + })); + jest.doMock('../../database/connection', () => ({ + getDatabase: mockGetDatabase, + })); + jest.doMock('./download-file-path', () => ({ + removePartialDownloadFile: mockRemovePartialDownloadFile, + })); + jest.doMock('./download-runtime', () => ({ + broadcastDownloadUpdate: mockBroadcastDownloadUpdate, + cancelDownload: jest.fn(), + pauseDownload: mockPauseDownload, + removeDownloadFromRuntime: mockRemoveDownloadFromRuntime, + setMainWindow: jest.fn(), + })); + jest.doMock('./download-requests', () => ({ + resumeDownloadRequest: mockResumeDownloadRequest, + retryDownloadRequest: jest.fn(), + startDownloadRequest: jest.fn(), + })); + jest.doMock('./download-recovery', () => ({ + resetStaleDownloads: jest.fn(), + })); + + await import('./downloads.events'); + const schema = await import('../../database/schema'); + downloadsFilePathColumn = schema.downloads.filePath; +} + +export function mockManagedPath(state: ManagedPathState) { + const limit = jest.fn(() => { + if (state === MANAGED_PATH_STATE.ERROR) { + return Promise.reject(new Error('database unavailable')); + } + return Promise.resolve( + state === MANAGED_PATH_STATE.MANAGED ? [{ id: 42 }] : [] + ); + }); + const where = jest.fn((_predicate: unknown) => ({ limit })); + const from = jest.fn(() => ({ where })); + const select = jest.fn(() => ({ from })); + const db = { select }; + mockGetDatabase.mockResolvedValue(db); + return { from, limit, select, where }; +} + +export function expectManagedPathLookup( + lookup: ReturnType, + filePath: string +) { + expect(mockGetDatabase).toHaveBeenCalledTimes(1); + expect(lookup.select).toHaveBeenCalledTimes(1); + expect(lookup.from).toHaveBeenCalledTimes(1); + expect(lookup.where).toHaveBeenCalledTimes(1); + expect(mockEq).toHaveBeenCalledTimes(1); + expect(mockEq.mock.calls[0][0] === downloadsFilePathColumn).toBe(true); + expect(mockEq.mock.calls[0][1]).toBe(filePath); + expect( + lookup.where.mock.calls[0][0] === mockEq.mock.results[0].value + ).toBe(true); + expect(lookup.limit).toHaveBeenCalledTimes(1); + expect(lookup.limit).toHaveBeenCalledWith(1); +} + +export function mockDownloadRow(row: { filePath: string | null; status: string }) { + const deleteWhere = jest.fn().mockResolvedValue(undefined); + const db = { + delete: jest.fn(() => ({ where: deleteWhere })), + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: jest.fn(() => ({ + limit: jest.fn().mockResolvedValue([row]), + })), + })), + })), + }; + mockGetDatabase.mockResolvedValue(db); + return { db, deleteWhere }; +} + +export function mockTerminalRows( + rows: Array<{ filePath: string | null; status: string }> +) { + const deleteWhere = jest.fn().mockResolvedValue(undefined); + const selectWhere = jest + .fn() + .mockResolvedValue( + rows.map((row, index) => ({ id: index + 1, ...row })) + ); + const db = { + delete: jest.fn(() => ({ where: deleteWhere })), + select: jest.fn(() => ({ + from: jest.fn(() => ({ + where: selectWhere, + })), + })), + }; + mockGetDatabase.mockResolvedValue(db); + return { db, deleteWhere, selectWhere }; +} diff --git a/apps/electron-backend/src/app/events/remote-control-http.spec.ts b/apps/electron-backend/src/app/events/remote-control-http.spec.ts new file mode 100644 index 000000000..fc8c7617b --- /dev/null +++ b/apps/electron-backend/src/app/events/remote-control-http.spec.ts @@ -0,0 +1,264 @@ +import { + bootstrapRemoteControl as bootstrapRemoteControlWith, + createBodyAtByteLength, + getIpcListener, + invokeHttpHandler, + JSON_HEADERS, + METHOD_NOT_ALLOWED_RESPONSE, + mockFirstRendererSend, + mockGetAllWindows, + mockIpcHandle, + mockIpcOn, + mockRegisterRemoteControlHandler, + mockSecondRendererSend, + mockStartHttpServer, + mockStoreGet, + REMOTE_CONTROL_PATHS, + resetRemoteControlMocks, + SUCCESS_RESPONSE, + type RemoteControlSettings, +} from './remote-control.test-helpers'; + +jest.mock('electron', () => ({ + BrowserWindow: { + getAllWindows: mockGetAllWindows, + }, + ipcMain: { + handle: mockIpcHandle, + on: mockIpcOn, + }, +})); + +jest.mock('../server/http-server', () => ({ + httpServer: { + registerRemoteControlHandler: mockRegisterRemoteControlHandler, + start: mockStartHttpServer, + }, +})); + +jest.mock('../services/store.service', () => ({ + store: { + get: mockStoreGet, + }, +})); + +import { RemoteControlEvents } from './remote-control.events'; + +const bootstrapRemoteControl = (settings?: RemoteControlSettings) => + bootstrapRemoteControlWith(RemoteControlEvents, settings); + +describe('RemoteControlEvents HTTP endpoints', () => { + let consoleLog: jest.SpyInstance; + let consoleWarn: jest.SpyInstance; + + beforeEach(() => { + resetRemoteControlMocks(); + consoleLog = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + consoleLog.mockRestore(); + consoleWarn.mockRestore(); + }); + + it('rejects POST on the status endpoint', async () => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, { + method: 'POST', + }); + + expect(result.response).toEqual(METHOD_NOT_ALLOWED_RESPONSE); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it('floors a finite positive channel number and dispatches it', async () => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler( + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + { + method: 'POST', + body: JSON.stringify({ number: 7.9 }), + } + ); + + expect(result.response).toEqual(SUCCESS_RESPONSE); + expect(mockFirstRendererSend).toHaveBeenCalledWith( + 'REMOTE_CONTROL_COMMAND', + { + type: 'channel-select-number', + number: 7, + } + ); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it.each([ + { label: 'missing', body: '{}' }, + { label: 'zero', body: '{"number":0}' }, + { label: 'negative', body: '{"number":-2}' }, + { label: 'infinite', body: '{"number":1e309}' }, + { label: 'nonnumeric', body: '{"number":"seven"}' }, + ])('rejects a $label channel number', async ({ body }) => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler( + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + { + method: 'POST', + body, + } + ); + + expect(result.response).toEqual({ + statusCode: 400, + headers: JSON_HEADERS, + body: JSON.stringify({ error: 'Invalid channel number' }), + }); + expect(mockGetAllWindows).not.toHaveBeenCalled(); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it('rejects malformed JSON without dispatching', async () => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler( + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + { + method: 'POST', + body: '{"number":', + } + ); + + expect(result.response).toEqual({ + statusCode: 400, + headers: JSON_HEADERS, + body: JSON.stringify({ error: 'Invalid JSON payload' }), + }); + expect(mockGetAllWindows).not.toHaveBeenCalled(); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it('accepts a valid JSON request at the 10,240-byte body limit', async () => { + bootstrapRemoteControl(); + const body = createBodyAtByteLength(10 * 1024); + + const result = await invokeHttpHandler( + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + { + method: 'POST', + body, + } + ); + + expect(Buffer.byteLength(body, 'utf8')).toBe(10_240); + expect(result.response).toEqual(SUCCESS_RESPONSE); + expect(mockFirstRendererSend).toHaveBeenCalledWith( + 'REMOTE_CONTROL_COMMAND', + { + type: 'channel-select-number', + number: 7, + } + ); + }); + + it('rejects and destroys a 10,241-byte request without dispatching', async () => { + bootstrapRemoteControl(); + const body = Buffer.alloc(10 * 1024 + 1, 'x'); + + const result = await invokeHttpHandler( + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + { + method: 'POST', + body, + } + ); + await result.requestClosed; + + expect(body.byteLength).toBe(10_241); + expect(result.response).toEqual({ + statusCode: 413, + headers: JSON_HEADERS, + body: JSON.stringify({ error: 'Payload too large' }), + }); + expect(result.request.destroyed).toBe(true); + expect(result.requestErrors).toEqual([]); + expect(mockGetAllWindows).not.toHaveBeenCalled(); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it('merges partial status updates and refreshes updatedAt', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-07-25T10:00:00.000Z')); + bootstrapRemoteControl(); + const updateStatus = getIpcListener('REMOTE_CONTROL_STATUS_UPDATE'); + + jest.setSystemTime(new Date('2026-07-25T10:01:00.000Z')); + updateStatus( + {}, + { + portal: 'm3u', + isLiveView: true, + channelName: 'News', + volume: 35, + } + ); + jest.setSystemTime(new Date('2026-07-25T10:02:00.000Z')); + updateStatus({}, { channelName: 'Sports', muted: true }); + + const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, { + method: 'GET', + }); + + expect(result.response).toEqual({ + statusCode: 200, + headers: JSON_HEADERS, + body: JSON.stringify({ + portal: 'm3u', + isLiveView: true, + supportsVolume: false, + updatedAt: '2026-07-25T10:02:00.000Z', + channelName: 'Sports', + volume: 35, + muted: true, + }), + }); + }); + + it.each([ + { + path: REMOTE_CONTROL_PATHS.CHANNEL_UP, + warning: 'No browser windows found to send channel change', + }, + { + path: REMOTE_CONTROL_PATHS.VOLUME_UP, + warning: 'No browser windows found to send remote command', + }, + ])( + 'returns success and warns instead of throwing when $path has no renderer', + async ({ path, warning }) => { + mockGetAllWindows.mockReturnValue([]); + bootstrapRemoteControl(); + + await expect( + invokeHttpHandler(path, { method: 'POST' }) + ).resolves.toMatchObject({ + response: SUCCESS_RESPONSE, + }); + expect(consoleWarn).toHaveBeenCalledWith(warning); + expect(mockFirstRendererSend).not.toHaveBeenCalled(); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + } + ); +}); diff --git a/apps/electron-backend/src/app/events/remote-control.events.spec.ts b/apps/electron-backend/src/app/events/remote-control.events.spec.ts index 67713fe93..4c3f8efa5 100644 --- a/apps/electron-backend/src/app/events/remote-control.events.spec.ts +++ b/apps/electron-backend/src/app/events/remote-control.events.spec.ts @@ -1,81 +1,23 @@ -import type * as http from 'node:http'; -import { PassThrough } from 'node:stream'; - -type HttpHandler = ( - request: http.IncomingMessage, - response: http.ServerResponse -) => void; -type IpcCallback = (...args: unknown[]) => unknown; - -interface RemoteControlSettings { - enabled: boolean; - port: number; -} - -interface ResponseSnapshot { - statusCode: number; - headers: http.OutgoingHttpHeaders; - body: string; -} - -interface ResponseRecorder { - response: http.ServerResponse; - completed: Promise; -} - -interface RequestInvocation { - response: ResponseSnapshot; - request: PassThrough; - requestClosed: Promise; - requestErrors: Error[]; -} - -interface InvokeOptions { - method: string; - body?: string | Buffer; -} - -const REMOTE_CONTROL_PATHS = { - STATUS: '/api/remote-control/status', - SELECT_NUMBER: '/api/remote-control/channel/select-number', - CHANNEL_UP: '/api/remote-control/channel/up', - CHANNEL_DOWN: '/api/remote-control/channel/down', - VOLUME_UP: '/api/remote-control/volume/up', - VOLUME_DOWN: '/api/remote-control/volume/down', - VOLUME_TOGGLE_MUTE: '/api/remote-control/volume/toggle-mute', -} as const; - -const JSON_HEADERS = { 'Content-Type': 'application/json' } as const; -const SUCCESS_RESPONSE = { - statusCode: 200, - headers: JSON_HEADERS, - body: JSON.stringify({ success: true }), -} as const; -const METHOD_NOT_ALLOWED_RESPONSE = { - statusCode: 405, - headers: JSON_HEADERS, - body: JSON.stringify({ error: 'Method not allowed' }), -} as const; - -const mockHttpHandlers = new Map(); -const mockIpcHandlers = new Map(); -const mockIpcListeners = new Map(); -const mockRegisterRemoteControlHandler = jest.fn( - (path: string, handler: HttpHandler) => { - mockHttpHandlers.set(path, handler); - } -); -const mockStartHttpServer = jest.fn(); -const mockStoreGet = jest.fn(); -const mockFirstRendererSend = jest.fn(); -const mockSecondRendererSend = jest.fn(); -const mockGetAllWindows = jest.fn(); -const mockIpcHandle = jest.fn((channel: string, handler: IpcCallback) => { - mockIpcHandlers.set(channel, handler); -}); -const mockIpcOn = jest.fn((channel: string, listener: IpcCallback) => { - mockIpcListeners.set(channel, listener); -}); +import { + bootstrapRemoteControl as bootstrapRemoteControlWith, + getIpcHandler, + invokeHttpHandler, + METHOD_NOT_ALLOWED_RESPONSE, + mockFirstRendererSend, + mockGetAllWindows, + mockHttpHandlers, + mockIpcHandle, + mockIpcHandlers, + mockIpcOn, + mockRegisterRemoteControlHandler, + mockSecondRendererSend, + mockStartHttpServer, + mockStoreGet, + REMOTE_CONTROL_PATHS, + resetRemoteControlMocks, + SUCCESS_RESPONSE, + type RemoteControlSettings, +} from './remote-control.test-helpers'; jest.mock('electron', () => ({ BrowserWindow: { @@ -102,162 +44,15 @@ jest.mock('../services/store.service', () => ({ import { RemoteControlEvents } from './remote-control.events'; -function createResponseRecorder(): ResponseRecorder { - let statusCode = 0; - let headers: http.OutgoingHttpHeaders = {}; - let response: http.ServerResponse; - let responseEnded = false; - let resolveCompleted: (snapshot: ResponseSnapshot) => void = () => { - throw new Error('Response completion promise is not initialized'); - }; - const completed = new Promise((resolve) => { - resolveCompleted = resolve; - }); +const bootstrapRemoteControl = (settings?: RemoteControlSettings) => + bootstrapRemoteControlWith(RemoteControlEvents, settings); - response = { - writeHead: ( - nextStatusCode: number, - nextHeaders?: http.OutgoingHttpHeaders - ) => { - statusCode = nextStatusCode; - headers = { ...nextHeaders }; - return response; - }, - end: (chunk?: string | Uint8Array) => { - if (responseEnded) { - throw new Error('Response ended more than once'); - } - responseEnded = true; - const body = - typeof chunk === 'string' - ? chunk - : chunk - ? Buffer.from(chunk).toString('utf8') - : ''; - resolveCompleted({ statusCode, headers, body }); - return response; - }, - } as unknown as http.ServerResponse; - - return { response, completed }; -} - -function getHttpHandler(path: string): HttpHandler { - const handler = mockHttpHandlers.get(path); - if (!handler) { - throw new Error(`Expected HTTP handler for ${path}`); - } - - return handler; -} - -function getIpcHandler(channel: string): IpcCallback { - const handler = mockIpcHandlers.get(channel); - if (!handler) { - throw new Error(`Expected IPC handler for ${channel}`); - } - - return handler; -} - -function getIpcListener(channel: string): IpcCallback { - const listener = mockIpcListeners.get(channel); - if (!listener) { - throw new Error(`Expected IPC listener for ${channel}`); - } - - return listener; -} - -function bootstrapRemoteControl( - settings: RemoteControlSettings = { enabled: false, port: 8765 } -): RemoteControlEvents { - mockStoreGet.mockImplementation( - (key: string, fallbackValue: unknown): unknown => { - if (key === 'remoteControl') { - return settings.enabled; - } - if (key === 'remoteControlPort') { - return settings.port; - } - - return fallbackValue; - } - ); - - const events = new RemoteControlEvents(); - events.bootstrapRemoteControlEvents(); - return events; -} - -async function invokeHttpHandler( - path: string, - options: InvokeOptions -): Promise { - const request = new PassThrough(); - const requestErrors: Error[] = []; - request.on('error', (error: Error) => { - requestErrors.push(error); - }); - const requestClosed = new Promise((resolve) => { - request.once('close', resolve); - }); - const incomingMessage = Object.assign(request, { - method: options.method, - url: path, - }) as unknown as http.IncomingMessage; - const recorder = createResponseRecorder(); - - getHttpHandler(path)(incomingMessage, recorder.response); - request.end(options.body); - - return { - response: await recorder.completed, - request, - requestClosed, - requestErrors, - }; -} - -function createBodyAtByteLength(byteLength: number): string { - const prefix = '{"number":7,"padding":"'; - const suffix = '"}'; - const paddingLength = - byteLength - - Buffer.byteLength(prefix, 'utf8') - - Buffer.byteLength(suffix, 'utf8'); - if (paddingLength < 0) { - throw new Error(`Cannot create a JSON body at ${byteLength} bytes`); - } - - const body = `${prefix}${'x'.repeat(paddingLength)}${suffix}`; - if (Buffer.byteLength(body, 'utf8') !== byteLength) { - throw new Error(`Expected a ${byteLength}-byte JSON body`); - } - - return body; -} - -describe('RemoteControlEvents', () => { +describe('RemoteControlEvents bootstrap and dispatch', () => { let consoleLog: jest.SpyInstance; let consoleWarn: jest.SpyInstance; beforeEach(() => { - mockHttpHandlers.clear(); - mockIpcHandlers.clear(); - mockIpcListeners.clear(); - mockRegisterRemoteControlHandler.mockClear(); - mockStartHttpServer.mockReset(); - mockStoreGet.mockReset(); - mockFirstRendererSend.mockReset(); - mockSecondRendererSend.mockReset(); - mockGetAllWindows.mockReset(); - mockIpcHandle.mockClear(); - mockIpcOn.mockClear(); - mockGetAllWindows.mockReturnValue([ - { webContents: { send: mockFirstRendererSend } }, - { webContents: { send: mockSecondRendererSend } }, - ]); + resetRemoteControlMocks(); consoleLog = jest .spyOn(console, 'log') .mockImplementation(() => undefined); @@ -390,199 +185,4 @@ describe('RemoteControlEvents', () => { expect(mockFirstRendererSend).not.toHaveBeenCalled(); expect(mockSecondRendererSend).not.toHaveBeenCalled(); }); - - it('rejects POST on the status endpoint', async () => { - bootstrapRemoteControl(); - - const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, { - method: 'POST', - }); - - expect(result.response).toEqual(METHOD_NOT_ALLOWED_RESPONSE); - expect(mockFirstRendererSend).not.toHaveBeenCalled(); - expect(mockSecondRendererSend).not.toHaveBeenCalled(); - }); - - it('floors a finite positive channel number and dispatches it', async () => { - bootstrapRemoteControl(); - - const result = await invokeHttpHandler( - REMOTE_CONTROL_PATHS.SELECT_NUMBER, - { - method: 'POST', - body: JSON.stringify({ number: 7.9 }), - } - ); - - expect(result.response).toEqual(SUCCESS_RESPONSE); - expect(mockFirstRendererSend).toHaveBeenCalledWith( - 'REMOTE_CONTROL_COMMAND', - { - type: 'channel-select-number', - number: 7, - } - ); - expect(mockSecondRendererSend).not.toHaveBeenCalled(); - }); - - it.each([ - { label: 'missing', body: '{}' }, - { label: 'zero', body: '{"number":0}' }, - { label: 'negative', body: '{"number":-2}' }, - { label: 'infinite', body: '{"number":1e309}' }, - { label: 'nonnumeric', body: '{"number":"seven"}' }, - ])('rejects a $label channel number', async ({ body }) => { - bootstrapRemoteControl(); - - const result = await invokeHttpHandler( - REMOTE_CONTROL_PATHS.SELECT_NUMBER, - { - method: 'POST', - body, - } - ); - - expect(result.response).toEqual({ - statusCode: 400, - headers: JSON_HEADERS, - body: JSON.stringify({ error: 'Invalid channel number' }), - }); - expect(mockGetAllWindows).not.toHaveBeenCalled(); - expect(mockFirstRendererSend).not.toHaveBeenCalled(); - expect(mockSecondRendererSend).not.toHaveBeenCalled(); - }); - - it('rejects malformed JSON without dispatching', async () => { - bootstrapRemoteControl(); - - const result = await invokeHttpHandler( - REMOTE_CONTROL_PATHS.SELECT_NUMBER, - { - method: 'POST', - body: '{"number":', - } - ); - - expect(result.response).toEqual({ - statusCode: 400, - headers: JSON_HEADERS, - body: JSON.stringify({ error: 'Invalid JSON payload' }), - }); - expect(mockGetAllWindows).not.toHaveBeenCalled(); - expect(mockFirstRendererSend).not.toHaveBeenCalled(); - expect(mockSecondRendererSend).not.toHaveBeenCalled(); - }); - - it('accepts a valid JSON request at the 10,240-byte body limit', async () => { - bootstrapRemoteControl(); - const body = createBodyAtByteLength(10 * 1024); - - const result = await invokeHttpHandler( - REMOTE_CONTROL_PATHS.SELECT_NUMBER, - { - method: 'POST', - body, - } - ); - - expect(Buffer.byteLength(body, 'utf8')).toBe(10_240); - expect(result.response).toEqual(SUCCESS_RESPONSE); - expect(mockFirstRendererSend).toHaveBeenCalledWith( - 'REMOTE_CONTROL_COMMAND', - { - type: 'channel-select-number', - number: 7, - } - ); - }); - - it('rejects and destroys a 10,241-byte request without dispatching', async () => { - bootstrapRemoteControl(); - const body = Buffer.alloc(10 * 1024 + 1, 'x'); - - const result = await invokeHttpHandler( - REMOTE_CONTROL_PATHS.SELECT_NUMBER, - { - method: 'POST', - body, - } - ); - await result.requestClosed; - - expect(body.byteLength).toBe(10_241); - expect(result.response).toEqual({ - statusCode: 413, - headers: JSON_HEADERS, - body: JSON.stringify({ error: 'Payload too large' }), - }); - expect(result.request.destroyed).toBe(true); - expect(result.requestErrors).toEqual([]); - expect(mockGetAllWindows).not.toHaveBeenCalled(); - expect(mockFirstRendererSend).not.toHaveBeenCalled(); - expect(mockSecondRendererSend).not.toHaveBeenCalled(); - }); - - it('merges partial status updates and refreshes updatedAt', async () => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-07-25T10:00:00.000Z')); - bootstrapRemoteControl(); - const updateStatus = getIpcListener('REMOTE_CONTROL_STATUS_UPDATE'); - - jest.setSystemTime(new Date('2026-07-25T10:01:00.000Z')); - updateStatus( - {}, - { - portal: 'm3u', - isLiveView: true, - channelName: 'News', - volume: 35, - } - ); - jest.setSystemTime(new Date('2026-07-25T10:02:00.000Z')); - updateStatus({}, { channelName: 'Sports', muted: true }); - - const result = await invokeHttpHandler(REMOTE_CONTROL_PATHS.STATUS, { - method: 'GET', - }); - - expect(result.response).toEqual({ - statusCode: 200, - headers: JSON_HEADERS, - body: JSON.stringify({ - portal: 'm3u', - isLiveView: true, - supportsVolume: false, - updatedAt: '2026-07-25T10:02:00.000Z', - channelName: 'Sports', - volume: 35, - muted: true, - }), - }); - }); - - it.each([ - { - path: REMOTE_CONTROL_PATHS.CHANNEL_UP, - warning: 'No browser windows found to send channel change', - }, - { - path: REMOTE_CONTROL_PATHS.VOLUME_UP, - warning: 'No browser windows found to send remote command', - }, - ])( - 'returns success and warns instead of throwing when $path has no renderer', - async ({ path, warning }) => { - mockGetAllWindows.mockReturnValue([]); - bootstrapRemoteControl(); - - await expect( - invokeHttpHandler(path, { method: 'POST' }) - ).resolves.toMatchObject({ - response: SUCCESS_RESPONSE, - }); - expect(consoleWarn).toHaveBeenCalledWith(warning); - expect(mockFirstRendererSend).not.toHaveBeenCalled(); - expect(mockSecondRendererSend).not.toHaveBeenCalled(); - } - ); }); diff --git a/apps/electron-backend/src/app/events/remote-control.test-helpers.ts b/apps/electron-backend/src/app/events/remote-control.test-helpers.ts new file mode 100644 index 000000000..e594659c6 --- /dev/null +++ b/apps/electron-backend/src/app/events/remote-control.test-helpers.ts @@ -0,0 +1,249 @@ +import type * as http from 'node:http'; +import { PassThrough } from 'node:stream'; + +/** + * Shared harness for the remote-control specs. + * + * Every exported mock is `mock`-prefixed so the spec files can reference them + * from their hoisted `jest.mock()` factories. + */ + +export type HttpHandler = ( + request: http.IncomingMessage, + response: http.ServerResponse +) => void; +export type IpcCallback = (...args: unknown[]) => unknown; + +export interface RemoteControlSettings { + enabled: boolean; + port: number; +} + +export interface ResponseSnapshot { + statusCode: number; + headers: http.OutgoingHttpHeaders; + body: string; +} + +export interface ResponseRecorder { + response: http.ServerResponse; + completed: Promise; +} + +export interface RequestInvocation { + response: ResponseSnapshot; + request: PassThrough; + requestClosed: Promise; + requestErrors: Error[]; +} + +export interface InvokeOptions { + method: string; + body?: string | Buffer; +} + +export const REMOTE_CONTROL_PATHS = { + STATUS: '/api/remote-control/status', + SELECT_NUMBER: '/api/remote-control/channel/select-number', + CHANNEL_UP: '/api/remote-control/channel/up', + CHANNEL_DOWN: '/api/remote-control/channel/down', + VOLUME_UP: '/api/remote-control/volume/up', + VOLUME_DOWN: '/api/remote-control/volume/down', + VOLUME_TOGGLE_MUTE: '/api/remote-control/volume/toggle-mute', +} as const; + +export const JSON_HEADERS = { 'Content-Type': 'application/json' } as const; +export const SUCCESS_RESPONSE = { + statusCode: 200, + headers: JSON_HEADERS, + body: JSON.stringify({ success: true }), +} as const; +export const METHOD_NOT_ALLOWED_RESPONSE = { + statusCode: 405, + headers: JSON_HEADERS, + body: JSON.stringify({ error: 'Method not allowed' }), +} as const; + +export const mockHttpHandlers = new Map(); +export const mockIpcHandlers = new Map(); +export const mockIpcListeners = new Map(); +export const mockRegisterRemoteControlHandler = jest.fn( + (path: string, handler: HttpHandler) => { + mockHttpHandlers.set(path, handler); + } +); +export const mockStartHttpServer = jest.fn(); +export const mockStoreGet = jest.fn(); +export const mockFirstRendererSend = jest.fn(); +export const mockSecondRendererSend = jest.fn(); +export const mockGetAllWindows = jest.fn(); +export const mockIpcHandle = jest.fn( + (channel: string, handler: IpcCallback) => { + mockIpcHandlers.set(channel, handler); + } +); +export const mockIpcOn = jest.fn((channel: string, listener: IpcCallback) => { + mockIpcListeners.set(channel, listener); +}); + +/** Restore every mock to the state each spec's `beforeEach` expects. */ +export function resetRemoteControlMocks(): void { + mockHttpHandlers.clear(); + mockIpcHandlers.clear(); + mockIpcListeners.clear(); + mockRegisterRemoteControlHandler.mockClear(); + mockStartHttpServer.mockReset(); + mockStoreGet.mockReset(); + mockFirstRendererSend.mockReset(); + mockSecondRendererSend.mockReset(); + mockGetAllWindows.mockReset(); + mockIpcHandle.mockClear(); + mockIpcOn.mockClear(); + mockGetAllWindows.mockReturnValue([ + { webContents: { send: mockFirstRendererSend } }, + { webContents: { send: mockSecondRendererSend } }, + ]); +} + +export function createResponseRecorder(): ResponseRecorder { + let statusCode = 0; + let headers: http.OutgoingHttpHeaders = {}; + let responseEnded = false; + let resolveCompleted: (snapshot: ResponseSnapshot) => void = () => { + throw new Error('Response completion promise is not initialized'); + }; + const completed = new Promise((resolve) => { + resolveCompleted = resolve; + }); + + const response = { + writeHead: ( + nextStatusCode: number, + nextHeaders?: http.OutgoingHttpHeaders + ) => { + statusCode = nextStatusCode; + headers = { ...nextHeaders }; + return response; + }, + end: (chunk?: string | Uint8Array) => { + if (responseEnded) { + throw new Error('Response ended more than once'); + } + responseEnded = true; + const body = + typeof chunk === 'string' + ? chunk + : chunk + ? Buffer.from(chunk).toString('utf8') + : ''; + resolveCompleted({ statusCode, headers, body }); + return response; + }, + } as unknown as http.ServerResponse; + + return { response, completed }; +} + +export function getHttpHandler(path: string): HttpHandler { + const handler = mockHttpHandlers.get(path); + if (!handler) { + throw new Error(`Expected HTTP handler for ${path}`); + } + + return handler; +} + +export function getIpcHandler(channel: string): IpcCallback { + const handler = mockIpcHandlers.get(channel); + if (!handler) { + throw new Error(`Expected IPC handler for ${channel}`); + } + + return handler; +} + +export function getIpcListener(channel: string): IpcCallback { + const listener = mockIpcListeners.get(channel); + if (!listener) { + throw new Error(`Expected IPC listener for ${channel}`); + } + + return listener; +} + +/** + * Construct and bootstrap the events class under test. The constructor is + * injected so this module never imports the subject — the spec files own that + * import order relative to their `jest.mock()` calls. + */ +export function bootstrapRemoteControl< + T extends { bootstrapRemoteControlEvents(): unknown }, +>( + RemoteControlEvents: new () => T, + settings: RemoteControlSettings = { enabled: false, port: 8765 } +): T { + mockStoreGet.mockImplementation( + (key: string, fallbackValue: unknown): unknown => { + if (key === 'remoteControl') { + return settings.enabled; + } + if (key === 'remoteControlPort') { + return settings.port; + } + + return fallbackValue; + } + ); + + const events = new RemoteControlEvents(); + events.bootstrapRemoteControlEvents(); + return events; +} + +export async function invokeHttpHandler( + path: string, + options: InvokeOptions +): Promise { + const request = new PassThrough(); + const requestErrors: Error[] = []; + request.on('error', (error: Error) => { + requestErrors.push(error); + }); + const requestClosed = new Promise((resolve) => { + request.once('close', resolve); + }); + const incomingMessage = Object.assign(request, { + method: options.method, + url: path, + }) as unknown as http.IncomingMessage; + const recorder = createResponseRecorder(); + + getHttpHandler(path)(incomingMessage, recorder.response); + request.end(options.body); + + return { + response: await recorder.completed, + request, + requestClosed, + requestErrors, + }; +} + +export function createBodyAtByteLength(byteLength: number): string { + const prefix = '{"number":7,"padding":"'; + const suffix = '"}'; + const paddingLength = + byteLength - + Buffer.byteLength(prefix, 'utf8') - + Buffer.byteLength(suffix, 'utf8'); + if (paddingLength < 0) { + throw new Error(`Cannot create a JSON body at ${byteLength} bytes`); + } + + const body = `${prefix}${'x'.repeat(paddingLength)}${suffix}`; + if (Buffer.byteLength(body, 'utf8') !== byteLength) { + throw new Error(`Expected a ${byteLength}-byte JSON body`); + } + + return body; +} diff --git a/apps/electron-backend/src/app/server/http-server.spec.ts b/apps/electron-backend/src/app/server/http-server.spec.ts index 88f3d8d38..d4207d498 100644 --- a/apps/electron-backend/src/app/server/http-server.spec.ts +++ b/apps/electron-backend/src/app/server/http-server.spec.ts @@ -9,9 +9,9 @@ import { import * as http from 'node:http'; import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; -import { join, posix, win32 } from 'node:path'; +import { join } from 'node:path'; -import { HttpServer, resolveStaticFilePath } from './http-server'; +import { HttpServer } from './http-server'; interface CapturedServer { listening: Promise; @@ -119,77 +119,6 @@ async function getAvailablePort(): Promise { return port; } -describe('resolveStaticFilePath', () => { - const POSIX_STATIC_ROOT = '/opt/iptvnator/remote-control'; - const WINDOWS_STATIC_ROOT = 'C:\\iptvnator\\remote-control'; - - it('rejects a Windows double-leading-slash traversal', () => { - expect( - resolveStaticFilePath( - WINDOWS_STATIC_ROOT, - '//../../outside-secret.txt', - win32 - ) - ).toBeNull(); - }); - - it('rejects encoded traversal segments after one decode', () => { - expect( - resolveStaticFilePath( - POSIX_STATIC_ROOT, - '/%2e%2e/outside-secret.txt', - posix - ) - ).toBeNull(); - }); - - it('fails closed for malformed percent encoding without throwing', () => { - expect( - resolveStaticFilePath(POSIX_STATIC_ROOT, '/%E0%A4%A', posix) - ).toBeNull(); - }); - - it('rejects decoded NUL bytes', () => { - expect( - resolveStaticFilePath( - POSIX_STATIC_ROOT, - '/assets/%00secret.js', - posix - ) - ).toBeNull(); - }); - - it('resolves a valid Windows-style asset inside the static root', () => { - expect( - resolveStaticFilePath( - WINDOWS_STATIC_ROOT, - '/assets\\app.js?version=1', - win32 - ) - ).toBe('C:\\iptvnator\\remote-control\\assets\\app.js'); - }); - - it('ignores query and fragment data for filesystem resolution', () => { - expect( - resolveStaticFilePath( - POSIX_STATIC_ROOT, - '/data.json?cache=1#ignored', - posix - ) - ).toBe('/opt/iptvnator/remote-control/data.json'); - }); - - it('decodes the URL pathname exactly once', () => { - expect( - resolveStaticFilePath( - POSIX_STATIC_ROOT, - '/%252e%252e/asset.js', - posix - ) - ).toBe('/opt/iptvnator/remote-control/%2e%2e/asset.js'); - }); -}); - describe('HttpServer', () => { let capturedServers: CapturedServer[]; let distPath: string; diff --git a/apps/electron-backend/src/app/server/resolve-static-file-path.spec.ts b/apps/electron-backend/src/app/server/resolve-static-file-path.spec.ts new file mode 100644 index 000000000..3cda4154c --- /dev/null +++ b/apps/electron-backend/src/app/server/resolve-static-file-path.spec.ts @@ -0,0 +1,74 @@ +import { posix, win32 } from 'node:path'; + +import { resolveStaticFilePath } from './http-server'; + +describe('resolveStaticFilePath', () => { + const POSIX_STATIC_ROOT = '/opt/iptvnator/remote-control'; + const WINDOWS_STATIC_ROOT = 'C:\\iptvnator\\remote-control'; + + it('rejects a Windows double-leading-slash traversal', () => { + expect( + resolveStaticFilePath( + WINDOWS_STATIC_ROOT, + '//../../outside-secret.txt', + win32 + ) + ).toBeNull(); + }); + + it('rejects encoded traversal segments after one decode', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/%2e%2e/outside-secret.txt', + posix + ) + ).toBeNull(); + }); + + it('fails closed for malformed percent encoding without throwing', () => { + expect( + resolveStaticFilePath(POSIX_STATIC_ROOT, '/%E0%A4%A', posix) + ).toBeNull(); + }); + + it('rejects decoded NUL bytes', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/assets/%00secret.js', + posix + ) + ).toBeNull(); + }); + + it('resolves a valid Windows-style asset inside the static root', () => { + expect( + resolveStaticFilePath( + WINDOWS_STATIC_ROOT, + '/assets\\app.js?version=1', + win32 + ) + ).toBe('C:\\iptvnator\\remote-control\\assets\\app.js'); + }); + + it('ignores query and fragment data for filesystem resolution', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/data.json?cache=1#ignored', + posix + ) + ).toBe('/opt/iptvnator/remote-control/data.json'); + }); + + it('decodes the URL pathname exactly once', () => { + expect( + resolveStaticFilePath( + POSIX_STATIC_ROOT, + '/%252e%252e/asset.js', + posix + ) + ).toBe('/opt/iptvnator/remote-control/%2e%2e/asset.js'); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.resilience.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.resilience.spec.ts new file mode 100644 index 000000000..c56a2fb36 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.resilience.spec.ts @@ -0,0 +1,285 @@ +import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + armWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; + +/** + * Capture must never change the business result. These cases drive the + * monotonic-clock timeouts and every runtime callback that can throw, and + * assert the awaited value survives each one. + */ +describe('worker performance capture resilience', () => { + const originalProfilingValue = process.env[PROFILING_ENV]; + + afterEach(() => { + if (originalProfilingValue === undefined) { + delete process.env[PROFILING_ENV]; + } else { + process.env[PROFILING_ENV] = originalProfilingValue; + } + }); + + it('times out arming after a monotonic 50ms without blocking work metrics', async () => { + const harness = createFakeRuntime({ + armHistogram: false, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect( + harness.scheduledTimeouts.reduce( + (total, timeout) => total + timeout, + 0 + ) + ).toBe(50); + expect(execution.performance).toMatchObject({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, + eventLoopUtilization: 0.75, + histogramFlushedEpochMs: null, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }); + }); + + it('cannot poll forever when the monotonic runtime clock stalls', async () => { + const harness = createFakeRuntime({ + armHistogram: false, + stallMonotonicClock: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect(harness.scheduledTimeouts).toHaveLength(50); + expect(execution).toMatchObject({ + result: 'result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, + }, + }); + }); + + it('times out flushing after a monotonic 50ms while preserving work CPU and ELU', async () => { + const harness = createFakeRuntime({ + flushHistogram: false, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect( + harness.scheduledTimeouts + .slice(1) + .reduce((total, timeout) => total + timeout, 0) + ).toBe(50); + expect(execution.performance).toMatchObject({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_FLUSH_TIMEOUT, + eventLoopUtilization: 0.75, + histogramFlushedEpochMs: null, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }); + }); + + it('reports unavailable thread CPU without falling back to process CPU usage', async () => { + const harness = createFakeRuntime({ + threadCpuAvailable: false, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'result' + ); + + expect(execution.performance).toMatchObject({ + eventLoopUtilization: 0.75, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE, + threadCpuUserMicros: null, + }); + expect(harness.lifecycle).not.toContain('cpu:1'); + expect(harness.lifecycle).not.toContain('cpu:2'); + }); + + it('preserves the business result when CPU and ELU runtime callbacks throw', async () => { + const harness = createFakeRuntime({ + throwBoundaryCallbacks: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: { + maxMs: 24, + p95Ms: 18, + p99Ms: 22, + }, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE, + threadCpuUserMicros: null, + }, + }); + }); + + it('preserves work metrics and the business result when timer scheduling throws', async () => { + const harness = createFakeRuntime({ + throwScheduleTimeout: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, + eventLoopUtilization: 0.75, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, + }); + }); + + it.each([ + { + label: 'count getter during finish', + options: { + throwHistogramCountAtRead: 3, + }, + reason: WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + }, + { + label: 'metric getter after flush', + options: { + throwHistogramMetric: true, + }, + reason: WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_INVALID, + }, + ])( + 'preserves the business result when the histogram $label throws', + async ({ options, reason }) => { + const harness = createFakeRuntime(options); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: reason, + eventLoopUtilization: 0.75, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, + }); + } + ); + + it('keeps the business result and rejects delay metrics when histogram disable throws', async () => { + const harness = createFakeRuntime({ + throwHistogramDisable: true, + }); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => 'business-result' + ); + + expect(execution).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + eventLoopUtilization: 0.75, + histogramFlushedEpochMs: null, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, + }); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts index d6438dec3..a0e0db15c 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.spec.ts @@ -146,263 +146,4 @@ describe('worker performance capture', () => { harness.lifecycle.indexOf('epoch:145') ); }); - - it('times out arming after a monotonic 50ms without blocking work metrics', async () => { - const harness = createFakeRuntime({ - armHistogram: false, - }); - const capture = startWorkerPerformanceCapture({ - enabled: true, - runtime: harness.runtime, - }); - - await armWorkerPerformanceCapture(capture); - const execution = await executeWithWorkerPerformanceCapture( - capture, - async () => 'result' - ); - - expect( - harness.scheduledTimeouts.reduce( - (total, timeout) => total + timeout, - 0 - ) - ).toBe(50); - expect(execution.performance).toMatchObject({ - eventLoopDelay: null, - eventLoopDelayUnavailableReason: - WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, - eventLoopUtilization: 0.75, - histogramFlushedEpochMs: null, - threadCpuSystemMicros: 30, - threadCpuUserMicros: 80, - }); - }); - - it('cannot poll forever when the monotonic runtime clock stalls', async () => { - const harness = createFakeRuntime({ - armHistogram: false, - stallMonotonicClock: true, - }); - const capture = startWorkerPerformanceCapture({ - enabled: true, - runtime: harness.runtime, - }); - - await armWorkerPerformanceCapture(capture); - const execution = await executeWithWorkerPerformanceCapture( - capture, - async () => 'result' - ); - - expect(harness.scheduledTimeouts).toHaveLength(50); - expect(execution).toMatchObject({ - result: 'result', - success: true, - performance: { - eventLoopDelay: null, - eventLoopDelayUnavailableReason: - WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, - }, - }); - }); - - it('times out flushing after a monotonic 50ms while preserving work CPU and ELU', async () => { - const harness = createFakeRuntime({ - flushHistogram: false, - }); - const capture = startWorkerPerformanceCapture({ - enabled: true, - runtime: harness.runtime, - }); - await armWorkerPerformanceCapture(capture); - - const execution = await executeWithWorkerPerformanceCapture( - capture, - async () => 'result' - ); - - expect( - harness.scheduledTimeouts - .slice(1) - .reduce((total, timeout) => total + timeout, 0) - ).toBe(50); - expect(execution.performance).toMatchObject({ - eventLoopDelay: null, - eventLoopDelayUnavailableReason: - WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_FLUSH_TIMEOUT, - eventLoopUtilization: 0.75, - histogramFlushedEpochMs: null, - threadCpuSystemMicros: 30, - threadCpuUserMicros: 80, - }); - }); - - it('reports unavailable thread CPU without falling back to process CPU usage', async () => { - const harness = createFakeRuntime({ - threadCpuAvailable: false, - }); - const capture = startWorkerPerformanceCapture({ - enabled: true, - runtime: harness.runtime, - }); - await armWorkerPerformanceCapture(capture); - - const execution = await executeWithWorkerPerformanceCapture( - capture, - async () => 'result' - ); - - expect(execution.performance).toMatchObject({ - eventLoopUtilization: 0.75, - threadCpuSystemMicros: null, - threadCpuUnavailableReason: - WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE, - threadCpuUserMicros: null, - }); - expect(harness.lifecycle).not.toContain('cpu:1'); - expect(harness.lifecycle).not.toContain('cpu:2'); - }); - - it('preserves the business result when CPU and ELU runtime callbacks throw', async () => { - const harness = createFakeRuntime({ - throwBoundaryCallbacks: true, - }); - const capture = startWorkerPerformanceCapture({ - enabled: true, - runtime: harness.runtime, - }); - await armWorkerPerformanceCapture(capture); - - const execution = await executeWithWorkerPerformanceCapture( - capture, - async () => 'business-result' - ); - - expect(execution).toMatchObject({ - error: null, - result: 'business-result', - success: true, - performance: { - eventLoopDelay: { - maxMs: 24, - p95Ms: 18, - p99Ms: 22, - }, - eventLoopUtilization: null, - eventLoopUtilizationUnavailableReason: - WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE, - threadCpuSystemMicros: null, - threadCpuUnavailableReason: - WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE, - threadCpuUserMicros: null, - }, - }); - }); - - it('preserves work metrics and the business result when timer scheduling throws', async () => { - const harness = createFakeRuntime({ - throwScheduleTimeout: true, - }); - const capture = startWorkerPerformanceCapture({ - enabled: true, - runtime: harness.runtime, - }); - await armWorkerPerformanceCapture(capture); - - const execution = await executeWithWorkerPerformanceCapture( - capture, - async () => 'business-result' - ); - - expect(execution).toMatchObject({ - error: null, - result: 'business-result', - success: true, - performance: { - eventLoopDelay: null, - eventLoopDelayUnavailableReason: - WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT, - eventLoopUtilization: 0.75, - threadCpuSystemMicros: 30, - threadCpuUserMicros: 80, - }, - }); - }); - - it.each([ - { - label: 'count getter during finish', - options: { - throwHistogramCountAtRead: 3, - }, - reason: WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, - }, - { - label: 'metric getter after flush', - options: { - throwHistogramMetric: true, - }, - reason: WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_INVALID, - }, - ])( - 'preserves the business result when the histogram $label throws', - async ({ options, reason }) => { - const harness = createFakeRuntime(options); - const capture = startWorkerPerformanceCapture({ - enabled: true, - runtime: harness.runtime, - }); - await armWorkerPerformanceCapture(capture); - - const execution = await executeWithWorkerPerformanceCapture( - capture, - async () => 'business-result' - ); - - expect(execution).toMatchObject({ - error: null, - result: 'business-result', - success: true, - performance: { - eventLoopDelay: null, - eventLoopDelayUnavailableReason: reason, - eventLoopUtilization: 0.75, - threadCpuSystemMicros: 30, - threadCpuUserMicros: 80, - }, - }); - } - ); - - it('keeps the business result and rejects delay metrics when histogram disable throws', async () => { - const harness = createFakeRuntime({ - throwHistogramDisable: true, - }); - const capture = startWorkerPerformanceCapture({ - enabled: true, - runtime: harness.runtime, - }); - await armWorkerPerformanceCapture(capture); - - const execution = await executeWithWorkerPerformanceCapture( - capture, - async () => 'business-result' - ); - - expect(execution).toMatchObject({ - error: null, - result: 'business-result', - success: true, - performance: { - eventLoopDelay: null, - eventLoopDelayUnavailableReason: - WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, - eventLoopUtilization: 0.75, - histogramFlushedEpochMs: null, - threadCpuSystemMicros: 30, - threadCpuUserMicros: 80, - }, - }); - }); }); From d2fd27b5356c1e22e05ac5842ce09e0153911bcd Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:33:08 +0200 Subject: [PATCH 04/12] test(performance): deflake the real-timer event-loop delay spec (#1293) * test(performance): deflake the real-timer event-loop delay spec The real-timer capture spec failed under full-suite parallelism because `monitorEventLoopDelay()` records nothing on its first internal timer tick - that tick only seeds the previous timestamp, so the first delay sample lands on the second tick. Condition-based arming therefore needs two event-loop turns inside its fixed 50ms wall-clock budget, and a machine running 10 Jest workers stretches a single turn past 20ms. The capture then degraded to a documented `event-loop-delay-arm-timeout`, which is the intended graceful path, while the spec asserted the happy path of that race and turned an environmental outcome into a red build. Retry the real-runtime capture within a 5s budget instead. The real `node:perf_hooks` runtime and the real 20ms block are kept, since the fake harness returns a hard-coded histogram max and never measures anything. Every attempt still asserts a contract: instrumentation never breaks the wrapped work, and a null delay must carry a documented arm/flush timeout rather than being silently null. Budget exhaustion warns instead of failing, so load can no longer produce a false failure. Co-Authored-By: Claude Opus 5 * test(performance): assert the real delay measurement unconditionally Codex flagged that budget exhaustion still passed the test, so a regression that made arming or flushing time out on every attempt would have been reported as a warning rather than a failure - removing the only assertion backed by Node's real histogram and a genuine event-loop block. Drop the tolerant retry loop. The arming deadline is read through the injectable `readMonotonicMs()`, so scaling only that clock leaves the wait bounded by its other limit, the 50-poll ceiling, which is ~25x the two event-loop turns arming actually needs. Everything else stays production code: the real `monitorEventLoopDelay()` histogram, real `setTimeout()` polling, and real epoch/CPU/ELU boundaries. The scaled clock reaches nothing but the wait budgets, since its only other consumer records phase events and this spec records none. The test now always asserts a real measurement and hard-fails otherwise. Verified by mutation: forcing arming to never arm fails it, and dropping the deliberate block fails it at maxMs 3.8ms against the 10ms floor. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- ...er-performance-capture.concurrency.spec.ts | 88 +++++++++++++++---- 1 file changed, 69 insertions(+), 19 deletions(-) diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts index 900f4ccde..263f43f64 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts @@ -6,11 +6,43 @@ import { releaseDatabaseWorkerPerformanceCapture, startWorkerPerformanceCapture, type WorkerPerformanceCapture, + type WorkerPerformanceCaptureRuntime, } from './worker-performance-capture'; +import { DEFAULT_WORKER_PERFORMANCE_RUNTIME } from './worker-performance-capture.runtime'; import { createFakeRuntime } from './worker-performance-capture.test-harness'; const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; +const BLOCK_DURATION_MS = 20; +const REAL_TIMER_TEST_TIMEOUT_MS = 30_000; + +/** + * `monitorEventLoopDelay()` records nothing on its first internal timer tick — + * that tick only seeds the previous timestamp, so the first delay sample lands + * on the second tick. Condition-based arming therefore needs two event-loop + * turns, and it budgets for them with a 50ms deadline read from + * `readMonotonicMs()`. A machine running the full Jest suite in parallel can + * stretch a single turn past 20ms, so that deadline expires against the + * scheduler rather than against any defect in the capture code. + * + * Slowing only that deadline clock leaves the wait bounded by its other limit, + * the 50-poll ceiling, which is ~25x the two turns arming actually needs. + * Everything else stays production code: the real `monitorEventLoopDelay()` + * histogram, real `setTimeout()` polling, and real epoch/CPU/ELU boundaries. + * The scaled clock reaches nothing but the wait budgets — its only other + * consumer records phase events, and this spec records none. + */ +const WAIT_DEADLINE_CLOCK_SCALE = 50; + +function createRuntimeWithScaledWaitDeadline(): WorkerPerformanceCaptureRuntime { + return { + ...DEFAULT_WORKER_PERFORMANCE_RUNTIME, + readMonotonicMs: () => + DEFAULT_WORKER_PERFORMANCE_RUNTIME.readMonotonicMs() / + WAIT_DEADLINE_CLOCK_SCALE, + }; +} + describe('worker performance capture concurrency and real timers', () => { const originalProfilingValue = process.env[PROFILING_ENV]; @@ -70,26 +102,44 @@ describe('worker performance capture concurrency and real timers', () => { expect(activeCaptures.size).toBe(0); }); - it('reliably observes a real 20ms event-loop block after condition-based arming', async () => { - process.env[PROFILING_ENV] = '1'; + it( + 'measures a real 20ms event-loop block through condition-based arming', + async () => { + process.env[PROFILING_ENV] = '1'; - const capture = startWorkerPerformanceCapture(); - await armWorkerPerformanceCapture(capture); - const execution = await executeWithWorkerPerformanceCapture( - capture, - async () => { - const blockStartedAt = performance.now(); - while (performance.now() - blockStartedAt < 20) { - // This deliberate block is the behavior under measurement. + // No `enabled` override: the env variable is the opt-in under test. + const capture = startWorkerPerformanceCapture({ + runtime: createRuntimeWithScaledWaitDeadline(), + }); + + expect(capture).not.toBeNull(); + + await armWorkerPerformanceCapture(capture); + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => { + const blockStartedAt = performance.now(); + while ( + performance.now() - blockStartedAt < + BLOCK_DURATION_MS + ) { + // This deliberate block is the behavior under measurement. + } } - } - ); + ); - expect(execution.success).toBe(true); - expect(execution.performance?.eventLoopDelay).not.toBeNull(); - expect(execution.performance?.eventLoopDelay?.maxMs).toBeGreaterThan( - 10 - ); - expect(execution.performance?.histogramFlushedEpochMs).not.toBeNull(); - }); + expect(execution.success).toBe(true); + expect(execution.performance?.invalidReason).toBeNull(); + expect( + execution.performance?.eventLoopDelayUnavailableReason + ).toBeNull(); + expect( + execution.performance?.histogramFlushedEpochMs + ).not.toBeNull(); + expect( + execution.performance?.eventLoopDelay?.maxMs + ).toBeGreaterThan(10); + }, + REAL_TIMER_TEST_TIMEOUT_MS + ); }); From a2d678bddaaaa94a6d1949f5eb86e0af57816d52 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:34:31 +0200 Subject: [PATCH 05/12] fix(packaging): stop the Linux frame-copy probe timing out on cold sandboxes (#1294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packaging verifier bounded `iptvnator_mpv_helper --runtime-probe` with RUNTIME_PROBE_TIMEOUT_MS (3s) — a constant it shares with the application's own startup capability gate. Three seconds is a tight budget for a helper that dlopens libmpv plus EGL/GL/GBM, and the Flatpak profile is closest to that edge because the helper runs inside the sandbox against its bundled closure: on #1277 the job failed three consecutive reruns and passed on the fourth with no code change, while the concurrent master job passed. Give the verifier its own budget rather than raising the shared one. The app's probe is a blocking spawnSync on the Electron main process, so a hung helper must not stall window creation, and a timeout there degrades gracefully to the native-view fallback. Nothing waits on the packaging probe but the CI job, which already has its own 120-minute bound, while a premature kill reports a healthy package as broken. - PACKAGE_VERIFICATION_PROBE_TIMEOUT_MS (15s) and PACKAGE_VERIFICATION_PROBE_MAX_ATTEMPTS (2) join the frozen probe contract; RUNTIME_PROBE_TIMEOUT_MS stays at 3s for the application gate. - runBoundedRuntimeProbe() retries only on ETIMEDOUT, repeating the identical bounded launch (same command, args, env, maxBuffer, killSignal) and announcing the retry on stderr so a degrading trend stays visible. Fail-closed behaviour is unchanged. A hard timeout is the one probe outcome that says nothing about the payload; spawn errors (a missing helper, a wrapper launched instead of the real ELF), termination by signal, nonzero exits and malformed or wrong-protocol lines all still fail on the first attempt, and a helper that keeps hanging still fails once both attempts are spent. The four new/extended verifier tests cover retry-then-success (asserting the second launch is identical to the first), exhausted timeouts still rejecting, four non-timeout verdicts each probing exactly once, and the attempt bound itself. Setting MAX_ATTEMPTS to 1 fails four of them. Co-Authored-By: Claude Opus 5 --- docs/architecture/embedded-mpv-native.md | 15 ++ tools/embedded-mpv/README.md | 13 + tools/embedded-mpv/runtime-probe-contract.cjs | 23 ++ .../embedded-mpv/runtime-probe-contract.d.cts | 2 + .../verify-linux-frame-copy-runtime.mjs | 72 +++++- .../verify-linux-frame-copy-runtime.test.mjs | 228 ++++++++++++++++-- 6 files changed, 324 insertions(+), 29 deletions(-) diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index c143db405..9c6c07057 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -271,6 +271,10 @@ regular development experiment flag) and one process-wide capability decision has succeeded. On Linux x64 that decision validates the profile manifest, regular-file/access modes, the complete declared bundled closure and hashes, then runs `iptvnator_mpv_helper --runtime-probe` with a three-second timeout. +That short budget belongs to the application gate alone, because it blocks the +main process and a timeout there degrades to the native-view fallback; +`tools/packaging/verify-linux-frame-copy-runtime.mjs` runs the same probe under +a deliberately larger bound described under "Same-Version Desktop Release Gate". The probe loads dependencies through the normal ELF loader, initializes an idle libmpv client, creates EGL/OpenGL plus mpv render contexts, then creates, maps, validates, and destroys a minimal `16x16` shared-memory ring @@ -951,6 +955,17 @@ packaging passes with `IPTVNATOR_EMBEDDED_MPV_PLATFORM=linux`, `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1`, and one exact `IPTVNATOR_LINUX_FRAME_COPY_PROFILE`. Each produced artifact is extracted and verified, and the x64 helper probe runs in the intended runtime environment. +That extracted-artifact probe uses its own 15-second bound rather than the +application gate's three seconds: nothing waits on it but the CI job, which +already has a job-level timeout, while a premature kill would report a healthy +package as broken. Cold sandboxes — Flatpak most of all — can spend seconds +merely loading libmpv plus EGL/GL/GBM. A hard timeout is also the only probe +outcome that says nothing about the payload, so the verifier repeats it once +(two attempts in total, announced on stderr) before failing. Every other +outcome — spawn error, termination by signal, nonzero exit, or a malformed +protocol line — remains fail-closed on the first attempt, so a wrapper launched +instead of the real ELF, a missing helper, or a hung helper still fails +verification. The packaged x64 Playwright smoke first runs its fixture-contract target and passes Chromium `--ignore-gpu-blocklist` so Mesa llvmpipe can expose WebGL2 in CI. That launch-only flag does not bypass any manifest, hash, loader, or helper diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index ccbd1b39b..f7c8b07f6 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -273,6 +273,19 @@ verifies `system`, `portable`, and `flatpak` independently. Every artifact is extracted for manifest, mode, package-metadata, ELF-isolation, and helper-probe checks. System formats are probed after their declared dependency is installed; Snap and Flatpak also require a sandboxed probe where the runner supports it. + +`tools/packaging/verify-linux-frame-copy-runtime.mjs` bounds its helper probe at +`PACKAGE_VERIFICATION_PROBE_TIMEOUT_MS` (15 s), not the application gate's +`RUNTIME_PROBE_TIMEOUT_MS` (3 s): the short budget exists because the app's +decision blocks the Electron main process, whereas nothing waits on the +packaging probe but the CI job's own timeout, and a premature kill would call a +healthy package broken. A hard timeout is the one outcome that says nothing +about the payload, so the verifier repeats it up to +`PACKAGE_VERIFICATION_PROBE_MAX_ATTEMPTS` (2) times with the identical bounded +launch and announces the retry on stderr. Every other outcome — spawn error, +signal, nonzero exit, malformed protocol line — stays fail-closed on the first +attempt, and a helper that keeps hanging still fails once the attempts are +spent. Both constants live in `runtime-probe-contract.cjs`. For a locally installed `--dangerous` Snap, CI explicitly installs and connects `mesa-core22` and `gnome-3-28-1804`, verifies both connections, and then runs the application-level diagnostic under Xvfb. diff --git a/tools/embedded-mpv/runtime-probe-contract.cjs b/tools/embedded-mpv/runtime-probe-contract.cjs index 28e8bde7d..5521c8090 100644 --- a/tools/embedded-mpv/runtime-probe-contract.cjs +++ b/tools/embedded-mpv/runtime-probe-contract.cjs @@ -1,6 +1,29 @@ +// Shared bounds for `iptvnator_mpv_helper --runtime-probe`. +// +// The two timeouts differ on purpose, because the two callers pay different +// costs for waiting: +// +// - RUNTIME_PROBE_TIMEOUT_MS bounds the application's own startup capability +// decision, a blocking spawnSync on the Electron main process. A hung helper +// must not stall window creation, and a timeout there degrades gracefully to +// the native-view fallback, so this budget stays short. +// - PACKAGE_VERIFICATION_PROBE_TIMEOUT_MS bounds the one-shot packaging check +// in tools/packaging/verify-linux-frame-copy-runtime.mjs. Nothing waits on it +// but CI, which already has its own job-level bound, while a premature +// timeout produces a false "broken payload" verdict on a healthy package. +// Cold sandboxes — Flatpak most of all — spend seconds just loading libmpv +// plus the EGL/GL/GBM stack, so this budget is generous. +// +// A hard timeout is also the one probe outcome that says nothing about the +// payload, so the packaging verifier repeats it up to +// PACKAGE_VERIFICATION_PROBE_MAX_ATTEMPTS times. Every other outcome — spawn +// error, signal, nonzero exit, malformed protocol line — remains fail-closed on +// the first attempt, and a helper that genuinely hangs still fails the check. const RUNTIME_PROBE_CONTRACT = Object.freeze({ RUNTIME_PROBE_MAX_BUFFER_BYTES: 16 * 1024 * 1024, RUNTIME_PROBE_TIMEOUT_MS: 3000, + PACKAGE_VERIFICATION_PROBE_TIMEOUT_MS: 15000, + PACKAGE_VERIFICATION_PROBE_MAX_ATTEMPTS: 2, }); module.exports = RUNTIME_PROBE_CONTRACT; diff --git a/tools/embedded-mpv/runtime-probe-contract.d.cts b/tools/embedded-mpv/runtime-probe-contract.d.cts index 545231e21..c4766f241 100644 --- a/tools/embedded-mpv/runtime-probe-contract.d.cts +++ b/tools/embedded-mpv/runtime-probe-contract.d.cts @@ -1,6 +1,8 @@ declare const RUNTIME_PROBE_CONTRACT: Readonly<{ readonly RUNTIME_PROBE_MAX_BUFFER_BYTES: 16777216; readonly RUNTIME_PROBE_TIMEOUT_MS: 3000; + readonly PACKAGE_VERIFICATION_PROBE_TIMEOUT_MS: 15000; + readonly PACKAGE_VERIFICATION_PROBE_MAX_ATTEMPTS: 2; }>; export = RUNTIME_PROBE_CONTRACT; diff --git a/tools/packaging/verify-linux-frame-copy-runtime.mjs b/tools/packaging/verify-linux-frame-copy-runtime.mjs index 68121639d..d0e9f692d 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.mjs @@ -26,8 +26,9 @@ const { } = require('./linux-frame-copy-profile.cjs'); const { resolveLinuxLauncherLayout } = require('./linux-launcher-layout.cjs'); const { + PACKAGE_VERIFICATION_PROBE_MAX_ATTEMPTS, + PACKAGE_VERIFICATION_PROBE_TIMEOUT_MS, RUNTIME_PROBE_MAX_BUFFER_BYTES, - RUNTIME_PROBE_TIMEOUT_MS, } = require('../embedded-mpv/runtime-probe-contract.cjs'); const scriptPath = fileURLToPath(import.meta.url); @@ -1501,6 +1502,56 @@ export function createRuntimeProbeEnvironment({ return probeEnvironment; } +function writeVerifierWarning(message) { + process.stderr.write(message); +} + +function isProbeTimeoutResult(result) { + const error = result?.error; + return Boolean( + error && typeof error === 'object' && error.code === 'ETIMEDOUT' + ); +} + +/** + * Executes the bounded packaged helper capability probe. + * + * A hard timeout is the only retried outcome: it proves nothing about the + * payload, and a cold sandbox can spend seconds merely loading libmpv plus the + * EGL/GL/GBM stack. Every other outcome — spawn error, signal, nonzero exit, + * malformed protocol line — is a real capability verdict and is returned + * unchanged after the first attempt. A helper that genuinely hangs still fails + * verification once the bounded attempts are exhausted. + */ +export function runBoundedRuntimeProbe({ + probeRunner, + helperPath, + probeEnvironment, + warn = writeVerifierWarning, +}) { + const probeOptions = { + encoding: 'utf8', + env: probeEnvironment, + killSignal: 'SIGKILL', + maxBuffer: RUNTIME_PROBE_MAX_BUFFER_BYTES, + timeout: PACKAGE_VERIFICATION_PROBE_TIMEOUT_MS, + windowsHide: true, + }; + let result = probeRunner(helperPath, ['--runtime-probe'], probeOptions); + for ( + let attempt = 2; + attempt <= PACKAGE_VERIFICATION_PROBE_MAX_ATTEMPTS && + isProbeTimeoutResult(result); + attempt += 1 + ) { + warn( + `Linux frame-copy runtime probe timed out after ${PACKAGE_VERIFICATION_PROBE_TIMEOUT_MS} ms; retrying (attempt ${attempt} of ${PACKAGE_VERIFICATION_PROBE_MAX_ATTEMPTS}).\n` + ); + result = probeRunner(helperPath, ['--runtime-probe'], probeOptions); + } + return result; +} + function validateNoEmbeddedMpvNativeArchiveEntries( resourceDir, asarListPackage @@ -1542,6 +1593,7 @@ export function verifyExtractedLinuxFrameCopyRuntime({ elfInspector = defaultElfInspector, probeRunner = runVerifierCommand, environment = process.env, + warn = writeVerifierWarning, asarListPackage = listAsarPackageEntries, }) { const errors = []; @@ -1625,18 +1677,12 @@ export function verifyExtractedLinuxFrameCopyRuntime({ nativeDir, runtimeMode: profile.runtimeMode, }); - const probeResult = probeRunner( - path.join(nativeDir, 'iptvnator_mpv_helper'), - ['--runtime-probe'], - { - encoding: 'utf8', - env: probeEnvironment, - killSignal: 'SIGKILL', - maxBuffer: RUNTIME_PROBE_MAX_BUFFER_BYTES, - timeout: RUNTIME_PROBE_TIMEOUT_MS, - windowsHide: true, - } - ); + const probeResult = runBoundedRuntimeProbe({ + probeRunner, + helperPath: path.join(nativeDir, 'iptvnator_mpv_helper'), + probeEnvironment, + warn, + }); errors.push(...validateProbeResult(probeResult)); return errors; } diff --git a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs index 0a92857ea..38c41cced 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs @@ -13,6 +13,7 @@ import { parseVerifierArguments, readLinuxArtifactMetadata, readElfArchitecture, + runBoundedRuntimeProbe, runVerifierCommand, validateSystemPackageDependencies, validateExtractedSnapMetadata, @@ -117,14 +118,37 @@ test('uses the shared frozen runtime probe resource contract', async () => { runtimeProbeContract.RUNTIME_PROBE_MAX_BUFFER_BYTES, 16 * 1024 * 1024 ); + // The packaging check may wait far longer than the app's blocking startup + // decision, and repeats only the outcome that says nothing about the + // payload. + assert.equal( + runtimeProbeContract.PACKAGE_VERIFICATION_PROBE_TIMEOUT_MS, + 15000 + ); + assert.equal( + runtimeProbeContract.PACKAGE_VERIFICATION_PROBE_MAX_ATTEMPTS, + 2 + ); + const runtimeProbeContractTypes = fs.readFileSync( + runtimeProbeContractTypesUrl, + 'utf8' + ); assert.match( - fs.readFileSync(runtimeProbeContractTypesUrl, 'utf8'), + runtimeProbeContractTypes, /readonly RUNTIME_PROBE_TIMEOUT_MS: 3000/ ); assert.match( - fs.readFileSync(runtimeProbeContractTypesUrl, 'utf8'), + runtimeProbeContractTypes, /readonly RUNTIME_PROBE_MAX_BUFFER_BYTES: 16777216/ ); + assert.match( + runtimeProbeContractTypes, + /readonly PACKAGE_VERIFICATION_PROBE_TIMEOUT_MS: 15000/ + ); + assert.match( + runtimeProbeContractTypes, + /readonly PACKAGE_VERIFICATION_PROBE_MAX_ATTEMPTS: 2/ + ); const verifierSource = fs.readFileSync(verifierUrl, 'utf8'); assert.match( @@ -135,6 +159,14 @@ test('uses the shared frozen runtime probe resource contract', async () => { verifierSource, /const RUNTIME_PROBE_TIMEOUT_MS\s*=\s*3000/ ); + assert.match( + verifierSource, + /timeout:\s*PACKAGE_VERIFICATION_PROBE_TIMEOUT_MS/ + ); + assert.doesNotMatch( + verifierSource, + /PACKAGE_VERIFICATION_PROBE_TIMEOUT_MS\s*=\s*15000/ + ); const backendContractsSource = fs.readFileSync( backendRuntimeContractsUrl, @@ -825,7 +857,7 @@ test('validates an x64 system payload and executes one bounded helper probe', () env: { PATH: '/usr/bin' }, killSignal: 'SIGKILL', maxBuffer: 16 * 1024 * 1024, - timeout: 3000, + timeout: 15000, windowsHide: true, }); } finally { @@ -996,25 +1028,37 @@ test('excludes only Snap template library roots from Electron isolation checks', } }); -test('rejects helper probes terminated by a signal or hard timeout', () => { - for (const probeResult of [ +test('rejects helper probes terminated by a signal or exhausted hard timeouts', () => { + for (const { probeResult, expectedAttempts } of [ { - status: null, - signal: 'SIGKILL', - stdout: '', - stderr: '', + // An external kill is a real capability verdict, not a flake. + probeResult: { + status: null, + signal: 'SIGKILL', + stdout: '', + stderr: '', + }, + expectedAttempts: 1, }, { - error: Object.assign(new Error('spawnSync helper ETIMEDOUT'), { - code: 'ETIMEDOUT', - }), - status: null, - signal: 'SIGKILL', - stdout: '', - stderr: '', + // spawnSync reports a hard timeout as ETIMEDOUT alongside the + // SIGKILL it sent; the timeout wins, and a helper that keeps + // hanging still fails once the bounded attempts are spent. + probeResult: { + error: Object.assign(new Error('spawnSync helper ETIMEDOUT'), { + code: 'ETIMEDOUT', + }), + status: null, + signal: 'SIGKILL', + stdout: '', + stderr: '', + }, + expectedAttempts: 2, }, ]) { const fixture = createSystemPayload(); + const warnings = []; + let probeCalls = 0; try { const errors = verifyExtractedLinuxFrameCopyRuntime({ resourceDir: fixture.resourceDir, @@ -1023,19 +1067,171 @@ test('rejects helper probes terminated by a signal or hard timeout', () => { packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, elfInspector: validElfInspector, probeRunner() { + probeCalls += 1; return probeResult; }, + warn(message) { + warnings.push(message); + }, }); assert.match( errors.join('\n'), /(?:runtime probe terminated by signal SIGKILL|Unable to execute .*ETIMEDOUT)/ ); + assert.equal(probeCalls, expectedAttempts); + assert.equal(warnings.length, expectedAttempts - 1); } finally { fs.rmSync(fixture.root, { recursive: true, force: true }); } } }); +test('retries one timed-out helper probe and accepts a healthy second attempt', () => { + const fixture = createSystemPayload(); + const probeCalls = []; + const warnings = []; + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, + elfInspector: validElfInspector, + probeRunner(command, args, options) { + probeCalls.push({ command, args, options }); + return probeCalls.length === 1 + ? { + error: Object.assign( + new Error('spawnSync helper ETIMEDOUT'), + { code: 'ETIMEDOUT' } + ), + status: null, + signal: 'SIGKILL', + stdout: '', + stderr: '', + } + : successfulProbeRunner(); + }, + warn(message) { + warnings.push(message); + }, + }); + assert.deepEqual(errors, []); + assert.equal(probeCalls.length, 2); + // The retry must repeat the identical bounded launch, never a weaker + // one. + assert.equal(probeCalls[1].command, probeCalls[0].command); + assert.deepEqual(probeCalls[1].args, ['--runtime-probe']); + assert.deepEqual(probeCalls[1].options, probeCalls[0].options); + assert.equal(probeCalls[1].options.timeout, 15000); + assert.equal(warnings.length, 1); + assert.match( + warnings[0], + /timed out after 15000 ms; retrying \(attempt 2 of 2\)/ + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('never retries helper probe outcomes that judge the payload', () => { + for (const { probeResult, expected } of [ + { + probeResult: { + error: Object.assign(new Error('spawnSync helper ENOENT'), { + code: 'ENOENT', + }), + status: null, + signal: null, + stdout: '', + stderr: '', + }, + expected: /Unable to execute .*ENOENT/, + }, + { + probeResult: { + status: 127, + signal: null, + stdout: '', + stderr: 'libmpv.so.2: cannot open shared object file', + }, + expected: /runtime probe failed with status 127/, + }, + { + probeResult: { + status: 0, + signal: null, + stdout: '{"protocol":1,"usable":true,"libmpv":"0.41.0","renderApi":"egl"}\n{"protocol":1}\n', + stderr: '', + }, + expected: /must emit exactly one newline-terminated JSON line/, + }, + { + probeResult: { + status: 0, + signal: null, + stdout: '{"protocol":2,"usable":true,"libmpv":"0.41.0","renderApi":"egl"}\n', + stderr: '', + }, + expected: /did not return protocol 1 usable EGL capability/, + }, + ]) { + const fixture = createSystemPayload(); + const warnings = []; + let probeCalls = 0; + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, + elfInspector: validElfInspector, + probeRunner() { + probeCalls += 1; + return probeResult; + }, + warn(message) { + warnings.push(message); + }, + }); + assert.match(errors.join('\n'), expected); + assert.equal(probeCalls, 1); + assert.deepEqual(warnings, []); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('bounds timed-out helper probes to the shared attempt contract', () => { + const timeoutResult = { + error: Object.assign(new Error('spawnSync helper ETIMEDOUT'), { + code: 'ETIMEDOUT', + }), + status: null, + signal: 'SIGKILL', + stdout: '', + stderr: '', + }; + const warnings = []; + let probeCalls = 0; + const result = runBoundedRuntimeProbe({ + probeRunner() { + probeCalls += 1; + return timeoutResult; + }, + helperPath: '/packaged/native/iptvnator_mpv_helper', + probeEnvironment: { PATH: '/usr/bin' }, + warn(message) { + warnings.push(message); + }, + }); + assert.equal(probeCalls, 2); + assert.equal(warnings.length, 1); + // The exhausted timeout is reported verbatim so the caller still fails. + assert.equal(result, timeoutResult); +}); + test('requires exact Snap graphics layouts and plugs used by the app', () => { const root = fs.mkdtempSync( path.join(os.tmpdir(), 'iptvnator-verifier-snap-metadata-') From 5932e71cb9ed2b234c87be8fdb62fd59b7611ef6 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:59:20 +0200 Subject: [PATCH 06/12] fix(electron-backend): process zero-delay database cancellation (#1295) --- ...lectron-backend-zero-delay-cancellation.md | 8 + ...ase-worker-zero-delay-cancellation.spec.ts | 180 ++++++++++++++++++ .../src/app/workers/database.worker.ts | 1 + docs/architecture/sqlite-db-worker.md | 5 +- 4 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 .changes/electron-backend-zero-delay-cancellation.md create mode 100644 apps/electron-backend/src/app/workers/database-worker-zero-delay-cancellation.spec.ts diff --git a/.changes/electron-backend-zero-delay-cancellation.md b/.changes/electron-backend-zero-delay-cancellation.md new file mode 100644 index 000000000..6f5aef92e --- /dev/null +++ b/.changes/electron-backend-zero-delay-cancellation.md @@ -0,0 +1,8 @@ +--- +type: fix +area: electron-backend +--- + +Cancelling a large Xtream import now stops database work even while it is +running at full speed, instead of letting the import finish before processing +the cancel request. diff --git a/apps/electron-backend/src/app/workers/database-worker-zero-delay-cancellation.spec.ts b/apps/electron-backend/src/app/workers/database-worker-zero-delay-cancellation.spec.ts new file mode 100644 index 000000000..b32236964 --- /dev/null +++ b/apps/electron-backend/src/app/workers/database-worker-zero-delay-cancellation.spec.ts @@ -0,0 +1,180 @@ +import { MessageChannel, type MessagePort } from 'node:worker_threads'; +import type { + DbWorkerIncomingMessage, + DbWorkerMessage, + DbWorkerResponseMessage, +} from './database-worker.types'; + +const BATCH_DELAY_ENV = 'IPTVNATOR_DB_WORKER_BATCH_DELAY_MS'; +const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; +const originalBatchDelay = process.env[BATCH_DELAY_ENV]; +const originalWorkerProfiling = process.env[WORKER_PROFILING_ENV]; + +function restoreEnvironment(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +function postWorkerMessage( + port: MessagePort, + message: DbWorkerIncomingMessage +): void { + port.postMessage(message); +} + +describe('database worker zero-delay cancellation', () => { + let workerPort: MessagePort | null = null; + let clientPort: MessagePort | null = null; + + afterEach(() => { + workerPort?.close(); + clientPort?.close(); + workerPort = null; + clientPort = null; + restoreEnvironment(BATCH_DELAY_ENV, originalBatchDelay); + restoreEnvironment(WORKER_PROFILING_ENV, originalWorkerProfiling); + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it('processes cancel posted from progress before the default-delay operation completes', async () => { + delete process.env[BATCH_DELAY_ENV]; + delete process.env[WORKER_PROFILING_ENV]; + + const channel = new MessageChannel(); + workerPort = channel.port1; + clientPort = channel.port2; + const messages: DbWorkerMessage[] = []; + let cancelPosted = false; + let settleResponse!: (response: DbWorkerResponseMessage) => void; + const responsePromise = new Promise( + (resolve) => { + settleResponse = resolve; + } + ); + + clientPort.on('message', (message: DbWorkerMessage) => { + messages.push(message); + + if ( + !cancelPosted && + message.type === 'event' && + message.event.status === 'progress' + ) { + cancelPosted = true; + postWorkerMessage(clientPort!, { + type: 'cancel', + operationId: 'operation-zero-delay', + }); + } + + if (message.type === 'response') { + settleResponse(message); + } + }); + + jest.doMock('worker_threads', () => ({ + ...jest.requireActual('worker_threads'), + parentPort: workerPort, + })); + jest.doMock('./database.worker-connection', () => ({ + closeWorkerDatabase: jest.fn(), + getWorkerDatabase: jest.fn().mockResolvedValue({}), + })); + jest.doMock('../database/operations/content.operations', () => ({ + saveContent: jest.fn( + async ( + _db: unknown, + _playlistId: string, + _streams: unknown[], + _type: string, + control: { + checkpoint: () => Promise; + onProgress: (progress: { + phase: string; + current: number; + total: number; + }) => Promise; + } + ) => { + for (let current = 1; current <= 3; current += 1) { + await control.checkpoint(); + await control.onProgress({ + phase: 'saving-content', + current, + total: 3, + }); + } + return { count: 3, success: true }; + } + ), + })); + jest.doMock('./worker-performance-capture', () => ({ + armWorkerPerformanceCapture: jest.fn(), + executeWithWorkerPerformanceCapture: jest.fn( + async (_capture: unknown, execute: () => Promise) => { + try { + return { + error: null, + performance: undefined, + result: await execute(), + success: true, + }; + } catch (error) { + return { + error, + performance: undefined, + result: undefined, + success: false, + }; + } + } + ), + registerDatabaseWorkerPerformanceCapture: jest.fn(), + releaseDatabaseWorkerPerformanceCapture: jest.fn(), + stampWorkerPerformanceResponsePostedEpoch: jest.fn( + (_capture: unknown, performance: unknown) => performance + ), + startWorkerPerformanceCapture: jest.fn(() => null), + })); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await import('./database.worker'); + postWorkerMessage(clientPort, { + type: 'request', + operation: 'DB_SAVE_CONTENT', + payload: { + operationId: 'operation-zero-delay', + playlistId: 'playlist-1', + streams: [{ stream_id: 1 }, { stream_id: 2 }], + type: 'live', + }, + requestId: 'request-zero-delay', + }); + + const response = await responsePromise; + + expect(cancelPosted).toBe(true); + expect(messages).toContainEqual( + expect.objectContaining({ + event: expect.objectContaining({ + operationId: 'operation-zero-delay', + status: 'cancelled', + }), + requestId: 'request-zero-delay', + type: 'event', + }) + ); + expect(response).toEqual( + expect.objectContaining({ + error: expect.objectContaining({ name: 'AbortError' }), + requestId: 'request-zero-delay', + success: false, + type: 'response', + }) + ); + }); +}); diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 61ca7f54d..7ac3e6501 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -192,6 +192,7 @@ function postEvent(requestId: string, event: DbOperationEvent): void { async function pauseBetweenBatches(): Promise { if (batchDelayMs <= 0) { + await new Promise((resolve) => setImmediate(resolve)); return; } diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 54f8bc266..56504eb66 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -723,7 +723,10 @@ For deterministic E2E timing, tests may set: IPTVNATOR_DB_WORKER_BATCH_DELAY_MS=20 ``` -This delay is test-only and disabled by default. +This artificial delay is test-only and disabled by default. At the default +value of `0`, every cancellable batch checkpoint still yields one event-loop +turn without adding a timer delay. That yield lets the worker receive a queued +cancel message before starting the next batch. ### Useful verification commands From 19b592badba06e160e611842da63fb9088df83d6 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:15:50 +0200 Subject: [PATCH 07/12] fix(epg): make manual EPG mapping lookups actually fail soft (#1291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mapping handlers documented a fail-soft contract but only honored half of it. Four of them returned the database operation's promise from inside the `try` block without awaiting it, so the rejection escaped the `catch`: the try block exits before the promise settles. A `getDatabase()` failure was caught, but a SQLite error in the operation rejected the `ipcMain.handle` promise, and the renderer call threw instead of receiving `null` / `{success:false}` / `[]`. Adding `await` in handleGetEpgMapping, handleSetEpgMapping, handleDeleteEpgMapping and handleSearchEpgChannels closes the gap. resolveChannelIds and handleGetEpgMappingsBatch already awaited correctly and are unchanged. This is pre-existing — the same shape predates the epg.events.ts split in 636545cb, which preserved semantics faithfully and inherited the bug. Adds epg-mapping.service.spec.ts, the first coverage these handlers have had: table-driven over all six functions against both failure modes, plus the guard clauses and queryByResolvedChannelIds remapping. Verified to fail on the old behavior — reverting the four awaits fails exactly the four operation-rejection cases. Co-authored-by: Claude Opus 5 --- .changes/epg-mapping-fail-soft.md | 9 + .../app/events/epg-mapping.service.spec.ts | 246 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 .changes/epg-mapping-fail-soft.md create mode 100644 apps/electron-backend/src/app/events/epg-mapping.service.spec.ts diff --git a/.changes/epg-mapping-fail-soft.md b/.changes/epg-mapping-fail-soft.md new file mode 100644 index 000000000..253aacde3 --- /dev/null +++ b/.changes/epg-mapping-fail-soft.md @@ -0,0 +1,9 @@ +--- +type: fix +area: epg +--- + +A database error while looking up manual EPG channel mappings no longer breaks +the request that triggered it. Searching for a channel in the "Map EPG channel" +dialog, or saving and removing a mapping, now degrades gracefully instead of +failing outright. diff --git a/apps/electron-backend/src/app/events/epg-mapping.service.spec.ts b/apps/electron-backend/src/app/events/epg-mapping.service.spec.ts new file mode 100644 index 000000000..d36f9c3be --- /dev/null +++ b/apps/electron-backend/src/app/events/epg-mapping.service.spec.ts @@ -0,0 +1,246 @@ +const getDatabase = jest.fn(); +const getEpgMapping = jest.fn(); +const getEpgMappingsBatch = jest.fn(); +const setEpgMapping = jest.fn(); +const deleteEpgMapping = jest.fn(); +const searchEpgChannels = jest.fn(); + +jest.mock('../database/connection', () => ({ + getDatabase: (...args: unknown[]) => getDatabase(...args), +})); + +jest.mock('../database/operations/epg-mapping.operations', () => ({ + getEpgMapping: (...args: unknown[]) => getEpgMapping(...args), + getEpgMappingsBatch: (...args: unknown[]) => getEpgMappingsBatch(...args), + setEpgMapping: (...args: unknown[]) => setEpgMapping(...args), + deleteEpgMapping: (...args: unknown[]) => deleteEpgMapping(...args), + searchEpgChannels: (...args: unknown[]) => searchEpgChannels(...args), +})); + +import { + handleDeleteEpgMapping, + handleGetEpgMapping, + handleGetEpgMappingsBatch, + handleSearchEpgChannels, + handleSetEpgMapping, + queryByResolvedChannelIds, + resolveChannelIds, +} from './epg-mapping.service'; + +const DB_HANDLE = { db: true }; +const DB_ERROR = new Error('getDatabase failed'); +const OP_ERROR = new Error('sqlite operation failed'); + +describe('epg-mapping.service', () => { + beforeEach(() => { + jest.resetAllMocks(); + getDatabase.mockResolvedValue(DB_HANDLE); + }); + + /** + * The fail-soft contract: a mapping lookup must never take down an EPG IPC + * request. Each entry is checked against BOTH failure modes — a rejecting + * `getDatabase()` and a rejecting database operation. + */ + const failSoftCases: Array<{ + name: string; + operation: jest.Mock; + invoke: () => Promise; + fallback: unknown; + }> = [ + { + name: 'resolveChannelIds', + operation: getEpgMappingsBatch, + invoke: () => resolveChannelIds(['channel-a']), + fallback: new Map(), + }, + { + name: 'handleGetEpgMapping', + operation: getEpgMapping, + invoke: () => handleGetEpgMapping('channel-a'), + fallback: null, + }, + { + name: 'handleGetEpgMappingsBatch', + operation: getEpgMappingsBatch, + invoke: () => handleGetEpgMappingsBatch(['channel-a']), + fallback: {}, + }, + { + name: 'handleSetEpgMapping', + operation: setEpgMapping, + invoke: () => handleSetEpgMapping('channel-a', 'epg-a'), + fallback: { success: false }, + }, + { + name: 'handleDeleteEpgMapping', + operation: deleteEpgMapping, + invoke: () => handleDeleteEpgMapping('channel-a'), + fallback: { success: false }, + }, + { + name: 'handleSearchEpgChannels', + operation: searchEpgChannels, + invoke: () => handleSearchEpgChannels('bbc'), + fallback: [], + }, + ]; + + for (const { name, operation, invoke, fallback } of failSoftCases) { + describe(`${name} fail-soft`, () => { + it('returns the fallback when getDatabase() rejects', async () => { + getDatabase.mockRejectedValue(DB_ERROR); + + await expect(invoke()).resolves.toEqual(fallback); + expect(operation).not.toHaveBeenCalled(); + }); + + it('returns the fallback when the database operation rejects', async () => { + operation.mockRejectedValue(OP_ERROR); + + await expect(invoke()).resolves.toEqual(fallback); + expect(operation).toHaveBeenCalled(); + }); + }); + } + + describe('resolveChannelIds', () => { + it('returns the mapping batch keyed by original channel id', async () => { + const mappings = new Map([['channel-a', 'epg-a']]); + getEpgMappingsBatch.mockResolvedValue(mappings); + + await expect(resolveChannelIds(['channel-a'])).resolves.toEqual( + mappings + ); + expect(getEpgMappingsBatch).toHaveBeenCalledWith(DB_HANDLE, [ + 'channel-a', + ]); + }); + }); + + describe('queryByResolvedChannelIds', () => { + it('queries mapped ids and remaps results onto the requested ids', async () => { + getEpgMappingsBatch.mockResolvedValue( + new Map([['channel-a', 'epg-a']]) + ); + const query = jest + .fn() + .mockResolvedValue({ 'epg-a': 'A', 'channel-b': 'B' }); + + const result = await queryByResolvedChannelIds( + ['channel-a', 'channel-b'], + query + ); + + expect(query).toHaveBeenCalledWith(['epg-a', 'channel-b']); + expect(result).toEqual({ 'channel-a': 'A', 'channel-b': 'B' }); + }); + + it('fills missing query results with null', async () => { + getEpgMappingsBatch.mockResolvedValue(new Map()); + const query = jest.fn().mockResolvedValue({}); + + await expect( + queryByResolvedChannelIds(['channel-a'], query) + ).resolves.toEqual({ 'channel-a': null }); + }); + + it('falls back to unmapped ids when the mapping lookup fails', async () => { + getEpgMappingsBatch.mockRejectedValue(OP_ERROR); + const query = jest.fn().mockResolvedValue({ 'channel-a': 'A' }); + + const result = await queryByResolvedChannelIds( + ['channel-a'], + query + ); + + expect(query).toHaveBeenCalledWith(['channel-a']); + expect(result).toEqual({ 'channel-a': 'A' }); + }); + }); + + describe('handleGetEpgMappingsBatch', () => { + it('converts the mapping batch into a plain record', async () => { + getEpgMappingsBatch.mockResolvedValue( + new Map([['channel-a', 'epg-a']]) + ); + + await expect( + handleGetEpgMappingsBatch(['channel-a']) + ).resolves.toEqual({ 'channel-a': 'epg-a' }); + }); + + it.each([ + ['an empty array', [] as string[]], + ['a non-array value', undefined as unknown as string[]], + ])( + 'short-circuits for %s without touching the database', + async (_label, channelKeys) => { + await expect( + handleGetEpgMappingsBatch(channelKeys) + ).resolves.toEqual({}); + expect(getDatabase).not.toHaveBeenCalled(); + } + ); + }); + + describe('handleSearchEpgChannels', () => { + it('forwards the search term and limit to the database', async () => { + const matches = [ + { id: 'epg-a', displayName: 'BBC One', iconUrl: null }, + ]; + searchEpgChannels.mockResolvedValue(matches); + + await expect(handleSearchEpgChannels('bbc', 25)).resolves.toEqual( + matches + ); + expect(searchEpgChannels).toHaveBeenCalledWith( + DB_HANDLE, + 'bbc', + 25 + ); + }); + + it.each([ + ['an empty term', ''], + ['a whitespace-only term', ' '], + ['an undefined term', undefined as unknown as string], + ])( + 'short-circuits for %s without touching the database', + async (_label, searchTerm) => { + await expect( + handleSearchEpgChannels(searchTerm) + ).resolves.toEqual([]); + expect(getDatabase).not.toHaveBeenCalled(); + } + ); + }); + + describe('mapping writes', () => { + it('passes the optional playlist id through to setEpgMapping', async () => { + setEpgMapping.mockResolvedValue({ success: true }); + + await expect( + handleSetEpgMapping('channel-a', 'epg-a', 'playlist-1') + ).resolves.toEqual({ success: true }); + expect(setEpgMapping).toHaveBeenCalledWith( + DB_HANDLE, + 'channel-a', + 'epg-a', + 'playlist-1' + ); + }); + + it('deletes a mapping by channel key', async () => { + deleteEpgMapping.mockResolvedValue({ success: true }); + + await expect(handleDeleteEpgMapping('channel-a')).resolves.toEqual({ + success: true, + }); + expect(deleteEpgMapping).toHaveBeenCalledWith( + DB_HANDLE, + 'channel-a' + ); + }); + }); +}); From 0334296f158e36fd2c5759a5ba6043b416fb2cce Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:05:22 +0200 Subject: [PATCH 08/12] fix(electron-backend): make test suite and lint host-agnostic on Windows checkouts (#1176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(electron-backend): make test suite and lint host-agnostic across Windows/Linux checkouts Windows checkouts (core.autocrlf=true) had 13 pre-existing jest failures and 5 Windows-only lint errors in electron-backend while Linux CI was green: - embedded-mpv-native-source.spec: normalize CRLF after readFileSync so multi-line source assertions match on autocrlf checkouts - worker-runtime-paths.spec: build expected candidate paths with path.join instead of hardcoded POSIX strings - external-player-launch-context: join darwin-only paths (.app bundle executables, Homebrew Caskroom) with path.posix.join so simulated darwin platforms resolve correctly on win32 hosts (no-op on macOS) - app.spec: build packaged-navigation fixtures with path.resolve + pathToFileURL; file:///tmp/... is not a valid win32 file URL - lint target: quote the eslint glob. The unquoted ** was expanded by the POSIX shell on Linux (shallow match), so CI linted only a subset of files while Windows passed the literal pattern to ESLint and linted the full tree - the hosts checked different file sets - fix the 5 errors full-tree linting surfaces: prefer-const in epg-worker.service and database.worker-connection, no-unsafe-finally in external-player-session-registry and embedded-mpv-native.service (rewritten as catch-swallow with identical semantics, pinned by new regression tests), intentional no-control-regex in the recording filename sanitizer; drop stale unused disable directives - document the quoted-glob convention in CLAUDE.md Co-Authored-By: Claude Fable 5 * docs: mirror the quoted-lint-glob convention into AGENTS.md AGENTS.md already mirrors the neighbouring max-lines/baseline paragraph from CLAUDE.md, and it requires coding conventions to stay in sync between the two files. The quoted-glob rule landed only in CLAUDE.md, so agents bootstrapping from AGENTS.md could reintroduce a host-dependent lint target. Also corrects the wording in both copies: the shallow expansion happens on macOS as well as Linux — /bin/sh has no globstar on either — and the target still exits 0 with a broken glob, which is why this went unnoticed. Adds the file-count check that catches it. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Fable 5 --- AGENTS.md | 1 + CLAUDE.md | 9 ++ apps/electron-backend/project.json | 2 +- apps/electron-backend/src/app/app.spec.ts | 15 ++- .../src/app/events/epg-worker.service.ts | 3 +- .../events/external-player-launch-context.ts | 8 +- .../external-player-session-registry.spec.ts | 18 +++ .../external-player-session-registry.ts | 7 +- .../embedded-mpv-native-source.spec.ts | 59 ++++------ .../embedded-mpv-native.service.spec.ts | 17 +++ .../services/embedded-mpv-native.service.ts | 56 +++++----- .../app/workers/database.worker-connection.ts | 5 +- .../app/workers/worker-runtime-paths.spec.ts | 103 ++++++++++++------ .../src/app/workers/worker-runtime-paths.ts | 1 - 14 files changed, 192 insertions(+), 112 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cdf03a35e..eb424f72b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ This file provides guidance to coding agents working in this repository. - Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents. - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. - ESLint enforces `max-lines` on TypeScript files (target under 300, hard maximum 400). Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs`. Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. +- Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint "apps//**/*.ts"`. An unquoted `**` is expanded by the POSIX shell on Linux and macOS (which has no `globstar`, so it matches only a shallow subset of files) while Windows passes the literal pattern to ESLint, which expands it recursively — the two hosts then lint different file sets. The target still reports success either way, so a broken glob hides missing coverage instead of failing. After changing such a target, compare the linted file count against `find -name '*.ts' | wc -l`. - Repository-specific skills are committed under `.codex/skills/`. Claude Code only discovers skills under `.claude/skills/`, so `release-notes` and `release-cut` are mirrored there and the two copies must be kept in sync; every other entry in `.claude/skills/` is personal and stays gitignored. If an external agent does not support skills, treat those files as concise ownership docs. ## Documentation After Changes diff --git a/CLAUDE.md b/CLAUDE.md index 3bc0945f8..23ba46552 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -227,6 +227,15 @@ process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. +Project `lint` targets that shell out to eslint must quote the glob, e.g. +`eslint "apps//**/*.ts"`. An unquoted `**` is expanded by the POSIX +shell on Linux and macOS (which has no `globstar`, so it matches only a +shallow subset of files) while Windows passes the literal pattern to ESLint, +which expands it recursively — the two hosts then lint different file sets. +The target still reports success either way, so a broken glob hides missing +coverage instead of failing. After changing such a target, compare the linted +file count against `find -name '*.ts' | wc -l`. + ## Architecture ### Monorepo Structure (Nx Workspace) diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index 6e4107999..291cd7adf 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -241,7 +241,7 @@ "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs", "{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts" ], - "command": "eslint apps/electron-backend/**/*.ts \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts\" \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/**/*.ts\"" + "command": "eslint \"apps/electron-backend/**/*.ts\" \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts\" \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/**/*.ts\"" }, "test": { "executor": "@nx/jest:jest", diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index bfd5a24a2..173c59d60 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -50,6 +50,8 @@ import { } from './app'; import App from './app'; import { app as electronApp, BrowserWindow, screen } from 'electron'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; import { store } from './services/store.service'; type MockMainWindow = { @@ -214,18 +216,23 @@ describe('Electron app security helpers', () => { }); it('allows only the packaged renderer file in packaged navigation', () => { + // Resolve to host-native absolute paths: POSIX-style file URLs such + // as file:///tmp/... are not valid win32 file URLs (no drive letter). + const packagedIndexPath = path.resolve('/tmp/iptvnator/index.html'); + const otherIndexPath = path.resolve('/tmp/other/index.html'); + expect( isTrustedRendererNavigationUrl( - 'file:///tmp/iptvnator/index.html', + pathToFileURL(packagedIndexPath).href, false, - '/tmp/iptvnator/index.html' + packagedIndexPath ) ).toBe(true); expect( isTrustedRendererNavigationUrl( - 'file:///tmp/other/index.html', + pathToFileURL(otherIndexPath).href, false, - '/tmp/iptvnator/index.html' + packagedIndexPath ) ).toBe(false); expect( diff --git a/apps/electron-backend/src/app/events/epg-worker.service.ts b/apps/electron-backend/src/app/events/epg-worker.service.ts index f671ab999..1b278ed97 100644 --- a/apps/electron-backend/src/app/events/epg-worker.service.ts +++ b/apps/electron-backend/src/app/events/epg-worker.service.ts @@ -422,7 +422,6 @@ export class EpgWorkerService { } let settled = false; - let timeoutId: ReturnType; const settle = (fn: () => void) => { if (settled) return; settled = true; @@ -430,7 +429,7 @@ export class EpgWorkerService { fn(); }; - timeoutId = setTimeout(() => { + const timeoutId = setTimeout(() => { const errorMessage = `${options.timeoutLabel} timed out after ${ this.fetchTimeoutMs / 1000 }s`; diff --git a/apps/electron-backend/src/app/events/external-player-launch-context.ts b/apps/electron-backend/src/app/events/external-player-launch-context.ts index e4908922b..d94ec38d1 100644 --- a/apps/electron-backend/src/app/events/external-player-launch-context.ts +++ b/apps/electron-backend/src/app/events/external-player-launch-context.ts @@ -80,7 +80,9 @@ function resolveMacOSAppBundlePlayerPath( return playerPath; } - return path.join( + // .app bundle paths are always POSIX, even when a darwin platform is + // simulated on a win32 host in tests. + return path.posix.join( appBundlePath, 'Contents', 'MacOS', @@ -320,10 +322,12 @@ function getHomebrewCaskVlcPaths(readDirectory: ReadDirectory): string[] { const caskroomPath = '/opt/homebrew/Caskroom/vlc'; try { + // Caskroom paths are always POSIX, even when a darwin platform is + // simulated on a win32 host in tests. return readDirectory(caskroomPath) .filter((entry) => entry.trim().length > 0) .map((entry) => - path.join( + path.posix.join( caskroomPath, entry, 'VLC.app', diff --git a/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts b/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts index 18e573f11..f59337017 100644 --- a/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts +++ b/apps/electron-backend/src/app/events/external-player-session-registry.spec.ts @@ -47,6 +47,24 @@ describe('ExternalPlayerSessionRegistry', () => { expect(registry.getActiveSessionId()).toBeNull(); }); + it('marks the session closed even when the runtime close fails', async () => { + const close = jest.fn().mockRejectedValue(new Error('close failed')); + const session = registry.beginSession({ + player: 'vlc', + title: 'Example', + streamUrl: 'https://example.com/video.m3u8', + }); + + registry.attachCloser(session.id, close); + + const closed = await registry.closeSession(session.id); + + expect(close).toHaveBeenCalled(); + expect(closed?.status).toBe('closed'); + expect(closed?.canClose).toBe(false); + expect(registry.getActiveSessionId()).toBeNull(); + }); + it('marks runtime failures as errors without clearing the active id', () => { const session = registry.beginSession({ player: 'mpv', diff --git a/apps/electron-backend/src/app/events/external-player-session-registry.ts b/apps/electron-backend/src/app/events/external-player-session-registry.ts index f18e1fb39..d3d981612 100644 --- a/apps/electron-backend/src/app/events/external-player-session-registry.ts +++ b/apps/electron-backend/src/app/events/external-player-session-registry.ts @@ -130,8 +130,11 @@ export class ExternalPlayerSessionRegistry { try { await runtime.close?.(); - } finally { - return this.markClosed(id); + } catch { + // Close failures must not keep the session in a live state; the + // registry still reports it as closed below. } + + return this.markClosed(id); } } diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts index 3588ed41d..cc34104c7 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts @@ -1,53 +1,40 @@ import { readFileSync } from 'fs'; import path from 'path'; +// Checkouts with core.autocrlf=true materialize these sources with CRLF +// line endings; normalize so the multi-line assertions below match the +// LF-based expectations on every host. +function readSource(relativePath: string): string { + return readFileSync(path.resolve(__dirname, relativePath), 'utf8').replace( + /\r\n/g, + '\n' + ); +} + describe('Embedded MPV native source recording invariants', () => { - const nativeSource = readFileSync( - path.resolve(__dirname, '../../../native/src/embedded_mpv.mm'), - 'utf8' + const nativeSource = readSource('../../../native/src/embedded_mpv.mm'); + const widCommonSource = readSource( + '../../../native/src/embedded_mpv_wid_common.h' ); - const widCommonSource = readFileSync( - path.resolve( - __dirname, - '../../../native/src/embedded_mpv_wid_common.h' - ), - 'utf8' + const win32Source = readSource( + '../../../native/src/embedded_mpv_win32.cc' ); - const win32Source = readFileSync( - path.resolve(__dirname, '../../../native/src/embedded_mpv_win32.cc'), - 'utf8' + const linuxSource = readSource( + '../../../native/src/embedded_mpv_linux.cc' ); - const linuxSource = readFileSync( - path.resolve(__dirname, '../../../native/src/embedded_mpv_linux.cc'), - 'utf8' - ); - const buildScriptSource = readFileSync( - path.resolve(__dirname, '../../../build-embedded-mpv.js'), - 'utf8' - ); - const buildAndMakeWorkflowSource = readFileSync( - path.resolve( - __dirname, - '../../../../../.github/workflows/build-and-make.yaml' - ), - 'utf8' + const buildScriptSource = readSource('../../../build-embedded-mpv.js'); + const buildAndMakeWorkflowSource = readSource( + '../../../../../.github/workflows/build-and-make.yaml' ); const electronBuilderConfig = JSON.parse( - readFileSync( - path.resolve(__dirname, '../../../../../electron-builder.json'), - 'utf8' - ) + readSource('../../../../../electron-builder.json') ) as { snap?: { plugs?: unknown; }; }; - const stageRuntimeSource = readFileSync( - path.resolve( - __dirname, - '../../../../../tools/embedded-mpv/stage-runtime.mjs' - ), - 'utf8' + const stageRuntimeSource = readSource( + '../../../../../tools/embedded-mpv/stage-runtime.mjs' ); const frameHelperRenderSource = readFileSync( path.resolve(__dirname, '../../../native/helper/frame_helper_render.h'), diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index 0568a9f0c..670576c77 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -1112,4 +1112,21 @@ describe('EmbeddedMpvNativeService power blocker', () => { }) ); }); + + it('still broadcasts a closed session when native dispose throws', () => { + startSession('s1', snapshot('playing')); + addon.disposeSession.mockImplementationOnce(() => { + throw new Error('native dispose failed'); + }); + + const disposed = service.disposeSession('s1'); + + expect(disposed?.status).toBe('closed'); + expect(mainWindowSendMock).toHaveBeenLastCalledWith( + 'EMBEDDED_MPV_SESSION_UPDATE', + expect.objectContaining({ id: 's1', status: 'closed' }) + ); + // The session is removed from the registry despite the addon failure. + expect(service.disposeSession('s1')).toBeNull(); + }); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index 8142fb66b..19c01502a 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -663,32 +663,35 @@ export class EmbeddedMpvNativeService { lastRecording = undefined; } addon.disposeSession(sessionId); - } finally { - this.sessions.delete(sessionId); - this.pollFailuresLogged.delete(sessionId); - const payload: EmbeddedMpvSession = { - id: session.id, - title: session.title, - streamUrl: session.streamUrl, - status: 'closed', - positionSeconds: 0, - durationSeconds: null, - volume: 1, - audioTracks: [], - selectedAudioTrackId: null, - subtitleTracks: [], - selectedSubtitleTrackId: null, - playbackSpeed: 1, - aspectOverride: 'no', - recording: this.createClosedRecordingState(lastRecording), - startedAt: session.startedAt, - updatedAt: new Date().toISOString(), - }; - this.sendSessionUpdate(payload); - this.stopPollingIfIdle(); - this.updatePowerBlocker(); - return payload; + } catch { + // Native dispose failures must not block registry cleanup or the + // closed-session broadcast below. } + + this.sessions.delete(sessionId); + this.pollFailuresLogged.delete(sessionId); + const payload: EmbeddedMpvSession = { + id: session.id, + title: session.title, + streamUrl: session.streamUrl, + status: 'closed', + positionSeconds: 0, + durationSeconds: null, + volume: 1, + audioTracks: [], + selectedAudioTrackId: null, + subtitleTracks: [], + selectedSubtitleTrackId: null, + playbackSpeed: 1, + aspectOverride: 'no', + recording: this.createClosedRecordingState(lastRecording), + startedAt: session.startedAt, + updatedAt: new Date().toISOString(), + }; + this.sendSessionUpdate(payload); + this.stopPollingIfIdle(); + this.updatePowerBlocker(); + return payload; } shutdown(): void { @@ -979,6 +982,9 @@ export class EmbeddedMpvNativeService { private sanitizeRecordingFileName(title: string): string { const normalized = title + // Control characters are invalid in file names on Windows; the + // range is matched intentionally. + // eslint-disable-next-line no-control-regex .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_') .replace(/\s+/g, ' ') .trim(); diff --git a/apps/electron-backend/src/app/workers/database.worker-connection.ts b/apps/electron-backend/src/app/workers/database.worker-connection.ts index 6a2b29784..9bc8b1bb7 100644 --- a/apps/electron-backend/src/app/workers/database.worker-connection.ts +++ b/apps/electron-backend/src/app/workers/database.worker-connection.ts @@ -15,7 +15,6 @@ import { trace, } from '../services/debug-trace'; -let Database: typeof BetterSqlite3; let drizzleFactory: | (typeof import('drizzle-orm/better-sqlite3'))['drizzle'] | undefined; @@ -37,7 +36,6 @@ function loadBetterSqlite3(): typeof BetterSqlite3 { loggerLabel: '[DB Worker]', searchPaths: nativeModuleSearchPaths, fallbackRequire: () => - // eslint-disable-next-line @typescript-eslint/no-require-imports require('better-sqlite3') as typeof BetterSqlite3, }); } @@ -49,7 +47,6 @@ function getDrizzleFactory(): (typeof import('drizzle-orm/better-sqlite3'))['dri // Require drizzle only after native lookup paths have been registered. // Its better-sqlite3 driver resolves the native package at module load time. - // eslint-disable-next-line @typescript-eslint/no-require-imports drizzleFactory = require('drizzle-orm/better-sqlite3').drizzle as ( typeof import('drizzle-orm/better-sqlite3') )['drizzle']; @@ -57,7 +54,7 @@ function getDrizzleFactory(): (typeof import('drizzle-orm/better-sqlite3'))['dri return drizzleFactory; } -Database = loadBetterSqlite3(); +const Database = loadBetterSqlite3(); let db: AppDatabase | null = null; let sqlite: BetterSqlite3.Database | null = null; diff --git a/apps/electron-backend/src/app/workers/worker-runtime-paths.spec.ts b/apps/electron-backend/src/app/workers/worker-runtime-paths.spec.ts index 70d7691d0..86094e116 100644 --- a/apps/electron-backend/src/app/workers/worker-runtime-paths.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-runtime-paths.spec.ts @@ -6,67 +6,101 @@ import { resolveWorkerRuntimeBootstrap, } from './worker-runtime-paths'; +// The implementation joins candidate paths with the host path module, so the +// expected values are built with path.join too — hardcoded POSIX strings +// would fail on win32 checkouts. +function packagedWorkerPath(root: string, workerFilename: string): string { + return path.join( + root, + 'dist', + 'apps', + 'electron-backend', + 'workers', + workerFilename + ); +} + +function unpackedNodeModulesPaths(root: string): string[] { + return [ + path.join(root, 'app.asar.unpacked', 'node_modules'), + path.join( + root, + 'app.asar.unpacked', + 'electron-backend', + 'node_modules' + ), + path.join( + root, + 'app.asar.unpacked', + 'dist', + 'apps', + 'electron-backend', + 'node_modules' + ), + ]; +} + describe('worker-runtime-paths', () => { it('resolves the development worker path when the worker file exists', () => { + const developmentWorkerDir = + '/workspace/dist/apps/electron-backend/workers'; + const workerPath = path.join( + developmentWorkerDir, + 'database.worker.js' + ); const bootstrap = resolveWorkerRuntimeBootstrap({ isPackaged: false, workerFilename: 'database.worker.js', - developmentWorkerDir: '/workspace/dist/apps/electron-backend/workers', - fileExists: (filePath) => - filePath === - '/workspace/dist/apps/electron-backend/workers/database.worker.js', + developmentWorkerDir, + fileExists: (filePath) => filePath === workerPath, }); expect(bootstrap).toEqual({ - workerPath: - '/workspace/dist/apps/electron-backend/workers/database.worker.js', - workerPathCandidates: [ - '/workspace/dist/apps/electron-backend/workers/database.worker.js', - ], + workerPath, + workerPathCandidates: [workerPath], }); }); it('prefers process.resourcesPath for packaged worker resolution', () => { + const resourcesPath = '/Applications/IPTVnator.app/Contents/Resources'; + const resourcesWorkerPath = packagedWorkerPath( + resourcesPath, + 'epg-parser.worker.js' + ); const bootstrap = resolveWorkerRuntimeBootstrap({ isPackaged: true, workerFilename: 'epg-parser.worker.js', developmentWorkerDir: '/unused', - resourcesPath: '/Applications/IPTVnator.app/Contents/Resources', + resourcesPath, appPath: '/Applications/IPTVnator.app/Contents/Resources/app.asar', - fileExists: (filePath) => - filePath === - '/Applications/IPTVnator.app/Contents/Resources/dist/apps/electron-backend/workers/epg-parser.worker.js', + fileExists: (filePath) => filePath === resourcesWorkerPath, }); - expect(bootstrap.workerPath).toBe( - '/Applications/IPTVnator.app/Contents/Resources/dist/apps/electron-backend/workers/epg-parser.worker.js' + expect(bootstrap.workerPath).toBe(resourcesWorkerPath); + expect(bootstrap.nativeModuleSearchPaths).toEqual( + unpackedNodeModulesPaths(resourcesPath) ); - expect(bootstrap.nativeModuleSearchPaths).toEqual([ - '/Applications/IPTVnator.app/Contents/Resources/app.asar.unpacked/node_modules', - '/Applications/IPTVnator.app/Contents/Resources/app.asar.unpacked/electron-backend/node_modules', - '/Applications/IPTVnator.app/Contents/Resources/app.asar.unpacked/dist/apps/electron-backend/node_modules', - ]); }); it('falls back to appPath dirname when the resourcesPath candidate is missing', () => { + const appWorkerPath = packagedWorkerPath( + '/opt/IPTVnator/resources', + 'database.worker.js' + ); const bootstrap = resolveWorkerRuntimeBootstrap({ isPackaged: true, workerFilename: 'database.worker.js', developmentWorkerDir: '/unused', resourcesPath: '/tmp/runtime-resources', appPath: '/opt/IPTVnator/resources/app.asar', - fileExists: (filePath) => - filePath === - '/opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js', + fileExists: (filePath) => filePath === appWorkerPath, }); - expect(bootstrap.workerPath).toBe( - '/opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js' - ); + expect(bootstrap.workerPath).toBe(appWorkerPath); expect(bootstrap.workerPathCandidates).toEqual([ - '/tmp/runtime-resources/dist/apps/electron-backend/workers/database.worker.js', - '/opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js', + packagedWorkerPath('/tmp/runtime-resources', 'database.worker.js'), + appWorkerPath, ]); }); @@ -84,8 +118,11 @@ describe('worker-runtime-paths', () => { [ 'Unable to resolve worker "database.worker.js".', 'Tried:', - '- /resources/dist/apps/electron-backend/workers/database.worker.js', - '- /opt/IPTVnator/resources/dist/apps/electron-backend/workers/database.worker.js', + `- ${packagedWorkerPath('/resources', 'database.worker.js')}`, + `- ${packagedWorkerPath( + '/opt/IPTVnator/resources', + 'database.worker.js' + )}`, ].join('\n') ); }); @@ -96,11 +133,7 @@ describe('worker-runtime-paths', () => { resourcesPath: '/resources', appPath: '/resources/app.asar', }) - ).toEqual([ - '/resources/app.asar.unpacked/node_modules', - '/resources/app.asar.unpacked/electron-backend/node_modules', - '/resources/app.asar.unpacked/dist/apps/electron-backend/node_modules', - ]); + ).toEqual(unpackedNodeModulesPaths('/resources')); }); it('loads native modules from the first working candidate path', () => { diff --git a/apps/electron-backend/src/app/workers/worker-runtime-paths.ts b/apps/electron-backend/src/app/workers/worker-runtime-paths.ts index 030edc0a7..2d1d76204 100644 --- a/apps/electron-backend/src/app/workers/worker-runtime-paths.ts +++ b/apps/electron-backend/src/app/workers/worker-runtime-paths.ts @@ -62,7 +62,6 @@ export function registerNativeModuleSearchPaths( } ): string[] { const env = options?.env ?? process.env; - // eslint-disable-next-line @typescript-eslint/no-require-imports const moduleApi = options?.moduleApi ?? (require('module') as NodeModuleApi); From bfad82c26c8c64b7689180c8a9c37bad899320cf Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:14:30 +0200 Subject: [PATCH 09/12] fix(settings): stop settings silently reverting on restart (#1272) Settings live in the renderer's IndexedDB, and two failure modes made them look saved while nothing reached disk. A second app instance sharing the same userData directory cannot take the Chromium storage lock, so its renderer reads defaults and every write is dropped. The app now holds a single-instance lock and focuses the running window instead of starting a rival copy. The lock is requested after the userData override so E2E runs with their own data dir keep independent locks, and after Squirrel event handling. IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1 opts out for local CDP debugging. updateSettings() patches in-memory state before persisting and the submit path had no rejection handler, so a failed write produced an unhandled rejection and no user-visible feedback. SettingsStore now records which half of the round trip failed, and the settings page surfaces it through a dismissible error snackbar; the dialog stays open on failure so the save can be retried. Two follow-ups from review, both wider than the report: - a second launch now re-creates the main window when the lock owner has none left, so closing the last window on macOS no longer leaves a second launch quitting silently with nothing on screen - App.onMainWindowCreated() re-runs window-owned bindings for every rebuilt window, so the downloads broadcaster stops holding a destroyed window. This also fixes the same bug on the pre-existing dock `activate` path. Closes #1156 Closes #102 --- .changes/settings-persistence-feedback.md | 10 ++ AGENTS.md | 1 + CLAUDE.md | 2 + .../src/electron-test-fixtures.ts | 117 ++++++++++++- apps/electron-backend-e2e/src/settings.e2e.ts | 65 +++++++ apps/electron-backend/src/app/app.spec.ts | 42 +++++ apps/electron-backend/src/app/app.ts | 53 +++++- .../src/app/services/single-instance.spec.ts | 163 ++++++++++++++++++ .../src/app/services/single-instance.ts | 114 ++++++++++++ apps/electron-backend/src/main.ts | 23 ++- .../src/app/settings/settings-form.facade.ts | 20 ++- .../app/settings/settings-snackbar.service.ts | 32 ++++ ...settings.component.storage-failure.spec.ts | 128 ++++++++++++++ .../src/app/settings/settings.component.ts | 25 ++- .../test-stubs/settings-test-harness.stub.ts | 3 + apps/web/src/assets/i18n/ar.json | 2 + apps/web/src/assets/i18n/ary.json | 2 + apps/web/src/assets/i18n/by.json | 2 + apps/web/src/assets/i18n/de.json | 2 + apps/web/src/assets/i18n/el.json | 2 + apps/web/src/assets/i18n/en.json | 2 + apps/web/src/assets/i18n/es.json | 2 + apps/web/src/assets/i18n/fr.json | 2 + apps/web/src/assets/i18n/hu.json | 2 + apps/web/src/assets/i18n/it.json | 2 + apps/web/src/assets/i18n/ja.json | 2 + apps/web/src/assets/i18n/ko.json | 2 + apps/web/src/assets/i18n/nl.json | 2 + apps/web/src/assets/i18n/pl.json | 2 + apps/web/src/assets/i18n/pt.json | 2 + apps/web/src/assets/i18n/ru.json | 2 + apps/web/src/assets/i18n/tr.json | 2 + apps/web/src/assets/i18n/zh.json | 2 + apps/web/src/assets/i18n/zhtw.json | 2 + apps/web/src/styles.scss | 8 + .../src/lib/settings-store.service.spec.ts | 107 ++++++++++++ .../src/lib/settings-store.service.ts | 28 ++- 37 files changed, 956 insertions(+), 23 deletions(-) create mode 100644 .changes/settings-persistence-feedback.md create mode 100644 apps/electron-backend/src/app/services/single-instance.spec.ts create mode 100644 apps/electron-backend/src/app/services/single-instance.ts create mode 100644 apps/web/src/app/settings/settings.component.storage-failure.spec.ts diff --git a/.changes/settings-persistence-feedback.md b/.changes/settings-persistence-feedback.md new file mode 100644 index 000000000..896e3ee77 --- /dev/null +++ b/.changes/settings-persistence-feedback.md @@ -0,0 +1,10 @@ +--- +type: fix +area: settings +issues: [1156, 102] +--- + +Launching IPTVnator a second time now brings the running window to the front +instead of starting a rival copy that could not save anything — a common reason +settings appeared to reset on restart. If saving or loading settings does fail, +the app now says so instead of silently showing defaults. diff --git a/AGENTS.md b/AGENTS.md index eb424f72b..78001ad29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ This file provides guidance to coding agents working in this repository. - Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`. - If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window. Symptoms: `tab list` shows `about:blank`, snapshots are empty, and screenshots are black. - If that happens, inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the IPTVnator page websocket from the `webSocketDebuggerUrl` field. +- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. ### Trace / Debug Startup diff --git a/CLAUDE.md b/CLAUDE.md index 23ba46552..f628770f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,6 +126,7 @@ nx run electron-backend:make - When the task is Electron automation/debugging, use the `electron` skill - Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`. - If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window (symptoms: `tab list` shows `about:blank`, empty snapshots, black screenshots). Inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the app page's `webSocketDebuggerUrl`. +- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. For startup tracing or white-screen debugging: @@ -590,6 +591,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Bootstraps Electron app and initializes database - Registers event handlers for IPC communication +- Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging. **Database**: diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index df1f1f025..87ab9351c 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -7,6 +7,7 @@ import { Page, test as base, } from '@playwright/test'; +import { spawn } from 'child_process'; import { createServer, Server } from 'http'; import { accessSync, @@ -119,6 +120,38 @@ export type LaunchedElectronApp = { mainWindow: Page; }; +export type CompetingElectronInstanceResult = { + exitCode: number | null; + signal: NodeJS.Signals | null; + stderr: string; + timedOut: boolean; +}; + +const competingInstanceStderrLimit = 4000; + +/** + * Removes a run's data directory, tolerating handles the OS has not released + * yet. + * + * Electron's single-instance lock keeps `SingletonLock`/`SingletonSocket` + * (`lockfile` on Windows) open for the whole process lifetime, and Windows + * releases those handles asynchronously as the process dies — so a removal + * issued right after `closeElectronApp` can still hit EBUSY. This is throwaway + * temp state, so a stubborn directory is a warning, never a test failure. + */ +function removeDataDir(dataDir: string): void { + try { + rmSync(dataDir, { + force: true, + maxRetries: 20, + recursive: true, + retryDelay: 250, + }); + } catch (error) { + console.warn(`Could not remove E2E data dir ${dataDir}:`, error); + } +} + export const test = base.extend({ dataDir: async ({ browserName }, use) => { void browserName; @@ -126,12 +159,29 @@ export const test = base.extend({ await use(dataDir); - rmSync(dataDir, { force: true, recursive: true }); + removeDataDir(dataDir); }, }); export { expect }; +/** + * Builds the argv every locally launched Electron process must share. + * + * Headless Linux CI has no usable sandbox or GPU, and an Electron started + * without these flags there dies on a signal instead of running — so any + * helper that spawns the app itself has to use the same list. + */ +function buildElectronLaunchArgs(extraArgs: readonly string[] = []): string[] { + const args = [...extraArgs, electronMainPath]; + + if (process.platform === 'linux' && process.env['CI']) { + args.unshift('--no-sandbox', '--disable-gpu'); + } + + return args; +} + export async function launchElectronApp( dataDir: string, options: LaunchElectronAppOptions = {} @@ -143,11 +193,7 @@ export async function launchElectronApp( } assertPackagedRendererBuildIsElectronSafe(); - const args = [...(options.args ?? []), electronMainPath]; - - if (process.platform === 'linux' && process.env['CI']) { - args.unshift('--no-sandbox', '--disable-gpu'); - } + const args = buildElectronLaunchArgs(options.args); const electronApp = await electron.launch({ args, @@ -333,6 +379,65 @@ function attachElectronProcessDiagnostics( }); } +/** + * Starts a raw second Electron process against an already-running instance's + * data directory and reports how it terminated. + * + * Deliberately not `launchElectronApp`: the expected outcome is that no window + * is ever created, because the single-instance guard hands the launch over to + * the running app. Two live instances would share a Chromium profile whose + * IndexedDB only one of them can lock, which is how settings silently stopped + * persisting (issues #102, #1156). + */ +export async function launchCompetingElectronInstance( + dataDir: string, + timeoutMs = 30000 +): Promise { + // In a Node context the `electron` package resolves to its binary path. + const electronBinaryPath = require('electron') as unknown as string; + const child = spawn(electronBinaryPath, buildElectronLaunchArgs(), { + env: { + ...process.env, + ELECTRON_IS_DEV: '0', + IPTVNATOR_E2E_DATA_DIR: dataDir, + NODE_ENV: 'test', + }, + stdio: ['ignore', 'ignore', 'pipe'], + }); + + // Kept for the assertion message: a competing launch that dies for an + // unrelated reason (missing sandbox, missing GPU) looks exactly like a + // refused one from the outside. + let stderr = ''; + child.stderr?.on('data', (chunk: Buffer) => { + stderr = `${stderr}${chunk.toString()}`.slice( + -competingInstanceStderrLimit + ); + }); + + return new Promise((resolvePromise) => { + const timer = setTimeout(() => { + child.kill(); + resolvePromise({ + exitCode: null, + signal: null, + stderr, + timedOut: true, + }); + }, timeoutMs); + + child.once('exit', (code, signal) => { + clearTimeout(timer); + resolvePromise({ + exitCode: code, + signal, + stderr, + timedOut: false, + }); + }); + }); +} + export async function closeElectronApp( app: LaunchedElectronApp ): Promise { diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index c803ab8ed..7e974cfcf 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -10,6 +10,7 @@ import { expect, goToDashboard, importM3uPlaylistFromNativeDialog, + launchCompetingElectronInstance, launchElectronApp, LaunchedElectronApp, m3uFixturePath, @@ -145,6 +146,70 @@ test.describe('Electron Settings', () => { } }); + test('@settings @persistence @electron refuses a second instance so it cannot break settings storage', async ({ + dataDir, + }) => { + const runningApp = await launchElectronApp(dataDir); + + try { + const competing = await launchCompetingElectronInstance(dataDir); + + expect( + { + exitCode: competing.exitCode, + signal: competing.signal, + timedOut: competing.timedOut, + }, + competing.stderr + ).toEqual({ exitCode: 0, signal: null, timedOut: false }); + // The running instance keeps its window and its storage lock. + expect(runningApp.electronApp.windows().length).toBeGreaterThan(0); + await expect(runningApp.mainWindow.locator('body')).toBeVisible(); + } finally { + await closeElectronApp(runningApp); + } + + // The lock is released on exit, so a later launch starts normally. + const relaunch = await launchElectronApp(dataDir); + await closeElectronApp(relaunch); + }); + + test('@settings @electron re-opens a window when a second launch arrives with none open', async ({ + dataDir, + }) => { + // Only macOS keeps the process alive after its last window closes; + // elsewhere `window-all-closed` quits and the next launch is a plain + // cold start. + test.skip( + process.platform !== 'darwin', + 'macOS-only windowless-process behaviour' + ); + const runningApp = await launchElectronApp(dataDir); + + try { + await runningApp.electronApp.evaluate(({ BrowserWindow }) => { + for (const window of BrowserWindow.getAllWindows()) { + window.close(); + } + }); + await expect + .poll(() => runningApp.electronApp.windows().length) + .toBe(0); + + const recreatedWindow = + runningApp.electronApp.waitForEvent('window'); + const competing = await launchCompetingElectronInstance(dataDir); + + expect(competing.timedOut).toBe(false); + expect(competing.exitCode).toBe(0); + // Without this the guard would quit the launch into nothing and + // leave the user staring at no window at all. + await expect((await recreatedWindow).locator('body')).toBeVisible(); + } finally { + await closeElectronApp(runningApp); + } + }); + test('@settings @persistence @electron persists changed desktop settings across app restart', async ({ dataDir, }) => { diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 173c59d60..2a663d941 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -59,6 +59,7 @@ type MockMainWindow = { getNormalBounds: jest.Mock; // Read once by attachWindowStateEvents to seed the tracked window // state; only reached off macOS, where the custom controls exist. + isDestroyed: jest.Mock; isFullScreen: jest.Mock; isMaximized: jest.Mock; loadFile: jest.Mock, [string]>; @@ -84,6 +85,7 @@ function createMockMainWindow(): MockMainWindow { loadURL: jest.fn, [string]>().mockResolvedValue(), on: jest.fn void]>(), once: jest.fn void]>(), + isDestroyed: jest.fn().mockReturnValue(false), setMenu: jest.fn(), show: jest.fn(), webContents: { @@ -97,6 +99,7 @@ function createMockMainWindow(): MockMainWindow { type AppInternals = { loadedMainWindow: MockMainWindow | null; mainWindow: MockMainWindow | null; + mainWindowListeners: Array<(mainWindow: MockMainWindow) => void>; mainWindowLoadPromise: Promise | null; onReady: () => void; rendererLoadingEnabled: boolean; @@ -120,6 +123,7 @@ describe('Electron app security helpers', () => { appInternals.mainWindow = null; appInternals.mainWindowLoadPromise = null; appInternals.rendererLoadingEnabled = false; + appInternals.mainWindowListeners.length = 0; (electronApp as unknown as { isPackaged: boolean }).isPackaged = false; (screen.getPrimaryDisplay as jest.Mock).mockReturnValue({ workAreaSize: { height: 720, width: 1280 }, @@ -322,6 +326,44 @@ describe('Electron app security helpers', () => { expect(mainWindow.loadFile).not.toHaveBeenCalled(); }); + it('runs a main-window listener immediately when a window already exists', () => { + const mainWindow = createMockMainWindow(); + (BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow); + (electronApp.isReady as jest.Mock).mockReturnValue(true); + App.main(electronApp, BrowserWindow); + const listener = jest.fn(); + + App.onMainWindowCreated( + listener as unknown as (mainWindow: Electron.BrowserWindow) => void + ); + + expect(listener).toHaveBeenCalledWith(mainWindow); + }); + + // macOS keeps the process alive without windows, so anything caching the + // window (download-broadcast) must be handed the rebuilt one. + it('re-runs main-window listeners when the window is rebuilt', () => { + const firstWindow = createMockMainWindow(); + (BrowserWindow as unknown as jest.Mock).mockReturnValue(firstWindow); + (electronApp.isReady as jest.Mock).mockReturnValue(true); + App.main(electronApp, BrowserWindow); + const listener = jest.fn(); + App.onMainWindowCreated( + listener as unknown as (mainWindow: Electron.BrowserWindow) => void + ); + listener.mockClear(); + + // Simulate the macOS 'closed' handler clearing the reference. + getAppInternals().mainWindow = null; + const secondWindow = createMockMainWindow(); + (BrowserWindow as unknown as jest.Mock).mockReturnValue(secondWindow); + + App.ensureMainWindow(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(secondWindow); + }); + it('clears only service worker registrations and cache storage', async () => { await clearElectronServiceWorkerStorage(); diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts index 7aef4a9ae..77a63d2d7 100644 --- a/apps/electron-backend/src/app/app.ts +++ b/apps/electron-backend/src/app/app.ts @@ -216,6 +216,9 @@ export default class App { // Keep a global reference of the window object, if you don't, the window will // be closed automatically when the JavaScript object is garbage collected. static mainWindow: Electron.BrowserWindow | null = null; + private static readonly mainWindowListeners: Array< + (mainWindow: Electron.BrowserWindow) => void + > = []; static application: Electron.App; static BrowserWindow; private static loadedMainWindow: Electron.BrowserWindow | null = null; @@ -268,9 +271,46 @@ export default class App { } } - private static onActivate() { - // On macOS it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. + /** + * Registers a listener that needs the current main window, and re-runs it + * whenever a new one is created. + * + * The main window is not created once per process: on macOS the window can + * be closed and rebuilt (dock `activate`, or a second launch handed over by + * the single-instance guard) while the process lives on. Anything that + * caches the window — `download-broadcast`'s module-level reference, for + * one — would otherwise keep pointing at a destroyed window and silently + * stop delivering to the renderer. Fires immediately when a window already + * exists, so callers registering after startup do not miss the first one. + */ + static onMainWindowCreated( + listener: (mainWindow: Electron.BrowserWindow) => void + ): void { + App.mainWindowListeners.push(listener); + + if (App.mainWindow && !App.mainWindow.isDestroyed()) { + listener(App.mainWindow); + } + } + + private static notifyMainWindowCreated( + mainWindow: Electron.BrowserWindow + ): void { + for (const listener of App.mainWindowListeners) { + listener(mainWindow); + } + } + + /** + * Brings the app back to a windowed state, re-creating the main window if + * it is gone. + * + * On macOS closing the last window deliberately keeps the process alive + * (`onWindowAllClosed`), so this is the recovery path for both the dock + * `activate` event and a second launch that the single-instance guard + * hands over to this process. + */ + static ensureMainWindow() { if (App.mainWindow === null) { App.onReady(); } @@ -279,6 +319,12 @@ export default class App { } } + private static onActivate() { + // On macOS it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + App.ensureMainWindow(); + } + private static handleRendererNavigation( event: Electron.Event, url: string @@ -383,6 +429,7 @@ export default class App { App.mainWindow.setMenu(null); attachWindowTrace(App.mainWindow); App.attachWindowStateEvents(App.mainWindow); + App.notifyMainWindowCreated(App.mainWindow); if (!savedWindowBounds) { App.mainWindow.center(); } diff --git a/apps/electron-backend/src/app/services/single-instance.spec.ts b/apps/electron-backend/src/app/services/single-instance.spec.ts new file mode 100644 index 000000000..6baec2e37 --- /dev/null +++ b/apps/electron-backend/src/app/services/single-instance.spec.ts @@ -0,0 +1,163 @@ +import { + ALLOW_MULTIPLE_INSTANCES_ENV, + acquireSingleInstanceLock, + allowsMultipleInstances, + focusExistingWindow, + type SingleInstanceApp, + type SingleInstanceWindow, +} from './single-instance'; + +function createApp(hasLock: boolean): jest.Mocked { + return { + quit: jest.fn(), + on: jest.fn(), + requestSingleInstanceLock: jest.fn().mockReturnValue(hasLock), + }; +} + +function createWindow( + overrides: Partial = {} +): jest.Mocked { + return { + focus: jest.fn(), + isDestroyed: jest.fn().mockReturnValue(false), + isMinimized: jest.fn().mockReturnValue(false), + isVisible: jest.fn().mockReturnValue(true), + restore: jest.fn(), + show: jest.fn(), + ...overrides, + } as jest.Mocked; +} + +describe('allowsMultipleInstances', () => { + it.each(['1', 'true', 'YES', ' on '])( + 'treats %p as an opt-in', + (value) => { + expect( + allowsMultipleInstances({ + [ALLOW_MULTIPLE_INSTANCES_ENV]: value, + }) + ).toBe(true); + } + ); + + it.each([undefined, '', '0', 'false', 'nope'])( + 'treats %p as opted out', + (value) => { + expect( + allowsMultipleInstances( + value === undefined + ? {} + : { [ALLOW_MULTIPLE_INSTANCES_ENV]: value } + ) + ).toBe(false); + } + ); +}); + +describe('acquireSingleInstanceLock', () => { + it('continues startup and registers the focus handler when the lock is free', () => { + const app = createApp(true); + + expect( + acquireSingleInstanceLock(app, () => null, jest.fn(), {}) + ).toBe(true); + expect(app.quit).not.toHaveBeenCalled(); + expect(app.on).toHaveBeenCalledWith( + 'second-instance', + expect.any(Function) + ); + }); + + it('quits and stops startup when another instance owns the profile', () => { + const app = createApp(false); + + expect( + acquireSingleInstanceLock(app, () => null, jest.fn(), {}) + ).toBe(false); + expect(app.quit).toHaveBeenCalledTimes(1); + expect(app.on).not.toHaveBeenCalled(); + }); + + it('focuses the existing window when a second instance is launched', () => { + const app = createApp(true); + const window = createWindow({ + isMinimized: jest.fn().mockReturnValue(true), + isVisible: jest.fn().mockReturnValue(false), + }); + + const createMainWindow = jest.fn(); + acquireSingleInstanceLock(app, () => window, createMainWindow, {}); + const [, handler] = app.on.mock.calls[0]; + (handler as () => void)(); + + expect(window.restore).toHaveBeenCalledTimes(1); + expect(window.show).toHaveBeenCalledTimes(1); + expect(window.focus).toHaveBeenCalledTimes(1); + expect(createMainWindow).not.toHaveBeenCalled(); + }); + + // macOS keeps the process alive after the last window closes, so a second + // launch must rebuild a window instead of quitting into nothing. + it.each([ + ['no window exists', null], + ['the window was destroyed', 'destroyed'], + ])('re-creates the main window when %s', (_label, windowState) => { + const app = createApp(true); + const window = + windowState === 'destroyed' + ? createWindow({ + isDestroyed: jest.fn().mockReturnValue(true), + }) + : null; + const createMainWindow = jest.fn(); + + acquireSingleInstanceLock(app, () => window, createMainWindow, {}); + const [, handler] = app.on.mock.calls[0]; + (handler as () => void)(); + + expect(createMainWindow).toHaveBeenCalledTimes(1); + if (window) { + expect(window.focus).not.toHaveBeenCalled(); + } + }); + + it('skips the lock entirely when multiple instances are allowed', () => { + const app = createApp(false); + + expect( + acquireSingleInstanceLock(app, () => null, jest.fn(), { + [ALLOW_MULTIPLE_INSTANCES_ENV]: '1', + }) + ).toBe(true); + expect(app.requestSingleInstanceLock).not.toHaveBeenCalled(); + expect(app.quit).not.toHaveBeenCalled(); + }); +}); + +describe('focusExistingWindow', () => { + it('does nothing without a window', () => { + expect(() => focusExistingWindow(null)).not.toThrow(); + }); + + it('does not touch a destroyed window', () => { + const window = createWindow({ + isDestroyed: jest.fn().mockReturnValue(true), + }); + + focusExistingWindow(window); + + expect(window.focus).not.toHaveBeenCalled(); + expect(window.restore).not.toHaveBeenCalled(); + }); + + it('only focuses an already visible window', () => { + const window = createWindow(); + + focusExistingWindow(window); + + expect(window.restore).not.toHaveBeenCalled(); + expect(window.show).not.toHaveBeenCalled(); + expect(window.focus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/services/single-instance.ts b/apps/electron-backend/src/app/services/single-instance.ts new file mode 100644 index 000000000..8186ed2ce --- /dev/null +++ b/apps/electron-backend/src/app/services/single-instance.ts @@ -0,0 +1,114 @@ +/** + * Single-instance guard. + * + * Every Electron instance that shares a `userData` directory also shares the + * Chromium storage profile behind it. Only the first process gets the + * IndexedDB LevelDB lock, so a second concurrent instance silently loses + * renderer-side persistence: the settings store falls back to defaults on read + * and its writes never reach disk (issues #102, #1156). Holding a single + * instance lock is the fix — a second launch focuses the window that already + * owns the profile instead of opening a broken twin. + * + * The lock is scoped to `userData`, so it must be requested *after* + * `app.setPath('userData', ...)`; otherwise E2E runs with a dedicated data + * directory would contend on the default profile's lock instead of their own. + */ + +/** Minimal Electron `App` surface used by the guard, kept narrow for tests. */ +export interface SingleInstanceApp { + quit(): void; + on(event: 'second-instance', listener: () => void): unknown; + requestSingleInstanceLock(): boolean; +} + +/** Minimal `BrowserWindow` surface used when re-focusing the existing window. */ +export interface SingleInstanceWindow { + focus(): void; + isDestroyed(): boolean; + isMinimized(): boolean; + isVisible(): boolean; + restore(): void; + show(): void; +} + +export const ALLOW_MULTIPLE_INSTANCES_ENV = 'IPTVNATOR_ALLOW_MULTIPLE_INSTANCES'; + +const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']); + +/** + * Escape hatch for local debugging (e.g. attaching a second CDP-enabled + * instance to the same database). Off by default so packaged users always get + * the guard. + */ +export function allowsMultipleInstances( + env: NodeJS.ProcessEnv = process.env +): boolean { + return TRUTHY_VALUES.has( + (env[ALLOW_MULTIPLE_INSTANCES_ENV] ?? '').trim().toLowerCase() + ); +} + +/** Brings an existing main window back to the foreground. */ +export function focusExistingWindow( + window: SingleInstanceWindow | null | undefined +): void { + if (!window || window.isDestroyed()) { + return; + } + + if (window.isMinimized()) { + window.restore(); + } + + if (!window.isVisible()) { + window.show(); + } + + window.focus(); +} + +/** True when there is no live window left to bring forward. */ +function needsNewWindow( + window: SingleInstanceWindow | null | undefined +): boolean { + return !window || window.isDestroyed(); +} + +/** + * Acquires the single instance lock. + * + * @param createMainWindow re-creates the main window when the lock owner has + * none left. On macOS closing the last window keeps the process alive, so + * without this a second launch would quit silently and leave the user with no + * window at all. + * @returns `true` when this process owns the profile and startup may continue, + * `false` when another instance already owns it and this one is quitting. + */ +export function acquireSingleInstanceLock( + app: SingleInstanceApp, + getMainWindow: () => SingleInstanceWindow | null | undefined, + createMainWindow: () => void, + env: NodeJS.ProcessEnv = process.env +): boolean { + if (allowsMultipleInstances(env)) { + return true; + } + + if (!app.requestSingleInstanceLock()) { + app.quit(); + return false; + } + + app.on('second-instance', () => { + const mainWindow = getMainWindow(); + + if (needsNewWindow(mainWindow)) { + createMainWindow(); + return; + } + + focusExistingWindow(mainWindow); + }); + + return true; +} diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index ea9b08923..b745ad244 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -37,6 +37,7 @@ import { } from './app/services/embedded-mpv-frame-copy-platform.util'; import { isEmbeddedMpvFeatureEnabled } from './app/services/embedded-mpv-runtime-policy.util'; import { runEmbeddedMpvRuntimeDiagnosticOrContinue } from './app/services/embedded-mpv-runtime-diagnostic'; +import { acquireSingleInstanceLock } from './app/services/single-instance'; import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service'; app.setName('iptvnator'); @@ -148,10 +149,10 @@ export default class Main { EpgEvents.bootstrapEpgEvents(); RemoteControlEvents.bootstrapRemoteControlEvents(); - // Set main window for downloads and reset stale downloads - if (App.mainWindow) { - setDownloadsMainWindow(App.mainWindow); - } + // Keep the downloads broadcaster bound to the live window. macOS can + // rebuild the window while the process runs, and a stale reference + // silently swallows every DOWNLOADS_UPDATE_EVENT. + App.onMainWindowCreated(setDownloadsMainWindow); // Load the renderer only after IPC handlers are registered. On slower // Linux CI hosts the renderer can otherwise invoke Electron bridge IPC @@ -194,6 +195,20 @@ runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => { // handle setup events as quickly as possible Main.initialize(); + // A second instance sharing this userData directory cannot take the + // Chromium storage lock, so its renderer silently loses every settings + // write. Focus the window that already owns the profile instead — or + // re-create it, since on macOS the process outlives its last window. + if ( + !acquireSingleInstanceLock( + app, + () => App.mainWindow, + () => App.ensureMainWindow() + ) + ) { + return; + } + // bootstrap app Main.bootstrapApp(); diff --git a/apps/web/src/app/settings/settings-form.facade.ts b/apps/web/src/app/settings/settings-form.facade.ts index a1b4a31a1..445b8e215 100644 --- a/apps/web/src/app/settings/settings-form.facade.ts +++ b/apps/web/src/app/settings/settings-form.facade.ts @@ -10,6 +10,7 @@ import { Theme, } from '@iptvnator/shared/interfaces'; import { TranslateService } from '@ngx-translate/core'; +import { SettingsSnackbarService } from './settings-snackbar.service'; import { SettingsStore } from '../services/settings-store.service'; import { SettingsService } from '../services/settings.service'; import { @@ -33,6 +34,7 @@ export class SettingsFormFacade { private readonly formBuilder = inject(FormBuilder); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsService = inject(SettingsService); + private readonly settingsSnackbar = inject(SettingsSnackbarService); private readonly settingsStore = inject(SettingsStore); private readonly translate = inject(TranslateService); @@ -53,8 +55,14 @@ export class SettingsFormFacade { } /** Waits for the persisted settings to be available */ - loadSettings(): Promise { - return this.settingsStore.loadSettings(); + async loadSettings(): Promise { + await this.settingsStore.loadSettings(); + + if (this.settingsStore.storageFailure() === 'load') { + // The form is about to show defaults that are not the user's saved + // values — say so instead of letting them look genuine. + this.settingsSnackbar.storageFailure('load'); + } } /** @@ -97,7 +105,9 @@ export class SettingsFormFacade { } this.patchAndMarkDirty({ coverSize }, 'coverSize'); - void this.settingsStore.updateSettings({ coverSize }); + this.settingsStore.updateSettings({ coverSize }).catch(() => { + this.settingsSnackbar.storageFailure('save'); + }); } selectEpgViewMode(epgViewMode: EpgViewMode): void { @@ -106,7 +116,9 @@ export class SettingsFormFacade { } this.patchAndMarkDirty({ epgViewMode }, 'epgViewMode'); - void this.settingsStore.updateSettings({ epgViewMode }); + this.settingsStore.updateSettings({ epgViewMode }).catch(() => { + this.settingsSnackbar.storageFailure('save'); + }); } setRecordingFolder(recordingFolder: string): void { diff --git a/apps/web/src/app/settings/settings-snackbar.service.ts b/apps/web/src/app/settings/settings-snackbar.service.ts index 2b5a40549..1b1166d7b 100644 --- a/apps/web/src/app/settings/settings-snackbar.service.ts +++ b/apps/web/src/app/settings/settings-snackbar.service.ts @@ -1,9 +1,12 @@ import { inject, Injectable } from '@angular/core'; import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar'; +import { SettingsStorageFailure } from '@iptvnator/services'; +import { TranslateService } from '@ngx-translate/core'; @Injectable() export class SettingsSnackbarService { private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); open(message: string, config: MatSnackBarConfig = {}): void { this.snackBar.open(message, undefined, { @@ -14,4 +17,33 @@ export class SettingsSnackbarService { ...config, }); } + + /** + * Failures need more reading time than the 2s confirmation toast, and a + * dismiss action so the message can stay up until it is acknowledged. + */ + error(message: string, dismissLabel: string): void { + this.snackBar.open(message, dismissLabel, { + duration: 10000, + horizontalPosition: 'center', + verticalPosition: 'bottom', + panelClass: ['settings-snackbar', 'settings-snackbar--error'], + }); + } + + /** + * Reports a failed settings read/write. Both mean the same thing to the + * user — what is on screen will not survive a restart — so they get a + * dismissible warning instead of the silent fallback to defaults. + */ + storageFailure(failure: SettingsStorageFailure): void { + this.error( + this.translate.instant( + failure === 'save' + ? 'SETTINGS.SETTINGS_SAVE_FAILED' + : 'SETTINGS.SETTINGS_LOAD_FAILED' + ), + this.translate.instant('CLOSE') + ); + } } diff --git a/apps/web/src/app/settings/settings.component.storage-failure.spec.ts b/apps/web/src/app/settings/settings.component.storage-failure.spec.ts new file mode 100644 index 000000000..94fe3a9a6 --- /dev/null +++ b/apps/web/src/app/settings/settings.component.storage-failure.spec.ts @@ -0,0 +1,128 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { SettingsStore } from '../services/settings-store.service'; +import { SettingsComponent } from './settings.component'; +import { + configureSettingsComponentTestBed, + createElectronStub, + createEpgBridgeStub, + MatSnackBarStub, + MockSettingsStore, + stubSettingsSideEffects, +} from './test-stubs/settings-test-harness.stub'; + +/** Matches the snackbar config used by `SettingsSnackbarService.error`. */ +const ERROR_SNACKBAR_CONFIG = expect.objectContaining({ + panelClass: ['settings-snackbar', 'settings-snackbar--error'], +}); + +/** + * Assertions target the MatDialog the component actually calls: spying on the + * one returned by `TestBed.inject(MatDialog)` does not observe it. + */ +interface SettingsComponentPrivateTestApi { + matDialog: MatDialog; +} + +/** + * Settings live in the renderer's IndexedDB, and both halves of the round trip + * can fail while the UI keeps looking healthy: a failed read shows defaults as + * if they were saved, and a failed write is applied in memory so it survives + * until the next restart. These cover the user-visible reporting of both. + */ +describe('SettingsComponent storage failures', () => { + let component: SettingsComponent; + let fixture: ComponentFixture; + let settingsStore: MockSettingsStore; + let snackBar: MatSnackBarStub; + let epgBridge: Partial; + const originalElectron = window.electron; + const componentMatDialog = (): MatDialog => + (component as unknown as SettingsComponentPrivateTestApi).matDialog; + + beforeEach(waitForAsync(() => { + epgBridge = createEpgBridgeStub(); + configureSettingsComponentTestBed(epgBridge); + })); + + beforeEach(() => { + window.electron = createElectronStub(); + + fixture = TestBed.createComponent(SettingsComponent); + settingsStore = TestBed.inject( + SettingsStore + ) as unknown as MockSettingsStore; + snackBar = TestBed.inject(MatSnackBar) as unknown as MatSnackBarStub; + + component = fixture.componentInstance; + stubSettingsSideEffects(component); + fixture.detectChanges(); + }); + + afterEach(() => { + window.electron = originalElectron; + }); + + it('warns when the persisted settings could not be read', async () => { + settingsStore.storageFailure.set('load'); + + await component.ngOnInit(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.SETTINGS_LOAD_FAILED', + 'CLOSE', + ERROR_SNACKBAR_CONFIG + ); + }); + + it('warns and keeps the dialog open when the settings write fails', async () => { + settingsStore.updateSettings.mockRejectedValue( + new Error('storage unavailable') + ); + const closeAll = jest.spyOn(componentMatDialog(), 'closeAll'); + component.isDialog = true; + + component.onSubmit(); + await fixture.whenStable(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.SETTINGS_SAVE_FAILED', + 'CLOSE', + ERROR_SNACKBAR_CONFIG + ); + expect(closeAll).not.toHaveBeenCalled(); + }); + + it('closes the dialog only after the settings write succeeded', async () => { + settingsStore.updateSettings.mockResolvedValue(undefined); + const closeAll = jest.spyOn(componentMatDialog(), 'closeAll'); + jest.spyOn(component.epg, 'fetchConfiguredEpg').mockImplementation(); + component.isDialog = true; + + component.onSubmit(); + // save() resolves a tick after the store write: the callback and + // the Electron mirror run first, so the close lands on the next + // turn of the microtask queue. + await fixture.whenStable(); + await fixture.whenStable(); + + expect(closeAll).toHaveBeenCalledTimes(1); + }); + + it('warns when a section write fails without leaving an unhandled rejection', async () => { + settingsStore.updateSettings.mockRejectedValue( + new Error('storage unavailable') + ); + + component.form.selectCoverSize('large'); + await fixture.whenStable(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'SETTINGS.SETTINGS_SAVE_FAILED', + 'CLOSE', + ERROR_SNACKBAR_CONFIG + ); + }); +}); diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts index 5535b9c76..f93a24fac 100644 --- a/apps/web/src/app/settings/settings.component.ts +++ b/apps/web/src/app/settings/settings.component.ts @@ -199,11 +199,26 @@ export class SettingsComponent implements OnInit, OnDestroy { * the indexed db store */ onSubmit(): void { - void this.form.save(() => this.applyChangedSettings()); - - if (this.isDialog) { - this.matDialog.closeAll(); - } + this.form + .save(() => this.applyChangedSettings()) + .then(() => { + if (this.isDialog) { + this.matDialog.closeAll(); + } + }) + .catch(() => { + // The store already applied the change in memory, so without + // this the save looks successful until the next restart. The + // dialog stays open so it can be retried. + // + // The Electron-side pushes in SettingsFormFacade.save() stay in + // the success branch on purpose: main keeps its own copy of the + // player paths and remote-control state, and applying half the + // form while telling the user nothing was saved is worse than + // applying none of it. Once settings live in the main process + // (issue #1273) this split disappears. + this.settingsSnackbar.storageFailure('save'); + }); } /** diff --git a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts index 2a4013874..abac1e9bd 100644 --- a/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts +++ b/apps/web/src/app/settings/test-stubs/settings-test-harness.stub.ts @@ -49,6 +49,7 @@ import { MockModule, MockProvider } from 'ng-mocks'; import { NgxIndexedDBService } from 'ngx-indexed-db'; import { from, of } from 'rxjs'; import { ElectronServiceStub } from '../../services/electron.service.stub'; +import { SettingsStorageFailure } from '@iptvnator/services'; import { SettingsStore } from '../../services/settings-store.service'; import { SettingsService } from '../../services/settings.service'; import { SettingsComponent } from '../settings.component'; @@ -148,6 +149,8 @@ export class MockSettingsStore { updateSettings = jest.fn().mockResolvedValue(undefined); + storageFailure = signal(null); + // Helper method for tests to modify settings _setSettings(newSettings: Partial) { this._settings.set({ diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 9c2ef982f..1c99e218b 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "يتطلب النقر المزدوج على صفوف القنوات قبل بدء التشغيل عبر MPV أو VLC.", "LANGUAGE": "اللغة", "SETTINGS_SAVED": "نجاح! تم حفظ الإعدادات.", + "SETTINGS_SAVE_FAILED": "تعذّر حفظ الإعدادات. ستفقد تغييراتك عند إعادة تشغيل التطبيق.", + "SETTINGS_LOAD_FAILED": "تعذّر تحميل الإعدادات المحفوظة — يتم عرض القيم الافتراضية. تأكّد من عدم تشغيل نسخة أخرى من التطبيق.", "THEME": "السمة البصرية", "SHOW_CAPTIONS": "عرض الترجمات", "SHOW_DASHBOARD": "عرض لوحة التحكم", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 6f8a3ca06..10ee715e0 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "خاص دير دوبل كليك على صفوف القنوات قبل ما يبدا التشغيل بـ MPV ولا VLC.", "LANGUAGE": "اللغة", "SETTINGS_SAVED": "نجاح! الإعدادات تحفضو.", + "SETTINGS_SAVE_FAILED": "ماتحفضاتش الإعدادات. التبديلات ديالك غادي تضيع منين تعاود تشعل التطبيق.", + "SETTINGS_LOAD_FAILED": "ماتحملاتش الإعدادات المحفوظة — كنوريو القيم الافتراضية. تأكد بلي ماكايناش نسخة أخرى من التطبيق خدامة.", "THEME": "المظهر المرئي", "SHOW_CAPTIONS": "وري الترجمة", "SHOW_DASHBOARD": "وري لوحة التحكم", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 12ef0bfb5..16c89096b 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Патрабаваць падвойны націск па радках каналаў перад пачаткам прайгравання праз MPV або VLC.", "LANGUAGE": "Мова", "SETTINGS_SAVED": "Новыя налады былі паспяхова захаваны!", + "SETTINGS_SAVE_FAILED": "Не ўдалося захаваць налады. Змены будуць страчаны пасля перазапуску праграмы.", + "SETTINGS_LOAD_FAILED": "Не ўдалося загрузіць захаваныя налады — паказаны значэнні па змаўчанні. Пераканайцеся, што праграма не запушчана яшчэ раз.", "THEME": "Тэма афармлення", "SHOW_CAPTIONS": "Адлюстроўваць субтытры", "SHOW_DASHBOARD": "Паказаць панэль", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 8786a0736..79049e257 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Erfordert einen Doppelklick auf Kanalzeilen, bevor die Wiedergabe mit MPV oder VLC startet.", "LANGUAGE": "Sprache", "SETTINGS_SAVED": "Einstellungen wurden erfolgreich gespeichert!", + "SETTINGS_SAVE_FAILED": "Einstellungen konnten nicht gespeichert werden. Die Änderungen gehen beim Neustart der App verloren.", + "SETTINGS_LOAD_FAILED": "Gespeicherte Einstellungen konnten nicht geladen werden — es werden Standardwerte angezeigt. Stelle sicher, dass die App nicht bereits läuft.", "THEME": "Farbdesign", "SHOW_CAPTIONS": "Untertitel anzeigen", "SHOW_DASHBOARD": "Dashboard anzeigen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 8ca4913ce..ce7d8c585 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Απαιτεί διπλό κλικ στις γραμμές καναλιών πριν ξεκινήσει η αναπαραγωγή με MPV ή VLC.", "LANGUAGE": "Γλώσσα", "SETTINGS_SAVED": "Επιτυχία! Η διαμόρφωση αποθηκεύτηκε.", + "SETTINGS_SAVE_FAILED": "Δεν ήταν δυνατή η αποθήκευση των ρυθμίσεων. Οι αλλαγές σας θα χαθούν μετά την επανεκκίνηση της εφαρμογής.", + "SETTINGS_LOAD_FAILED": "Δεν ήταν δυνατή η φόρτωση των αποθηκευμένων ρυθμίσεων — εμφανίζονται οι προεπιλογές. Βεβαιωθείτε ότι δεν εκτελείται άλλο αντίγραφο της εφαρμογής.", "THEME": "Οπτικό θέμα", "SHOW_CAPTIONS": "Εμφάνιση υπότιτλων", "SHOW_DASHBOARD": "Εμφάνιση πίνακα ελέγχου", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 130b118e9..505219f7d 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Require a double-click on channel rows before MPV or VLC playback starts.", "LANGUAGE": "Language", "SETTINGS_SAVED": "Success! Configuration was saved.", + "SETTINGS_SAVE_FAILED": "Settings could not be saved. Your changes will be lost when the app is restarted.", + "SETTINGS_LOAD_FAILED": "Saved settings could not be loaded — showing defaults. Make sure no other copy of the app is running.", "THEME": "Visual theme", "SHOW_CAPTIONS": "Show subtitles", "SHOW_DASHBOARD": "Show dashboard", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 256f10042..5e20f9cb3 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Requiere doble clic en las filas de canales antes de iniciar la reproducción con MPV o VLC.", "LANGUAGE": "Idioma", "SETTINGS_SAVED": "¡Completado! La configuración se guardó.", + "SETTINGS_SAVE_FAILED": "No se pudo guardar la configuración. Los cambios se perderán al reiniciar la aplicación.", + "SETTINGS_LOAD_FAILED": "No se pudo cargar la configuración guardada: se muestran los valores predeterminados. Asegúrate de que no haya otra copia de la aplicación abierta.", "THEME": "Tema visual", "SHOW_CAPTIONS": "Mostrar subtítulos", "SHOW_DASHBOARD": "Mostrar panel", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index cb86e2daa..5998517d3 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Exiger un double-clic sur les lignes de chaînes avant de lancer la lecture avec MPV ou VLC.", "LANGUAGE": "Langue", "SETTINGS_SAVED": "La configuration a été enregistrée avec succès.", + "SETTINGS_SAVE_FAILED": "Les paramètres n'ont pas pu être enregistrés. Vos modifications seront perdues au redémarrage de l'application.", + "SETTINGS_LOAD_FAILED": "Les paramètres enregistrés n'ont pas pu être chargés — les valeurs par défaut sont affichées. Vérifiez qu'aucune autre copie de l'application n'est ouverte.", "THEME": "Thème", "SHOW_CAPTIONS": "Afficher les sous-titres", "SHOW_DASHBOARD": "Afficher le tableau de bord", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 83183c4a4..686784f15 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Az MPV- vagy VLC-lejátszás csak a csatornasorra történő dupla kattintással induljon el.", "LANGUAGE": "Nyelv", "SETTINGS_SAVED": "A beállítások mentése sikerült.", + "SETTINGS_SAVE_FAILED": "A beállítások mentése nem sikerült. A módosítások az alkalmazás újraindításakor elvesznek.", + "SETTINGS_LOAD_FAILED": "A mentett beállítások betöltése nem sikerült — az alapértelmezett értékek láthatók. Ellenőrizd, hogy nem fut-e az alkalmazás másik példánya.", "THEME": "Megjelenési téma", "SHOW_CAPTIONS": "Feliratok megjelenítése", "SHOW_DASHBOARD": "Vezérlőpult megjelenítése", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index a57ea5146..14522bbf9 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Richiede un doppio clic sulle righe dei canali prima di avviare la riproduzione con MPV o VLC.", "LANGUAGE": "Lingua", "SETTINGS_SAVED": "La configurazione è stata salvata.", + "SETTINGS_SAVE_FAILED": "Impossibile salvare le impostazioni. Le modifiche andranno perse al riavvio dell'applicazione.", + "SETTINGS_LOAD_FAILED": "Impossibile caricare le impostazioni salvate: vengono mostrati i valori predefiniti. Assicurati che non ci sia un'altra copia dell'app in esecuzione.", "THEME": "Tema visuale", "SHOW_CAPTIONS": "Visualizza sottotitoli", "SHOW_DASHBOARD": "Mostra dashboard", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index ccbdfeddb..ac1184b39 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "MPV または VLC の再生を開始する前に、チャンネル行をダブルクリックする必要があります。", "LANGUAGE": "言語", "SETTINGS_SAVED": "成功!設定が保存されました。", + "SETTINGS_SAVE_FAILED": "設定を保存できませんでした。アプリを再起動すると変更は失われます。", + "SETTINGS_LOAD_FAILED": "保存された設定を読み込めませんでした。既定値を表示しています。アプリが二重に起動していないか確認してください。", "THEME": "視覚テーマ", "SHOW_CAPTIONS": "字幕を表示", "SHOW_DASHBOARD": "ダッシュボードを表示", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 1c7351c29..ba5191362 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "MPV 또는 VLC 재생을 시작하기 전에 채널 행을 두 번 클릭하도록 합니다.", "LANGUAGE": "언어", "SETTINGS_SAVED": "설정이 저장되었습니다.", + "SETTINGS_SAVE_FAILED": "설정을 저장하지 못했습니다. 앱을 다시 시작하면 변경 사항이 사라집니다.", + "SETTINGS_LOAD_FAILED": "저장된 설정을 불러오지 못했습니다. 기본값을 표시합니다. 앱이 중복 실행되고 있지 않은지 확인하세요.", "THEME": "테마", "SHOW_CAPTIONS": "Show subtitles", "SHOW_DASHBOARD": "대시보드 표시", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index c28d9c7e3..7d2621ee5 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Vereist een dubbelklik op kanaalrijen voordat MPV- of VLC-weergave start.", "LANGUAGE": "Taal", "SETTINGS_SAVED": "Succes! De instellingen zijn opgeslagen.", + "SETTINGS_SAVE_FAILED": "De instellingen konden niet worden opgeslagen. Je wijzigingen gaan verloren bij het herstarten van de app.", + "SETTINGS_LOAD_FAILED": "De opgeslagen instellingen konden niet worden geladen — er worden standaardwaarden getoond. Controleer of de app niet al draait.", "THEME": "Visueel thema", "SHOW_CAPTIONS": "Ondertiteling weergeven", "SHOW_DASHBOARD": "Dashboard tonen", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 46f5db3c9..e3990a36f 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Wymagaj podwójnego kliknięcia wiersza kanału przed rozpoczęciem odtwarzania w MPV lub VLC.", "LANGUAGE": "Język", "SETTINGS_SAVED": "Sukces! Konfiguracja została zapisana.", + "SETTINGS_SAVE_FAILED": "Nie udało się zapisać ustawień. Zmiany zostaną utracone po ponownym uruchomieniu aplikacji.", + "SETTINGS_LOAD_FAILED": "Nie udało się wczytać zapisanych ustawień — wyświetlane są wartości domyślne. Upewnij się, że aplikacja nie jest już uruchomiona.", "THEME": "Motyw wizualny", "SHOW_CAPTIONS": "Pokaż napisy", "SHOW_DASHBOARD": "Pokaż pulpit", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index da02cce09..5ce03f2fa 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Exige um duplo clique nas linhas de canais antes de iniciar a reprodução no MPV ou VLC.", "LANGUAGE": "Idioma", "SETTINGS_SAVED": "Sucesso! A configuração foi salva.", + "SETTINGS_SAVE_FAILED": "Não foi possível guardar as definições. As alterações serão perdidas ao reiniciar a aplicação.", + "SETTINGS_LOAD_FAILED": "Não foi possível carregar as definições guardadas — a mostrar os valores predefinidos. Certifique-se de que a aplicação não está já em execução.", "THEME": "Tema visual", "SHOW_CAPTIONS": "Mostrar legendas", "SHOW_DASHBOARD": "Mostrar dashboard", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index c2eb29748..3ba76249e 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "Требовать двойной щелчок по строке канала перед запуском воспроизведения через MPV или VLC.", "LANGUAGE": "Язык", "SETTINGS_SAVED": "Новые настройки были успешно сохранены!", + "SETTINGS_SAVE_FAILED": "Не удалось сохранить настройки. Изменения будут потеряны после перезапуска приложения.", + "SETTINGS_LOAD_FAILED": "Не удалось загрузить сохранённые настройки — показаны значения по умолчанию. Убедитесь, что приложение не запущено ещё раз.", "THEME": "Тема оформления", "SHOW_CAPTIONS": "Отображать субтитры", "SHOW_DASHBOARD": "Показывать панель", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 5df212dc2..f82fa81cd 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "MPV veya VLC oynatımı başlamadan önce kanal satırlarına çift tıklanmasını gerektirir.", "LANGUAGE": "Dil", "SETTINGS_SAVED": "Başarılı! Yapılandırma kaydedildi.", + "SETTINGS_SAVE_FAILED": "Ayarlar kaydedilemedi. Değişiklikleriniz uygulama yeniden başlatıldığında kaybolacak.", + "SETTINGS_LOAD_FAILED": "Kaydedilmiş ayarlar yüklenemedi — varsayılanlar gösteriliyor. Uygulamanın başka bir kopyasının çalışmadığından emin olun.", "THEME": "Görünüm teması", "SHOW_CAPTIONS": "Altyazıları göster", "SHOW_DASHBOARD": "Panoyu göster", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 3f0dc3ac2..7f85bedbb 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "在 MPV 或 VLC 开始播放前,要求双击频道行。", "LANGUAGE": "语言", "SETTINGS_SAVED": "配置已成功保存", + "SETTINGS_SAVE_FAILED": "无法保存设置。重启应用后所做的更改将会丢失。", + "SETTINGS_LOAD_FAILED": "无法加载已保存的设置,正在显示默认值。请确认没有重复运行的应用实例。", "THEME": "主题", "SHOW_CAPTIONS": "显示字幕", "SHOW_DASHBOARD": "显示仪表盘", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 042b7c831..0fae55d20 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -290,6 +290,8 @@ "OPEN_EXTERNAL_PLAYER_ON_DOUBLE_CLICK_DESCRIPTION": "在 MPV 或 VLC 開始播放前,要求雙擊頻道列。", "LANGUAGE": "語言", "SETTINGS_SAVED": "成功!設定已儲存。", + "SETTINGS_SAVE_FAILED": "無法儲存設定。重新啟動應用程式後所做的變更將會遺失。", + "SETTINGS_LOAD_FAILED": "無法載入已儲存的設定,正在顯示預設值。請確認沒有重複執行的應用程式。", "THEME": "視覺主題", "SHOW_CAPTIONS": "顯示字幕", "SHOW_DASHBOARD": "顯示儀表板", diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 4e9fc76c3..9d1759c7f 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -237,6 +237,14 @@ textarea, margin-bottom: calc(76px + env(safe-area-inset-bottom, 0px)); } +// Failures (settings could not be saved/loaded) must not read as the usual +// neutral confirmation toast. +.mat-mdc-snack-bar-container.settings-snackbar--error { + --mdc-snackbar-container-color: var(--mat-sys-error-container); + --mdc-snackbar-supporting-text-color: var(--mat-sys-on-error-container); + --mat-snack-bar-button-color: var(--mat-sys-on-error-container); +} + // WCAG-friendly visually-hidden helper — content stays in the // accessibility tree and DOM query results but occupies no layout box. // Used where a visible heading would duplicate adjacent navigation diff --git a/libs/services/src/lib/settings-store.service.spec.ts b/libs/services/src/lib/settings-store.service.spec.ts index b3362edb7..80062db2d 100644 --- a/libs/services/src/lib/settings-store.service.spec.ts +++ b/libs/services/src/lib/settings-store.service.spec.ts @@ -287,3 +287,110 @@ describe('SettingsStore dashboard rail settings', () => { ); }); }); + +describe('SettingsStore storage failure reporting', () => { + let injector: Injector; + let storage: { + get: jest.Mock; + set: jest.Mock; + }; + let consoleError: jest.SpyInstance; + + beforeEach(() => { + storage = { + get: jest.fn(() => of(null)), + set: jest.fn(() => of(undefined)), + }; + consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + injector = Injector.create({ + providers: [ + SettingsStore, + { + provide: StorageMap, + useValue: storage, + }, + ], + }); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + it('reports no failure while storage works', async () => { + const store = injector.get(SettingsStore); + + await store.loadSettings(); + await store.updateSettings({ language: Language.FRENCH }); + + expect(store.storageFailure()).toBeNull(); + }); + + it('flags a failed initial load so defaults are not mistaken for saved values', async () => { + const failingSettings = new Subject | null>(); + storage.get.mockReturnValueOnce(failingSettings.asObservable()); + const store = injector.get(SettingsStore); + + const initialLoad = store.loadSettings(); + failingSettings.error(new Error('storage unavailable')); + await initialLoad; + + expect(store.storageFailure()).toBe('load'); + expect(store.getSettings().language).toBe(Language.ENGLISH); + }); + + it('flags a failed save and rethrows instead of silently keeping the in-memory change', async () => { + storage.set.mockImplementationOnce(() => { + throw new Error('quota exceeded'); + }); + const store = injector.get(SettingsStore); + await store.loadSettings(); + + await expect( + store.updateSettings({ language: Language.FRENCH }) + ).rejects.toThrow('quota exceeded'); + + expect(store.storageFailure()).toBe('save'); + // The in-memory patch still applied — that is exactly why the flag + // matters: the UI shows French but nothing reached disk. + expect(store.getSettings().language).toBe(Language.FRENCH); + }); + + it('clears the failure once a later save succeeds', async () => { + storage.set.mockImplementationOnce(() => { + throw new Error('quota exceeded'); + }); + const store = injector.get(SettingsStore); + await store.loadSettings(); + + await expect( + store.updateSettings({ language: Language.FRENCH }) + ).rejects.toThrow('quota exceeded'); + expect(store.storageFailure()).toBe('save'); + + await store.updateSettings({ language: Language.GERMAN }); + + expect(store.storageFailure()).toBeNull(); + }); + + it('clears a load failure once the retried load succeeds', async () => { + const failingSettings = new Subject | null>(); + storage.get + .mockReturnValueOnce(failingSettings.asObservable()) + .mockReturnValueOnce(of({ language: Language.FRENCH })); + const store = injector.get(SettingsStore); + + const initialLoad = store.loadSettings(); + failingSettings.error(new Error('storage unavailable')); + await initialLoad; + expect(store.storageFailure()).toBe('load'); + + await store.loadSettings(); + + expect(store.storageFailure()).toBeNull(); + expect(store.getSettings().language).toBe(Language.FRENCH); + }); +}); diff --git a/libs/services/src/lib/settings-store.service.ts b/libs/services/src/lib/settings-store.service.ts index 447db58a2..af65ae281 100644 --- a/libs/services/src/lib/settings-store.service.ts +++ b/libs/services/src/lib/settings-store.service.ts @@ -59,6 +59,23 @@ const DEFAULT_SETTINGS: Settings = { tmdb: DEFAULT_TMDB_SETTINGS, }; +/** + * Which half of the settings persistence round-trip failed, if any. + * + * Settings live in the renderer's IndexedDB, which can be unavailable for + * reasons the app cannot control (a second instance holding the Chromium + * storage lock, a corrupted profile, storage blocked by security software). + * Both failures used to be swallowed: `updateSettings` patches the in-memory + * state before persisting, so a failed write still looked applied until the + * next restart (issue #1156). Recording the failure lets the settings UI say + * so instead of pretending the change stuck. + */ +export type SettingsStorageFailure = 'load' | 'save'; + +interface SettingsStorageState { + storageFailure: SettingsStorageFailure | null; +} + let embeddedMpvPrepareScheduled = false; function scheduleEmbeddedMpvPrepare(): void { @@ -101,6 +118,7 @@ function scheduleEmbeddedMpvPrepare(): void { export const SettingsStore = signalStore( { providedIn: 'root' }, withState(DEFAULT_SETTINGS), + withState({ storageFailure: null }), withComputed((store) => ({ /** * Live EPG panel layout with the `'timeline'` default applied — the @@ -124,6 +142,7 @@ export const SettingsStore = signalStore( const stored = await firstValueFrom( storage.get(STORE_KEY.Settings) ); + patchState(store, { storageFailure: null }); if (stored) { const storedSettings = stored as Partial; patchState(store, { @@ -147,7 +166,10 @@ export const SettingsStore = signalStore( })().catch((error) => { settingsLoadPromise = undefined; console.error('Failed to load settings:', error); - // Keep default settings if loading fails + // Keep default settings if loading fails, but remember + // that they are defaults-by-failure rather than by choice + // so the settings UI can warn about it. + patchState(store, { storageFailure: 'load' }); }); return settingsLoadPromise; @@ -176,11 +198,15 @@ export const SettingsStore = signalStore( await firstValueFrom( storage.set(STORE_KEY.Settings, completeSettings) ); + patchState(store, { storageFailure: null }); if (completeSettings.player === VideoPlayer.EmbeddedMpv) { scheduleEmbeddedMpvPrepare(); } } catch (error) { console.error('Failed to save settings:', error); + // The in-memory patch above already applied, so without + // this flag the change looks saved until the next restart. + patchState(store, { storageFailure: 'save' }); throw error; } }, From 0e16e179bf45dafff47c6df9086c5e2a0853f957 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:22:09 +0200 Subject: [PATCH 10/12] chore(deps): bump github/codeql-action from 3 to 4 (#1282) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v3...v4) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index dfe88308a..7f87287f3 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -44,7 +44,7 @@ jobs: # commit checked out above (the modern default); JavaScript is # interpreted, so no build step is needed before analysis. - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -53,4 +53,4 @@ jobs: # queries: ./path/to/local/query, your-org/your-repo/queries@main - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 From 5e725a06f3b58f16928b54f87a8489ebd41d9c92 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:22:12 +0200 Subject: [PATCH 11/12] chore(deps): bump actions/deploy-pages from 4 to 5 (#1283) Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5. - [Release notes](https://github.com/actions/deploy-pages/releases) - [Commits](https://github.com/actions/deploy-pages/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/deploy-pages dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/deploy-website.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index aa235d701..47295f5d7 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -56,4 +56,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 From a2fafcfc08cc733f0ac20cb1f047d4b076f7f2dd Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:08:07 +0200 Subject: [PATCH 12/12] test(performance): add end-to-end Xtream benchmark harness (#1300) * docs(performance): plan Xtream benchmark * feat(xtream-mock-server): add deterministic 100k fixture * style(xtream-mock-server): apply repository formatting * fix(xtream-mock-server): harden performance fixture data * feat(xtream-mock-server): add performance control plane * docs(performance): correct Xtream capture plan * fix(xtream-mock-server): harden performance controls * fix(xtream-mock-server): harden control lifecycle * feat(performance): add Xtream preload markers * feat(performance): trace Xtream main phases * feat(performance): mark Xtream store publications * feat(performance): trace Xtream database phases * feat(performance): trace Xtream delete cancellation * feat(performance): capture Xtream phase attribution * feat(performance): mark Sources Xtream refresh * test(performance): define Xtream benchmark evidence contracts * test(performance): add Xtream benchmark runner * test(performance): surface failure evidence writes * test(performance): align database read clock * test(performance): preserve capture failure contracts --- AGENTS.md | 4 +- CLAUDE.md | 4 +- .../playwright.xtream-performance.config.ts | 34 + apps/electron-backend-e2e/project.json | 10 + .../src/electron-test-fixtures.ts | 97 +- .../database-worker-cancel-capture.spec.ts | 161 +++ .../database-worker-cancel-capture.ts | 295 ++++ ...tabase-worker-post-gc-finalization.spec.ts | 9 +- .../electron-launch-environment.spec.ts | 105 ++ ...-diagnostic-artifact-content-validation.ts | 8 + .../m3u-import-benchmark-support.ts | 5 +- .../m3u-import-renderer-artifacts.ts | 60 +- .../src/performance/m3u-import.benchmark.ts | 16 +- .../m3u-refresh-cancellation-contract.ts | 19 +- .../m3u-refresh-cancellation.benchmark.ts | 11 +- .../performance/m3u-refresh-main-capture.ts | 1257 +++++++++++++++-- .../m3u-refresh-renderer-capture.ts | 20 +- .../main-capture-sampling-scheduler.spec.ts | 81 ++ .../performance-phase-duration-envelope.ts | 18 + .../performance-phase-events.spec.ts | 63 + .../performance/performance-phase-events.ts | 101 ++ ...rformance-raw-artifact-permissions.spec.ts | 126 ++ .../performance-timeline-merge.spec.ts | 53 + .../performance/performance-timeline-merge.ts | 62 + .../worker-performance-phase-duration.spec.ts | 107 ++ .../worker-performance-phase-events.ts | 39 +- ...orker-request-performance-envelope.spec.ts | 77 +- .../worker-request-performance.spec.ts | 4 +- .../performance/worker-request-performance.ts | 9 +- .../xtream-account-inventory.spec.ts | 78 + .../xtream-account-phase-correlation.spec.ts | 196 +++ .../xtream-acquisition-duration.ts | 15 + .../performance/xtream-benchmark-artifacts.ts | 55 + .../xtream-benchmark-configuration.spec.ts | 387 +++++ .../xtream-benchmark-configuration.ts | 278 ++++ .../xtream-benchmark-contract.spec.ts | 361 +++++ .../performance/xtream-benchmark-contract.ts | 399 ++++++ .../xtream-benchmark-environment.spec.ts | 62 + .../xtream-benchmark-integrity.spec.ts | 274 ++++ .../xtream-benchmark-iteration-support.ts | 262 ++++ .../performance/xtream-benchmark-iteration.ts | 399 ++++++ .../xtream-benchmark-lifecycle.spec.ts | 250 ++++ .../performance/xtream-benchmark-lifecycle.ts | 93 ++ .../xtream-benchmark-manifest-schema.ts | 186 +++ .../xtream-benchmark-orchestrator.ts | 119 ++ .../xtream-benchmark-output-configuration.ts | 180 +++ ...am-benchmark-report-provenance.fixtures.ts | 178 +++ .../xtream-benchmark-report.fixtures.ts | 400 ++++++ .../xtream-benchmark-report.spec.ts | 351 +++++ .../performance/xtream-benchmark-report.ts | 387 +++++ .../xtream-benchmark-runtime.spec.ts | 52 + .../performance/xtream-benchmark-runtime.ts | 52 + .../xtream-benchmark-summary-evidence.ts | 112 ++ .../xtream-build-identity-schema.ts | 214 +++ .../performance/xtream-build-identity.spec.ts | 246 ++++ .../src/performance/xtream-build-identity.ts | 202 +++ .../xtream-cancellation-evidence-binding.ts | 258 ++++ .../xtream-cancellation-evidence-snapshot.ts | 115 ++ .../xtream-cancellation-evidence.fixture.ts | 283 ++++ .../xtream-catalog-database-queries.ts | 180 +++ .../xtream-catalog-database-reader.spec.ts | 365 +++++ .../xtream-catalog-database-reader.ts | 302 ++++ ...ream-catalog-database-verification.spec.ts | 361 +++++ .../xtream-catalog-database-verification.ts | 237 ++++ .../performance/xtream-control-client.spec.ts | 377 +++++ .../src/performance/xtream-control-client.ts | 273 ++++ ...tream-control-observation-contract.spec.ts | 223 +++ .../xtream-control-observation-contract.ts | 204 +++ .../xtream-control-state-schema.spec.ts | 167 +++ .../xtream-control-state-schema.ts | 314 ++++ .../xtream-delete-correlation.spec.ts | 141 ++ .../xtream-descriptor-integrity.spec.ts | 143 ++ .../xtream-diagnostic-artifacts.spec.ts | 392 +++++ .../xtream-diagnostic-artifacts.ts | 324 +++++ .../xtream-diagnostic-capture-schema.spec.ts | 61 + .../xtream-diagnostic-capture-schema.ts | 149 ++ .../xtream-diagnostic-evidence.spec.ts | 198 +++ .../performance/xtream-diagnostic-evidence.ts | 266 ++++ .../xtream-evidence-integrity.spec.ts | 321 +++++ .../src/performance/xtream-exact-schema.ts | 369 +++++ .../xtream-exclusive-artifact-writer.spec.ts | 88 ++ .../xtream-exclusive-artifact-writer.ts | 49 + .../xtream-filesystem-integrity.spec.ts | 154 ++ .../xtream-ipc-attribution-identity.spec.ts | 239 ++++ .../xtream-ipc-marker-events.model.ts | 67 + .../xtream-ipc-marker-events.spec.ts | 379 +++++ .../performance/xtream-ipc-marker-events.ts | 399 ++++++ .../performance/xtream-ipc-marker-protocol.ts | 333 +++++ .../xtream-ipc-producer-identity.ts | 96 ++ .../xtream-ipc-worker-identity-binding.ts | 78 + .../xtream-ipc-worker-identity.fixture.ts | 85 ++ ...-iteration-assembler-background-ui.spec.ts | 201 +++ ...eam-iteration-assembler-fixture-runtime.ts | 319 +++++ .../xtream-iteration-assembler.fixture.ts | 337 +++++ .../xtream-iteration-assembler.spec.ts | 330 +++++ .../performance/xtream-iteration-assembler.ts | 259 ++++ .../xtream-iteration-assembly-metrics.ts | 332 +++++ .../xtream-iteration-assembly-phases.ts | 376 +++++ .../xtream-iteration-assembly-scenarios.ts | 363 +++++ .../xtream-iteration-evidence-binding.spec.ts | 389 +++++ .../xtream-iteration-evidence-binding.ts | 154 ++ .../xtream-iteration-fixed-contracts.ts | 53 + .../xtream-iteration-semantics.spec.ts | 338 +++++ .../xtream-iteration-validity.spec.ts | 373 +++++ .../performance/xtream-iteration-validity.ts | 361 +++++ .../xtream-main-capture-api.spec.ts | 341 +++++ .../xtream-main-capture-integration.spec.ts | 235 +++ .../performance/xtream-main-lifecycle.spec.ts | 94 ++ .../src/performance/xtream-main-lifecycle.ts | 111 ++ .../xtream-main-phase-duration.spec.ts | 71 + .../xtream-main-phase-events.spec.ts | 378 +++++ .../xtream-main-settlement.spec.ts | 285 ++++ .../src/performance/xtream-main-settlement.ts | 114 ++ .../src/performance/xtream-memory-validity.ts | 13 + .../performance/xtream-own-array-snapshot.ts | 119 ++ .../xtream-persistence-integrity.spec.ts | 95 ++ .../xtream-persistence-schedule.ts | 16 + ...tream-phase-attribution-fixture-builder.ts | 189 +++ .../xtream-phase-attribution-topology.spec.ts | 147 ++ .../xtream-phase-attribution.fixtures.ts | 351 +++++ .../xtream-phase-attribution.spec.ts | 277 ++++ .../performance/xtream-phase-attribution.ts | 376 +++++ .../performance/xtream-phase-event-pairing.ts | 100 ++ .../src/performance/xtream-phase-inventory.ts | 285 ++++ .../xtream-phase-semantic-source.ts | 109 ++ .../xtream-phase-topology.fixtures.ts | 102 ++ .../performance/xtream-phase-topology.spec.ts | 341 +++++ .../src/performance/xtream-phase-topology.ts | 368 +++++ .../xtream-process-evidence.spec.ts | 110 ++ .../performance/xtream-process-evidence.ts | 99 ++ .../xtream-provider-phase-topology.ts | 372 +++++ .../xtream-raw-persistence-receipt.ts | 83 ++ .../xtream-refresh-route-phase-topology.ts | 122 ++ .../xtream-renderer-capture.spec.ts | 242 ++++ .../performance/xtream-renderer-capture.ts | 384 +++++ .../xtream-renderer-console-errors.spec.ts | 88 ++ .../xtream-renderer-console-errors.ts | 98 ++ .../xtream-renderer-probe-contract.ts | 43 + .../xtream-renderer-probe-control.ts | 288 ++++ .../performance/xtream-renderer-probe.spec.ts | 210 +++ .../src/performance/xtream-renderer-probe.ts | 409 ++++++ .../src/performance/xtream-safe-file-read.ts | 88 ++ .../xtream-safe-path-identifier.spec.ts | 94 ++ .../xtream-scenario-cancellation-observer.ts | 268 ++++ .../xtream-scenario-driver-wiring.spec.ts | 170 +++ .../xtream-scenario-driver.fake-page.ts | 131 ++ .../xtream-scenario-driver.spec.ts | 342 +++++ .../src/performance/xtream-scenario-driver.ts | 329 +++++ .../xtream-scenario-operation-observer.ts | 239 ++++ .../src/performance/xtream-scenario-ui.ts | 178 +++ .../performance/xtream-summary-contract.ts | 318 +++++ .../xtream-terminal-settlement.spec.ts | 167 +++ .../xtream-ui-action-probe.spec.ts | 201 +++ .../src/performance/xtream-ui-action-probe.ts | 359 +++++ .../xtream-worker-phase-events.spec.ts | 209 +++ .../performance/xtream-worker-phase-events.ts | 176 +++ .../xtream-worker-phase-evidence.fixture.ts | 235 +++ .../xtream-worker-phase-topology.ts | 99 ++ ...xtream-worker-request-evidence.fixtures.ts | 245 ++++ .../xtream-worker-request-evidence.ts | 382 +++++ .../xtream-worker-request-identity.ts | 118 ++ .../xtream-worker-request-inventory.spec.ts | 84 ++ .../xtream-worker-request-proxy.spec.ts | 43 + .../xtream-worker-request-proxy.ts | 71 + .../xtream-worker-request-snapshot.spec.ts | 92 ++ .../xtream-worker-request-snapshot.ts | 230 +++ .../src/xtream.performance.ts | 7 + .../main.preload.performance.test-helpers.ts | 6 + .../src/app/api/main.preload.ts | 41 +- ...reload.xtream-performance.contract.spec.ts | 187 +++ ...n.preload.xtream-performance.gates.spec.ts | 180 +++ ...in.preload.xtream-performance.spec-data.ts | 230 +++ ...reload-performance-capture.runtime.spec.ts | 127 ++ ...preload-performance-capture.safety.spec.ts | 230 +++ ...xtream-preload-performance-capture.spec.ts | 219 +++ .../api/xtream-preload-performance-capture.ts | 191 +++ .../xtream-preload-performance-metadata.ts | 190 +++ .../category.operations.performance.spec.ts | 110 ++ .../operations/category.operations.ts | 118 +- .../content-import-performance.spec.ts | 269 ++++ .../content-search-performance.spec.ts | 124 ++ .../database/operations/content.operations.ts | 189 ++- .../performance-phase-capture.test-helpers.ts | 68 + .../operations/performance-phase-capture.ts | 17 + .../playlist-delete-performance.spec.ts | 165 +++ .../operations/playlist.operations.ts | 112 +- .../xtream-content-operation-steps.ts | 117 ++ .../xtream-delete-performance.spec.ts | 217 +++ .../database/operations/xtream.operations.ts | 139 +- .../events/xtream-performance.runtime.spec.ts | 263 ++++ .../src/app/events/xtream-performance.spec.ts | 235 +++ .../src/app/events/xtream-performance.ts | 262 ++++ .../app/events/xtream-session-cancellation.ts | 26 + .../xtream.events.performance-error.spec.ts | 204 +++ .../events/xtream.events.performance.spec.ts | 254 ++++ .../xtream.events.performance.test-helpers.ts | 118 ++ .../src/app/events/xtream.events.spec.ts | 11 + .../src/app/events/xtream.events.ts | 80 +- .../services/database-worker-client.spec.ts | 34 + .../app/services/database-worker-client.ts | 8 +- .../database-worker-cancel-receipt.spec.ts | 120 ++ .../workers/database-worker-cancel-receipt.ts | 63 + .../src/app/workers/database-worker.types.ts | 17 +- .../src/app/workers/database.worker.ts | 66 +- .../worker-performance-cancellation.spec.ts | 185 +-- ...r-performance-cancellation.test-helpers.ts | 94 ++ .../worker-performance-capture.model.ts | 6 +- .../worker-performance-integration.spec.ts | 9 +- .../worker-performance-phase-adapter.spec.ts | 76 + .../app/workers/worker-performance-phase.ts | 35 +- .../worker-performance-xtream-wiring.spec.ts | 58 + apps/xtream-mock-server/README.md | 106 +- apps/xtream-mock-server/jest.config.ts | 13 + apps/xtream-mock-server/project.json | 10 +- apps/xtream-mock-server/src/app/data-store.ts | 165 ++- .../generators/performance.generator.spec.ts | 388 +++++ .../app/generators/performance.generator.ts | 294 ++++ .../performance-category-validation.spec.ts | 45 + .../src/app/performance-control-abort.spec.ts | 149 ++ .../app/performance-control-lifecycle.spec.ts | 83 ++ .../src/app/performance-control-routes.ts | 131 ++ .../app/performance-control-security.spec.ts | 129 ++ .../performance-control-validation.spec.ts | 341 +++++ .../src/app/performance-control-validation.ts | 185 +++ .../src/app/performance-control.spec.ts | 380 +++++ .../src/app/performance-control.ts | 299 ++++ .../src/app/performance-control.types.ts | 98 ++ .../app/performance-interception-lifecycle.ts | 45 + .../src/app/performance-manifest.ts | 56 + .../src/app/performance-occurrences.spec.ts | 108 ++ .../src/app/performance-occurrences.ts | 104 ++ .../src/app/project-config.spec.ts | 28 + apps/xtream-mock-server/src/app/scenarios.ts | 31 +- .../src/app/server-lifecycle.ts | 55 + .../src/app/server-shutdown.spec.ts | 160 +++ .../xtream-mock-server/src/app/server.spec.ts | 225 +++ apps/xtream-mock-server/src/app/server.ts | 274 ++++ .../src/app/testing/http-server.fixture.ts | 126 ++ apps/xtream-mock-server/src/main.ts | 258 +--- apps/xtream-mock-server/tsconfig.spec.json | 17 + docs/architecture/sqlite-db-worker.md | 57 + docs/architecture/xtream-mock-server.md | 271 +++- ...2026-07-27-xtream-performance-benchmark.md | 1042 ++++++++++++++ .../playlist-refresh-action.service.spec.ts | 52 +- .../lib/playlist-refresh-action.service.ts | 17 +- .../recent-playlists.component.spec.ts | 118 +- .../recent-playlists.component.ts | 38 +- .../features/with-content.feature.spec.ts | 162 ++- .../stores/features/with-content.feature.ts | 94 +- .../features/with-search.feature.spec.ts | 116 ++ .../stores/features/with-search.feature.ts | 17 +- libs/shared/interfaces/src/index.ts | 1 + .../src/lib/performance-phase.interface.ts | 31 +- .../xtream-performance-marker.interface.ts | 58 + .../lib/renderer-performance-phase.spec.ts | 30 +- .../src/lib/renderer-performance-phase.ts | 8 + 256 files changed, 43760 insertions(+), 883 deletions(-) create mode 100644 apps/electron-backend-e2e/playwright.xtream-performance.config.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-cancel-capture.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/database-worker-cancel-capture.ts create mode 100644 apps/electron-backend-e2e/src/performance/electron-launch-environment.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/main-capture-sampling-scheduler.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/performance-phase-duration-envelope.ts create mode 100644 apps/electron-backend-e2e/src/performance/performance-raw-artifact-permissions.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/performance-timeline-merge.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/performance-timeline-merge.ts create mode 100644 apps/electron-backend-e2e/src/performance/worker-performance-phase-duration.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-account-inventory.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-account-phase-correlation.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-acquisition-duration.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-artifacts.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-environment.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-integrity.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration-support.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-manifest-schema.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-orchestrator.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-output-configuration.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-report-provenance.fixtures.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-report.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-report.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-runtime.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-runtime.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-benchmark-summary-evidence.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-build-identity.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence-binding.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence-snapshot.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence.fixture.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-catalog-database-queries.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-catalog-database-reader.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-catalog-database-reader.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-catalog-database-verification.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-catalog-database-verification.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-control-client.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-control-client.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-control-observation-contract.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-control-observation-contract.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-control-state-schema.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-control-state-schema.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-delete-correlation.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-descriptor-integrity.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-diagnostic-capture-schema.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-diagnostic-capture-schema.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-diagnostic-evidence.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-diagnostic-evidence.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-evidence-integrity.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-exact-schema.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-exclusive-artifact-writer.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-exclusive-artifact-writer.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-filesystem-integrity.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-ipc-attribution-identity.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.model.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-ipc-marker-protocol.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-ipc-producer-identity.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-ipc-worker-identity-binding.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-ipc-worker-identity.fixture.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-iteration-assembler-background-ui.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-iteration-assembler-fixture-runtime.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.fixture.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-metrics.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-phases.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-scenarios.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-iteration-evidence-binding.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-iteration-evidence-binding.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-iteration-fixed-contracts.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-iteration-semantics.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-iteration-validity.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-iteration-validity.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-main-capture-api.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-main-capture-integration.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-main-lifecycle.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-main-lifecycle.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-main-phase-duration.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-main-phase-events.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-main-settlement.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-main-settlement.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-memory-validity.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-own-array-snapshot.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-persistence-integrity.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-persistence-schedule.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-phase-attribution-fixture-builder.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-phase-attribution-topology.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-phase-attribution.fixtures.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-phase-attribution.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-phase-attribution.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-phase-event-pairing.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-phase-inventory.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-phase-semantic-source.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-phase-topology.fixtures.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-phase-topology.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-phase-topology.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-process-evidence.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-process-evidence.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-provider-phase-topology.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-raw-persistence-receipt.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-refresh-route-phase-topology.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-renderer-capture.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-renderer-capture.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-renderer-console-errors.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-renderer-console-errors.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-renderer-probe-contract.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-renderer-probe-control.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-renderer-probe.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-safe-file-read.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-safe-path-identifier.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-scenario-cancellation-observer.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-scenario-driver.fake-page.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-scenario-operation-observer.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-summary-contract.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-terminal-settlement.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-worker-phase-evidence.fixture.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-worker-phase-topology.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-worker-request-evidence.fixtures.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-worker-request-evidence.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-worker-request-identity.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-worker-request-inventory.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-worker-request-proxy.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-worker-request-proxy.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-worker-request-snapshot.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/xtream-worker-request-snapshot.ts create mode 100644 apps/electron-backend-e2e/src/xtream.performance.ts create mode 100644 apps/electron-backend/src/app/api/main.preload.xtream-performance.contract.spec.ts create mode 100644 apps/electron-backend/src/app/api/main.preload.xtream-performance.gates.spec.ts create mode 100644 apps/electron-backend/src/app/api/main.preload.xtream-performance.spec-data.ts create mode 100644 apps/electron-backend/src/app/api/xtream-preload-performance-capture.runtime.spec.ts create mode 100644 apps/electron-backend/src/app/api/xtream-preload-performance-capture.safety.spec.ts create mode 100644 apps/electron-backend/src/app/api/xtream-preload-performance-capture.spec.ts create mode 100644 apps/electron-backend/src/app/api/xtream-preload-performance-capture.ts create mode 100644 apps/electron-backend/src/app/api/xtream-preload-performance-metadata.ts create mode 100644 apps/electron-backend/src/app/database/operations/category.operations.performance.spec.ts create mode 100644 apps/electron-backend/src/app/database/operations/content-import-performance.spec.ts create mode 100644 apps/electron-backend/src/app/database/operations/content-search-performance.spec.ts create mode 100644 apps/electron-backend/src/app/database/operations/performance-phase-capture.test-helpers.ts create mode 100644 apps/electron-backend/src/app/database/operations/performance-phase-capture.ts create mode 100644 apps/electron-backend/src/app/database/operations/playlist-delete-performance.spec.ts create mode 100644 apps/electron-backend/src/app/database/operations/xtream-content-operation-steps.ts create mode 100644 apps/electron-backend/src/app/database/operations/xtream-delete-performance.spec.ts create mode 100644 apps/electron-backend/src/app/events/xtream-performance.runtime.spec.ts create mode 100644 apps/electron-backend/src/app/events/xtream-performance.spec.ts create mode 100644 apps/electron-backend/src/app/events/xtream-performance.ts create mode 100644 apps/electron-backend/src/app/events/xtream-session-cancellation.ts create mode 100644 apps/electron-backend/src/app/events/xtream.events.performance-error.spec.ts create mode 100644 apps/electron-backend/src/app/events/xtream.events.performance.spec.ts create mode 100644 apps/electron-backend/src/app/events/xtream.events.performance.test-helpers.ts create mode 100644 apps/electron-backend/src/app/workers/database-worker-cancel-receipt.spec.ts create mode 100644 apps/electron-backend/src/app/workers/database-worker-cancel-receipt.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-cancellation.test-helpers.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-phase-adapter.spec.ts create mode 100644 apps/electron-backend/src/app/workers/worker-performance-xtream-wiring.spec.ts create mode 100644 apps/xtream-mock-server/jest.config.ts create mode 100644 apps/xtream-mock-server/src/app/generators/performance.generator.spec.ts create mode 100644 apps/xtream-mock-server/src/app/generators/performance.generator.ts create mode 100644 apps/xtream-mock-server/src/app/performance-category-validation.spec.ts create mode 100644 apps/xtream-mock-server/src/app/performance-control-abort.spec.ts create mode 100644 apps/xtream-mock-server/src/app/performance-control-lifecycle.spec.ts create mode 100644 apps/xtream-mock-server/src/app/performance-control-routes.ts create mode 100644 apps/xtream-mock-server/src/app/performance-control-security.spec.ts create mode 100644 apps/xtream-mock-server/src/app/performance-control-validation.spec.ts create mode 100644 apps/xtream-mock-server/src/app/performance-control-validation.ts create mode 100644 apps/xtream-mock-server/src/app/performance-control.spec.ts create mode 100644 apps/xtream-mock-server/src/app/performance-control.ts create mode 100644 apps/xtream-mock-server/src/app/performance-control.types.ts create mode 100644 apps/xtream-mock-server/src/app/performance-interception-lifecycle.ts create mode 100644 apps/xtream-mock-server/src/app/performance-manifest.ts create mode 100644 apps/xtream-mock-server/src/app/performance-occurrences.spec.ts create mode 100644 apps/xtream-mock-server/src/app/performance-occurrences.ts create mode 100644 apps/xtream-mock-server/src/app/project-config.spec.ts create mode 100644 apps/xtream-mock-server/src/app/server-lifecycle.ts create mode 100644 apps/xtream-mock-server/src/app/server-shutdown.spec.ts create mode 100644 apps/xtream-mock-server/src/app/server.spec.ts create mode 100644 apps/xtream-mock-server/src/app/server.ts create mode 100644 apps/xtream-mock-server/src/app/testing/http-server.fixture.ts create mode 100644 apps/xtream-mock-server/tsconfig.spec.json create mode 100644 docs/superpowers/plans/2026-07-27-xtream-performance-benchmark.md create mode 100644 libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts create mode 100644 libs/shared/interfaces/src/lib/xtream-performance-marker.interface.ts diff --git a/AGENTS.md b/AGENTS.md index 78001ad29..b4a8b5223 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,8 +87,8 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal - - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC request/completion markers plus count-only M3U acquire/parse/normalize and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset - - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization phase events, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset + - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset + - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset - Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching diff --git a/CLAUDE.md b/CLAUDE.md index f628770f3..5ffa4af4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,8 +142,8 @@ Useful narrower flags: - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal -- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted preload IPC request/completion markers plus count-only M3U acquire/parse/normalize and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset -- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization phase events, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset +- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset +- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching diff --git a/apps/electron-backend-e2e/playwright.xtream-performance.config.ts b/apps/electron-backend-e2e/playwright.xtream-performance.config.ts new file mode 100644 index 000000000..fcf8086e9 --- /dev/null +++ b/apps/electron-backend-e2e/playwright.xtream-performance.config.ts @@ -0,0 +1,34 @@ +import { randomBytes } from 'node:crypto'; + +import { workspaceRoot } from '@nx/devkit'; +import { defineConfig } from '@playwright/test'; + +const controlToken = + process.env['IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN'] ?? + randomBytes(32).toString('base64url'); +process.env['IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN'] = controlToken; + +export default defineConfig({ + fullyParallel: false, + reporter: [['list']], + retries: 0, + testDir: './src', + testMatch: 'xtream.performance.ts', + timeout: 30 * 60 * 1_000, + use: { + testIdAttribute: 'data-test-id', + }, + webServer: { + command: 'pnpm nx run xtream-mock-server:serve', + cwd: workspaceRoot, + env: { + HOST: '127.0.0.1', + IPTVNATOR_XTREAM_MOCK_CONTROL: '1', + IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: controlToken, + PORT: '3221', + }, + reuseExistingServer: false, + url: 'http://127.0.0.1:3221/health', + }, + workers: 1, +}); diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index ee68cd410..cdf610087 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -39,6 +39,16 @@ "command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-import.performance.ts" } }, + "benchmark-xtream": { + "dependsOn": ["electron-backend:build-performance"], + "executor": "nx:run-commands", + "cache": false, + "parallelism": false, + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec playwright test --config=playwright.xtream-performance.config.ts src/xtream.performance.ts" + } + }, "packaged-frame-copy-smoke": { "dependsOn": ["test-packaged-frame-copy-fixtures"], "executor": "nx:run-commands", diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 87ab9351c..12424acd7 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -77,7 +77,9 @@ type ElectronFixtures = { export type LaunchElectronAppOptions = { args?: readonly string[]; + environmentInheritance?: 'all' | 'runtime-only'; env?: Record; + omitEnvKeys?: readonly string[]; }; export type PortalDebugEvent = { @@ -197,16 +199,7 @@ export async function launchElectronApp( const electronApp = await electron.launch({ args, - env: { - ...process.env, - // Electron E2E uses local mock HTTP servers for playlists, portals, and EPG sources. - IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: - process.env['IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'] ?? '1', - ...options.env, - ELECTRON_IS_DEV: '0', - IPTVNATOR_E2E_DATA_DIR: dataDir, - NODE_ENV: 'test', - }, + env: buildElectronLaunchEnvironment(dataDir, options), }); attachElectronProcessDiagnostics(electronApp); @@ -222,6 +215,86 @@ export async function launchElectronApp( }; } +export function buildElectronLaunchEnvironment( + dataDir: string, + options: LaunchElectronAppOptions = {}, + inheritedEnvironment: NodeJS.ProcessEnv = process.env +): Record { + if ( + options.environmentInheritance !== undefined && + options.environmentInheritance !== 'all' && + options.environmentInheritance !== 'runtime-only' + ) { + throw new Error('Electron environment inheritance mode is invalid'); + } + const runtimeOnly = options.environmentInheritance === 'runtime-only'; + const environment: Record = { + ...(runtimeOnly + ? selectElectronRuntimeEnvironment(inheritedEnvironment) + : inheritedEnvironment), + // Electron E2E uses local mock HTTP servers for playlists, portals, and EPG sources. + IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: runtimeOnly + ? '1' + : (inheritedEnvironment['IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'] ?? + '1'), + ...options.env, + ELECTRON_IS_DEV: '0', + IPTVNATOR_E2E_DATA_DIR: dataDir, + NODE_ENV: 'test', + }; + for (const key of options.omitEnvKeys ?? []) { + delete environment[key]; + } + return Object.fromEntries( + Object.entries(environment).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string' + ) + ); +} + +export const ELECTRON_RUNTIME_ENVIRONMENT_KEYS = Object.freeze([ + 'APPDATA', + 'COMSPEC', + 'DBUS_SESSION_BUS_ADDRESS', + 'DISPLAY', + 'HOME', + 'LANG', + 'LANGUAGE', + 'LC_ALL', + 'LC_CTYPE', + 'LOCALAPPDATA', + 'LOGNAME', + 'PATH', + 'PATHEXT', + 'SYSTEMROOT', + 'TEMP', + 'TMP', + 'TMPDIR', + 'USER', + 'USERPROFILE', + 'WAYLAND_DISPLAY', + 'WINDIR', + 'XAUTHORITY', + 'XDG_RUNTIME_DIR', + 'XDG_SESSION_TYPE', + '__CF_USER_TEXT_ENCODING', +] as const); + +function selectElectronRuntimeEnvironment( + inheritedEnvironment: NodeJS.ProcessEnv +): Record { + const selected: Record = {}; + const allowed = new Set( + ELECTRON_RUNTIME_ENVIRONMENT_KEYS.map((key) => key.toUpperCase()) + ); + for (const [key, value] of Object.entries(inheritedEnvironment)) { + if (typeof value === 'string' && allowed.has(key.toUpperCase())) { + selected[key] = value; + } + } + return selected; +} + /** * Resolve the x64 unpacked Linux executable produced by electron-builder. * An explicit path wins so CI can point at an AppImage/Flatpak extraction @@ -1703,9 +1776,7 @@ export async function expectWorkspaceSearchStatus( } /** The degraded-search hint chip must be absent (e.g. complete local search). */ -export async function expectNoWorkspaceSearchStatus( - page: Page -): Promise { +export async function expectNoWorkspaceSearchStatus(page: Page): Promise { await expect( page.locator('app-workspace-shell-header .search-chip--status') ).toHaveCount(0); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-cancel-capture.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-cancel-capture.spec.ts new file mode 100644 index 000000000..88cf4ca91 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-cancel-capture.spec.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { createDatabaseWorkerCancelCapture } from './database-worker-cancel-capture'; + +function dispatch(overrides: Record = {}) { + return { + captureGeneration: 3, + ipcCallId: 9, + operationId: 'operation-1', + postedEpochMs: 110, + requestId: 'request-1', + sourceEpochMs: 100, + ...overrides, + }; +} + +function receipt(overrides: Record = {}) { + return { + type: 'performance-cancel-received', + epochMs: 120, + operationId: 'operation-1', + requestId: 'request-1', + ...overrides, + }; +} + +describe('database worker cancel capture', () => { + it('restores without module closures for Electron-main injection', () => { + const source = createDatabaseWorkerCancelCapture.toString(); + assert.doesNotMatch(source, /__name/); + const factory = new Function( + `"use strict"; return (${source});` + )() as typeof createDatabaseWorkerCancelCapture; + factory().start(1); + }); + + it('keeps dispatch, worker receipt, and terminal cancellation distinct', () => { + const capture = createDatabaseWorkerCancelCapture(); + capture.start(3); + assert.equal(capture.acceptDispatch(dispatch()), true); + assert.equal( + capture.acceptReceipt({ + captureGeneration: 3, + message: receipt(), + receivedEpochMs: 121, + recordGeneration: 3, + }), + true + ); + assert.equal( + capture.acceptTerminal({ + captureGeneration: 3, + operationId: 'operation-1', + receivedEpochMs: 130, + recordGeneration: 3, + requestId: 'request-1', + }), + true + ); + + const snapshot = capture.stop(); + assert.deepEqual(snapshot.invalidReasons, []); + assert.deepEqual( + snapshot.timeline.map(({ epochMs, type }) => ({ epochMs, type })), + [ + { epochMs: 110, type: 'db-cancel-dispatched' }, + { epochMs: 120, type: 'db-cancel-received' }, + { epochMs: 130, type: 'db-cancel-terminal-received' }, + ] + ); + }); + + it('rejects extra fields, non-monotonic epochs, and cross-generation receipts', () => { + for (const testCase of [ + { + message: receipt({ credential: 'forbidden' }), + receivedEpochMs: 121, + recordGeneration: 3, + }, + { + message: receipt({ epochMs: 109 }), + receivedEpochMs: 121, + recordGeneration: 3, + }, + { + message: receipt({ epochMs: 122 }), + receivedEpochMs: 121, + recordGeneration: 3, + }, + { + message: receipt(), + receivedEpochMs: 121, + recordGeneration: 2, + }, + ]) { + const capture = createDatabaseWorkerCancelCapture(); + capture.start(3); + assert.equal(capture.acceptDispatch(dispatch()), true); + assert.equal( + capture.acceptReceipt({ + captureGeneration: 3, + ...testCase, + }), + false + ); + assert.notEqual(capture.stop().invalidReasons.length, 0); + } + }); + + it('invalidates a missing receipt separately from a missing terminal', () => { + const missingReceipt = createDatabaseWorkerCancelCapture(); + missingReceipt.start(3); + assert.equal(missingReceipt.acceptDispatch(dispatch()), true); + assert.deepEqual(missingReceipt.stop().invalidReasons, [ + 'cancel-receipt-missing', + 'cancel-terminal-missing', + ]); + + const missingTerminal = createDatabaseWorkerCancelCapture(); + missingTerminal.start(3); + assert.equal(missingTerminal.acceptDispatch(dispatch()), true); + assert.equal( + missingTerminal.acceptReceipt({ + captureGeneration: 3, + message: receipt(), + receivedEpochMs: 121, + recordGeneration: 3, + }), + true + ); + assert.deepEqual(missingTerminal.stop().invalidReasons, [ + 'cancel-terminal-missing', + ]); + }); + + it('fails neutrally for revoked or throwing proxy input', () => { + const capture = createDatabaseWorkerCancelCapture(); + capture.start(3); + const revoked = Proxy.revocable({}, {}); + revoked.revoke(); + const throwing = new Proxy( + {}, + { + get() { + throw new Error('property access denied'); + }, + ownKeys() { + throw new Error('keys denied'); + }, + } + ); + + assert.doesNotThrow(() => capture.acceptDispatch(revoked.proxy)); + assert.equal(capture.acceptDispatch(revoked.proxy), false); + assert.doesNotThrow(() => capture.acceptReceipt(throwing as never)); + assert.equal(capture.acceptReceipt(throwing as never), false); + assert.doesNotThrow(() => capture.acceptTerminal(throwing as never)); + assert.equal(capture.acceptTerminal(throwing as never), false); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/database-worker-cancel-capture.ts b/apps/electron-backend-e2e/src/performance/database-worker-cancel-capture.ts new file mode 100644 index 000000000..0ae253e53 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-worker-cancel-capture.ts @@ -0,0 +1,295 @@ +export interface DatabaseWorkerCancelTimelineRecord { + readonly epochMs: number; + readonly ipcCallId: number; + readonly operationId: string; + readonly requestId: string; + readonly sourceEpochMs: number; + readonly type: + | 'db-cancel-dispatched' + | 'db-cancel-received' + | 'db-cancel-terminal-received'; +} + +export interface DatabaseWorkerCancelCaptureSnapshot { + readonly invalidReasons: readonly string[]; + readonly timeline: readonly DatabaseWorkerCancelTimelineRecord[]; +} + +export interface DatabaseWorkerCancelCapture { + acceptDispatch(input: unknown): boolean; + acceptReceipt(input: { + readonly captureGeneration: number; + readonly message: unknown; + readonly receivedEpochMs: number; + readonly recordGeneration: number | null; + }): boolean; + acceptTerminal(input: { + readonly captureGeneration: number; + readonly operationId: string; + readonly receivedEpochMs: number; + readonly recordGeneration: number | null; + readonly requestId: string; + }): boolean; + start(captureGeneration: number): void; + stop(): DatabaseWorkerCancelCaptureSnapshot; +} + +export function createDatabaseWorkerCancelCapture(): DatabaseWorkerCancelCapture { + type JsonRecord = Record; + interface DispatchState { + readonly captureGeneration: number; + readonly ipcCallId: number; + readonly operationId: string; + readonly postedEpochMs: number; + readonly requestId: string; + readonly sourceEpochMs: number; + receiptEpochMs: number | null; + terminalEpochMs: number | null; + } + + const dispatchKeys = new Set([ + 'captureGeneration', + 'ipcCallId', + 'operationId', + 'postedEpochMs', + 'requestId', + 'sourceEpochMs', + ]); + const receiptKeys = new Set([ + 'type', + 'epochMs', + 'operationId', + 'requestId', + ]); + const identifierPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + let active = false; + let generation = 0; + let invalidReasons: string[] = []; + let timeline: DatabaseWorkerCancelTimelineRecord[] = []; + const dispatches = new Map(); + const helpers = { + invalidate(reason: string): false { + invalidReasons.push(reason); + return false; + }, + isEpoch(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isFinite(value) && value > 0 + ); + }, + isGeneration(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; + }, + isIdentifier(value: unknown): value is string { + return typeof value === 'string' && identifierPattern.test(value); + }, + isRecord(value: unknown): value is JsonRecord { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ); + }, + ownsOnly(value: JsonRecord, keys: ReadonlySet): boolean { + const ownKeys = Reflect.ownKeys(value); + return ( + ownKeys.length === keys.size && + ownKeys.every((key) => typeof key === 'string' && keys.has(key)) + ); + }, + timelineRecord( + dispatch: DispatchState, + epochMs: number, + type: DatabaseWorkerCancelTimelineRecord['type'] + ): DatabaseWorkerCancelTimelineRecord { + return { + epochMs, + ipcCallId: dispatch.ipcCallId, + operationId: dispatch.operationId, + requestId: dispatch.requestId, + sourceEpochMs: dispatch.sourceEpochMs, + type, + }; + }, + }; + return { + acceptDispatch(input): boolean { + try { + if ( + !active || + !helpers.isRecord(input) || + !helpers.ownsOnly(input, dispatchKeys) || + input['captureGeneration'] !== generation || + !helpers.isGeneration(input['captureGeneration']) || + !Number.isSafeInteger(input['ipcCallId']) || + Number(input['ipcCallId']) <= 0 || + !helpers.isIdentifier(input['operationId']) || + !helpers.isIdentifier(input['requestId']) || + !helpers.isEpoch(input['postedEpochMs']) || + !helpers.isEpoch(input['sourceEpochMs']) || + Number(input['sourceEpochMs']) > + Number(input['postedEpochMs']) + ) { + return helpers.invalidate('cancel-dispatch-invalid'); + } + const requestId = input['requestId']; + if (dispatches.has(requestId)) { + return helpers.invalidate('cancel-dispatch-duplicate'); + } + const dispatch = { + captureGeneration: generation, + ipcCallId: Number(input['ipcCallId']), + operationId: input['operationId'], + postedEpochMs: Number(input['postedEpochMs']), + receiptEpochMs: null, + requestId, + sourceEpochMs: Number(input['sourceEpochMs']), + terminalEpochMs: null, + }; + dispatches.set(requestId, dispatch); + timeline.push( + helpers.timelineRecord( + dispatch, + dispatch.postedEpochMs, + 'db-cancel-dispatched' + ) + ); + return true; + } catch { + return helpers.invalidate('cancel-dispatch-invalid'); + } + }, + acceptReceipt(input): boolean { + try { + if ( + !active || + input.captureGeneration !== generation || + input.recordGeneration !== generation + ) { + return helpers.invalidate( + 'cancel-receipt-generation-mismatch' + ); + } + const message = input.message; + if ( + !helpers.isRecord(message) || + !helpers.ownsOnly(message, receiptKeys) || + message['type'] !== 'performance-cancel-received' || + !helpers.isIdentifier(message['operationId']) || + !helpers.isIdentifier(message['requestId']) || + !helpers.isEpoch(message['epochMs']) || + !helpers.isEpoch(input.receivedEpochMs) + ) { + return helpers.invalidate('cancel-receipt-invalid-schema'); + } + const dispatch = dispatches.get(message['requestId']); + if (!dispatch) { + return helpers.invalidate( + 'cancel-receipt-dispatch-missing' + ); + } + if (dispatch.operationId !== message['operationId']) { + return helpers.invalidate( + 'cancel-receipt-correlation-mismatch' + ); + } + if ( + Number(message['epochMs']) < dispatch.postedEpochMs || + Number(message['epochMs']) > input.receivedEpochMs + ) { + return helpers.invalidate('cancel-receipt-epoch-invalid'); + } + if (dispatch.receiptEpochMs !== null) { + return helpers.invalidate('cancel-receipt-duplicate'); + } + dispatch.receiptEpochMs = Number(message['epochMs']); + timeline.push( + helpers.timelineRecord( + dispatch, + dispatch.receiptEpochMs, + 'db-cancel-received' + ) + ); + return true; + } catch { + return helpers.invalidate('cancel-receipt-invalid-schema'); + } + }, + acceptTerminal(input): boolean { + try { + if ( + !active || + input.captureGeneration !== generation || + input.recordGeneration !== generation + ) { + return helpers.invalidate( + 'cancel-terminal-generation-mismatch' + ); + } + if ( + !helpers.isIdentifier(input.operationId) || + !helpers.isIdentifier(input.requestId) || + !helpers.isEpoch(input.receivedEpochMs) + ) { + return helpers.invalidate('cancel-terminal-invalid'); + } + const dispatch = dispatches.get(input.requestId); + if (!dispatch) { + return helpers.invalidate( + 'cancel-terminal-dispatch-missing' + ); + } + if (dispatch.operationId !== input.operationId) { + return helpers.invalidate( + 'cancel-terminal-correlation-mismatch' + ); + } + if ( + dispatch.receiptEpochMs === null || + input.receivedEpochMs < dispatch.receiptEpochMs + ) { + return helpers.invalidate('cancel-terminal-before-receipt'); + } + if (dispatch.terminalEpochMs !== null) { + return helpers.invalidate('cancel-terminal-duplicate'); + } + dispatch.terminalEpochMs = input.receivedEpochMs; + timeline.push( + helpers.timelineRecord( + dispatch, + dispatch.terminalEpochMs, + 'db-cancel-terminal-received' + ) + ); + return true; + } catch { + return helpers.invalidate('cancel-terminal-invalid'); + } + }, + start(captureGeneration): void { + if (!helpers.isGeneration(captureGeneration)) { + throw new Error('database-worker-cancel-generation-invalid'); + } + active = true; + generation = captureGeneration; + invalidReasons = []; + timeline = []; + dispatches.clear(); + }, + stop(): DatabaseWorkerCancelCaptureSnapshot { + for (const dispatch of dispatches.values()) { + if (dispatch.receiptEpochMs === null) { + invalidReasons.push('cancel-receipt-missing'); + } + if (dispatch.terminalEpochMs === null) { + invalidReasons.push('cancel-terminal-missing'); + } + } + active = false; + return Object.freeze({ + invalidReasons: Object.freeze([...invalidReasons]), + timeline: Object.freeze([...timeline]), + }); + }, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.spec.ts b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.spec.ts index bcd3ac969..2223e48c1 100644 --- a/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.spec.ts +++ b/apps/electron-backend-e2e/src/performance/database-worker-post-gc-finalization.spec.ts @@ -370,21 +370,22 @@ test('worker capture failures use an artifact-neutral timeline label', () => { assert.doesNotMatch(source, /worker-profile-error:\$\{stage\}/); }); -test('capture generation resets artifact paths and always emits current DB records', () => { +test('capture generation resets artifact paths while arming and always emits current DB records', () => { const source = readFileSync( new URL('./m3u-refresh-main-capture.ts', import.meta.url), 'utf8' ); const start = source.indexOf('const startCapture = async ('); - const diagnostic = source.indexOf('if (state.diagnostic)', start); + const api = source.indexOf('const api =', start); const profileReset = source.indexOf('state.mainProfilePath = null', start); const snapshotReset = source.indexOf( 'state.mainSnapshotPath = null', start ); - assert.ok(profileReset > start && profileReset < diagnostic); - assert.ok(snapshotReset > start && snapshotReset < diagnostic); + assert.ok(profileReset > start && profileReset < api); + assert.ok(snapshotReset > start && snapshotReset < api); + assert.doesNotMatch(source.slice(start, api), /'Profiler\.start'/); assert.match( source, /record\.captureGeneration === state\.captureGeneration[\s\S]*record\.kind === 'database\.worker'/ diff --git a/apps/electron-backend-e2e/src/performance/electron-launch-environment.spec.ts b/apps/electron-backend-e2e/src/performance/electron-launch-environment.spec.ts new file mode 100644 index 000000000..c6c82239f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/electron-launch-environment.spec.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + buildElectronLaunchEnvironment, + ELECTRON_RUNTIME_ENVIRONMENT_KEYS, +} from '../electron-test-fixtures'; +import { assertXtreamBenchmarkEnvironmentIsolated } from './xtream-benchmark-iteration-support'; + +describe('Electron launch environment isolation', () => { + it('inherits only portable runtime keys for a profiled benchmark child', () => { + const environment = buildElectronLaunchEnvironment( + '/tmp/iptvnator-performance', + { + environmentInheritance: 'runtime-only', + env: { + IPTVNATOR_PERF_CAPTURE: '1', + }, + }, + { + AWS_SECRET_ACCESS_KEY: 'aws-secret', + ELECTRON_RUN_AS_NODE: '1', + GITHUB_TOKEN: 'github-secret', + HTTPS_PROXY: 'https://user:password@proxy.invalid', + IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: '0', + IPTVNATOR_REAL_PROVIDER_URL: 'https://provider.invalid', + IPTVNATOR_XTREAM_MOCK_CONTROL: '1', + IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'secret-token', + NODE_OPTIONS: '--require credential-dumper.js', + PATH: '/usr/bin', + REAL_PROVIDER_PASSWORD: 'provider-password', + REAL_PROVIDER_URL: 'https://provider.invalid', + TMPDIR: '/tmp/runtime', + } + ); + + assert.equal(environment['IPTVNATOR_PERF_CAPTURE'], '1'); + assert.equal(environment['IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'], '1'); + assert.equal(environment['PATH'], '/usr/bin'); + assert.equal(environment['TMPDIR'], '/tmp/runtime'); + for (const key of [ + 'AWS_SECRET_ACCESS_KEY', + 'ELECTRON_RUN_AS_NODE', + 'GITHUB_TOKEN', + 'HTTPS_PROXY', + 'IPTVNATOR_REAL_PROVIDER_URL', + 'IPTVNATOR_XTREAM_MOCK_CONTROL', + 'IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN', + 'NODE_OPTIONS', + 'REAL_PROVIDER_PASSWORD', + 'REAL_PROVIDER_URL', + ]) { + assert.equal(environment[key], undefined, key); + } + }); + + it('drops undefined overrides instead of serializing them into the child', () => { + const environment = buildElectronLaunchEnvironment( + '/tmp/iptvnator-performance', + { env: { OPTIONAL_DEBUG_FLAG: undefined } }, + { OPTIONAL_DEBUG_FLAG: 'inherited' } + ); + + assert.equal(environment['OPTIONAL_DEBUG_FLAG'], undefined); + }); + + it('checks the effective Electron main environment, not only launch input', async () => { + const allowed = [ + ...ELECTRON_RUNTIME_ENVIRONMENT_KEYS, + 'ELECTRON_IS_DEV', + 'IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS', + 'IPTVNATOR_DB_WORKER_BATCH_DELAY_MS', + 'IPTVNATOR_E2E_DATA_DIR', + 'IPTVNATOR_PERF_CAPTURE', + 'IPTVNATOR_PERF_WORKER_PROFILING', + 'IPTVNATOR_TRACE_RENDERER_CONSOLE', + 'NODE_ENV', + ]; + + await assert.doesNotReject( + assertXtreamBenchmarkEnvironmentIsolated(fakeApp(allowed)) + ); + for (const leaked of [ + 'GITHUB_TOKEN', + 'HTTPS_PROXY', + 'NODE_OPTIONS', + 'REAL_PROVIDER_URL', + ]) { + await assert.rejects( + assertXtreamBenchmarkEnvironmentIsolated( + fakeApp([...allowed, leaked]) + ), + /xtream-benchmark-environment-reached-electron/ + ); + } + }); +}); + +function fakeApp(keys: readonly string[]) { + return { + electronApp: { + evaluate: async () => [...keys], + }, + } as never; +} diff --git a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts index b162bdafa..ed4b9c13f 100644 --- a/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts +++ b/apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifact-content-validation.ts @@ -73,6 +73,14 @@ export function isCpuProfile(value: unknown): boolean { return elapsed > 0; } +export function readCpuProfileDurationMicros(value: unknown): number | null { + if (!isCpuProfile(value)) { + return null; + } + const profile = value as Record; + return profile['endTime'] - profile['startTime']; +} + export function isHeapSnapshot(value: unknown): boolean { if ( !isRecord(value) || diff --git a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts index c153a3581..70e2220d8 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-import-benchmark-support.ts @@ -69,7 +69,10 @@ export async function resolveM3uImportBenchmarkConfiguration(): Promise | null = null; let traceFirstEvent = true; @@ -62,9 +69,7 @@ export function createRendererArtifactCapture(options: { traceOutput.write(']}'); traceOutput.end(); } - if (!traceOutput.writableFinished) { - await once(traceOutput, 'finish'); - } + await finished(traceOutput); })(); return traceOutputFinish; }, @@ -122,7 +127,7 @@ export async function stopRendererDiagnosticCapture( await writeFile( requirePath(artifacts.cpuProfilePath, 'renderer CPU profile'), JSON.stringify(cpuProfile['profile']), - 'utf8' + { encoding: 'utf8', flag: 'wx', mode: 0o600 } ); await session.send('Tracing.end'); await artifacts.traceComplete; @@ -145,7 +150,8 @@ export async function takeRendererHeapSnapshot( path: string | null ): Promise { const output = createWriteStream( - requirePath(path, 'renderer heap snapshot') + requirePath(path, 'renderer heap snapshot'), + { flags: 'wx', mode: 0o600 } ); const onChunk = ({ chunk }: { readonly chunk: string }): void => { output.write(chunk); @@ -158,9 +164,43 @@ export async function takeRendererHeapSnapshot( } finally { session.off('HeapProfiler.addHeapSnapshotChunk', onChunk); output.end(); - if (!output.writableFinished) { - await once(output, 'finish'); - } + await finished(output); + } +} + +export function selectRendererPeakHeapUsedBytes( + samples: readonly RendererHeapUsageSample[], + measurementStartEpochMs: number, + terminalEpochMs: number +): number { + const inWindow = samples.filter( + ({ epochMs, usedSize }) => + Number.isFinite(epochMs) && + epochMs >= measurementStartEpochMs && + epochMs <= terminalEpochMs && + Number.isFinite(usedSize) && + usedSize >= 0 + ); + if (inWindow.length === 0) { + throw new Error('renderer-heap-window-empty'); + } + return Math.max(...inWindow.map(({ usedSize }) => usedSize)); +} + +export function assertRendererMeasurementWindow(input: { + readonly captureCutoffEpochMs: number; + readonly measurementStartEpochMs: number; + readonly operationStartEpochMs: number; + readonly terminalEpochMs: number; +}): void { + const epochs = Object.values(input); + if ( + !epochs.every((epoch) => Number.isFinite(epoch) && epoch > 0) || + input.measurementStartEpochMs < input.operationStartEpochMs || + input.terminalEpochMs < input.measurementStartEpochMs || + input.captureCutoffEpochMs < input.terminalEpochMs + ) { + throw new Error('renderer-measurement-window-invalid'); } } diff --git a/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts index dab027bc9..7f3b61df8 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-import.benchmark.ts @@ -82,7 +82,10 @@ async function runScenario( assertSyntheticM3uImportUrl(server.resourceUrl); await server.prime(); await assertPerformanceArtifactCapacity(scenarioDirectory); - await mkdir(scenarioDirectory, { recursive: false }); + await mkdir(scenarioDirectory, { + mode: 0o700, + recursive: false, + }); for (const definition of definitions) { console.log( `[performance] ${config.variant}/${scenario.id}/${definition.runId} starting` @@ -163,7 +166,10 @@ async function runIteration( ): Promise { const iterationDirectory = join(scenarioDirectory, definition.runId); await assertPerformanceArtifactCapacity(iterationDirectory); - await mkdir(iterationDirectory, { recursive: false }); + await mkdir(iterationDirectory, { + mode: 0o700, + recursive: false, + }); const dataDirectory = await mkdtemp( join(tmpdir(), 'iptvnator-m3u-import-performance-') ); @@ -179,6 +185,7 @@ async function runIteration( `--remote-debugging-port=${config.rendererCdpPort}`, `--user-data-dir=${join(dataDirectory, 'user-data')}`, ], + environmentInheritance: 'runtime-only', env: { IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', IPTVNATOR_PERF_CAPTURE: '1', @@ -188,10 +195,7 @@ async function runIteration( }, }); app.mainWindow.setDefaultTimeout(120_000); - await assertRendererCdpTarget( - app.mainWindow, - config.rendererCdpPort - ); + await assertRendererCdpTarget(app.mainWindow, config.rendererCdpPort); await installMainCapture(app.electronApp); const rendererWindowIdentity = await resolveRendererWindowIdentity(app); const requestsBefore = server.requestCount(); diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts index a89f95dc8..619c5cb2c 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation-contract.ts @@ -1,7 +1,7 @@ import type { RendererProcessRssCapture } from './renderer-process-rss-capture'; import type { RendererRssValidity } from './renderer-rss-validity'; import type { - AppPlaylistPerformancePhase, + DatabaseWorkerPerformancePhase, PerformancePhaseEvent, } from '@iptvnator/shared/interfaces'; @@ -57,8 +57,7 @@ export type WorkerPostGcHeapCapture = }; export const WORKER_PEAK_MEMORY_UNAVAILABLE_REASON = { - EXTERNAL_MEMORY_SAMPLES_MISSING: - 'worker-external-memory-samples-missing', + EXTERNAL_MEMORY_SAMPLES_MISSING: 'worker-external-memory-samples-missing', HEAP_USED_SAMPLES_MISSING: 'worker-heap-used-samples-missing', } as const; @@ -86,17 +85,23 @@ export interface ProcessMemoryMetrics { } export interface MainTimelineRecord { + readonly action?: string | null; readonly boundary?: 'end' | 'start'; readonly byteCount?: number; + readonly categoryType?: string | null; + readonly contentType?: string | null; readonly durationMs?: number | null; readonly epochMs: number; readonly ipcCallId?: number; - readonly itemCount?: number; + readonly itemCount?: number | null; + readonly method?: string; readonly operation?: string; - readonly operationId?: string; + readonly operationId?: string | null; + readonly outcome?: 'error' | 'success' | null; readonly phase?: string; - readonly playlistId?: string; + readonly playlistId?: string | null; readonly requestId?: string; + readonly sessionId?: string | null; readonly success?: boolean; readonly sourceEpochMs?: number; readonly type: string; @@ -114,7 +119,7 @@ export interface WorkerRequestPerformanceMetrics { readonly operationId: string | null; readonly operationIdUnavailableReason: string | null; readonly performanceCaptureUnavailableReason: string | null; - readonly phaseEvents: readonly PerformancePhaseEvent[]; + readonly phaseEvents: readonly PerformancePhaseEvent[]; readonly playlistId: string | null; readonly requestId: string | null; readonly requestReceivedEpochMs: number | null; diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts index 243803989..09b7f1abf 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts @@ -186,7 +186,10 @@ async function runIteration( ): Promise { const iterationDirectory = join(config.outputDirectory, definition.runId); await assertPerformanceArtifactCapacity(iterationDirectory); - await mkdir(iterationDirectory, { recursive: false }); + await mkdir(iterationDirectory, { + mode: 0o700, + recursive: false, + }); const dataDirectory = await mkdtemp( join(tmpdir(), 'iptvnator-m3u-performance-') ); @@ -204,6 +207,7 @@ async function runIteration( `--remote-debugging-port=${RENDERER_CDP_PORT}`, `--user-data-dir=${join(dataDirectory, 'user-data')}`, ], + environmentInheritance: 'runtime-only', env: { IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', IPTVNATOR_PERF_CAPTURE: '1', @@ -448,7 +452,10 @@ async function resolveConfiguration(): Promise { const outputDirectory = join(outputRoot, variant); await assertMissing(outputDirectory); await assertPerformanceArtifactCapacity(outputDirectory); - await mkdir(outputDirectory, { recursive: true }); + await mkdir(outputDirectory, { + mode: 0o700, + recursive: true, + }); return Object.freeze({ electronVersion: electronPackage.version, gitCommit: sourceState.commit, diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts index 2c4536f8e..b05d01be3 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts @@ -3,6 +3,12 @@ import type { ElectronApplication } from '@playwright/test'; import { M3U_IMPORT_PERFORMANCE_PHASE, M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, + XTREAM_MAIN_PERFORMANCE_PHASE, + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL, + XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, + XTREAM_PRELOAD_PERFORMANCE_METHOD, + XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION, + XtreamCodeActions, } from '@iptvnator/shared/interfaces'; import { @@ -11,6 +17,12 @@ import { type DatabaseRequestIdentity, type DatabaseRequestIdentityCaptureApi, } from './database-request-identity-capture'; +import { + createDatabaseWorkerCancelCapture, + type DatabaseWorkerCancelCapture, + type DatabaseWorkerCancelCaptureSnapshot, + type DatabaseWorkerCancelTimelineRecord, +} from './database-worker-cancel-capture'; import { createDatabaseWorkerPostGcCutoffApi, type DatabaseWorkerPostGcCutoffApi, @@ -31,7 +43,9 @@ import { } from './database-worker-post-gc-selection'; import type { MainCaptureMetrics, + WorkerCaptureMetrics, WorkerPostGcHeapUnavailableReason, + WorkerRequestPerformanceMetrics, } from './m3u-refresh-cancellation-contract'; import { selectMainCaptureGeneration, @@ -52,10 +66,23 @@ import { type RendererWindowRssSessionApi, type RendererWindowRssSessionMetrics, } from './renderer-window-rss-session'; +import { createDiagnosticsPerformancePhaseEventParser } from './performance-phase-events'; +import { + createPerformanceTimelineMergeApi, + type PerformanceTimelineMergeApi, +} from './performance-timeline-merge'; +import { createXtreamIpcMarkerCapture } from './xtream-ipc-marker-events'; +import type { + XtreamIpcMarkerCaptureSnapshot, + XtreamIpcTimelineRecord, +} from './xtream-ipc-marker-events.model'; +import { createXtreamIpcMarkerProtocol } from './xtream-ipc-marker-protocol'; +import { createXtreamMainLifecycleValidator } from './xtream-main-lifecycle'; const MAIN_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshMainCapture'; export interface MainCaptureStartOptions { + readonly deferMeasurement?: boolean; readonly diagnostic: boolean; readonly outputDirectory: string; readonly rendererWindowIdentity: RendererWindowIdentity; @@ -79,14 +106,100 @@ export interface MainCaptureRolloverResult { readonly nextCaptureUnavailableReason: string | null; } -interface MainCaptureRolloverStatus { +export type XtreamMainCaptureOperationOutcomeKind = + 'error' | 'pending' | 'success'; + +export interface XtreamMainCaptureOperationOutcome { + readonly count: number; + readonly operation: string; + readonly outcome: XtreamMainCaptureOperationOutcomeKind; +} + +export interface XtreamMainCaptureOperationProgress { + readonly current: number | null; + readonly epochMs: number; + readonly phase: string | null; + readonly status: + 'cancelled' | 'completed' | 'error' | 'progress' | 'started'; + readonly total: number | null; +} + +export interface XtreamMainCaptureActiveOperation { + readonly operation: string; + readonly operationId: string | null; + readonly playlistId: string | null; + readonly progress: XtreamMainCaptureOperationProgress | null; + readonly requestId: string; +} + +export interface XtreamMainCaptureInvalidReasons { + readonly capture: readonly string[]; + readonly databaseWorkerCancel: readonly string[]; + readonly xtreamIpc: readonly string[]; +} + +export interface XtreamMainCaptureStatus { + readonly active: boolean; + readonly activeOperations: readonly XtreamMainCaptureActiveOperation[]; + readonly captureGeneration: number; + readonly captureStartedEpochMs: number; + readonly cutoffPhase: 'active' | 'idle' | 'stopping'; + readonly databaseWorkerCount: number; + readonly invalidReasons: readonly string[]; + readonly lateDatabaseRequestCount: number; + readonly lateEventCount: number; + readonly measurementPhase: 'armed' | 'frozen' | 'measuring'; + readonly measurementStartedEpochMs: number | null; + readonly operationOutcomes: readonly XtreamMainCaptureOperationOutcome[]; + readonly playlistRefreshWorkerCount: number; +} + +export interface MainCaptureRolloverStatus { readonly nextCaptureStarted: boolean; readonly nextCaptureUnavailableReason: string | null; } -type MainCaptureStopTransport = MainCaptureGenerationTransport & { +export interface XtreamMainCaptureTransport extends MainCaptureGenerationTransport { readonly rollover: MainCaptureRolloverStatus | null; -}; + readonly xtream: { + readonly cancelCapture: DatabaseWorkerCancelCaptureSnapshot; + readonly captureCutoffEpochMs: number; + readonly captureGeneration: number; + readonly captureStartedEpochMs: number; + readonly captureStoppedEpochMs: number; + readonly databaseWorkerCount: number; + readonly invalidReasons: readonly string[]; + readonly ipcCapture: XtreamIpcMarkerCaptureSnapshot; + readonly lateDatabaseRequestCount: number; + readonly lateEventCount: number; + readonly measurementStartedEpochMs: number; + readonly operationOutcomes: readonly XtreamMainCaptureOperationOutcome[]; + readonly playlistRefreshWorkerCount: number; + }; +} + +export interface XtreamMainCaptureResult { + readonly cancelTimeline: readonly DatabaseWorkerCancelTimelineRecord[]; + readonly capture: MainCaptureMetrics; + readonly captureCutoffEpochMs: number; + readonly captureGeneration: number; + readonly captureStartedEpochMs: number; + readonly captureStoppedEpochMs: number; + readonly databaseWorkerCount: number; + readonly invalidReasons: XtreamMainCaptureInvalidReasons; + readonly ipcTimeline: readonly XtreamIpcTimelineRecord[]; + readonly lateDatabaseRequestCount: number; + readonly lateEventCount: number; + readonly measurementStartedEpochMs: number; + readonly operationOutcomes: readonly XtreamMainCaptureOperationOutcome[]; + readonly playlistRefreshWorkerCount: number; + readonly quarantinedIpcMarkerCount: number; + readonly requests: readonly WorkerRequestPerformanceMetrics[]; + readonly rollover: MainCaptureRolloverStatus | null; + readonly workers: readonly WorkerCaptureMetrics[]; +} + +type MainCaptureStopTransport = XtreamMainCaptureTransport; export async function installMainCapture( electronApp: ElectronApplication @@ -95,6 +208,10 @@ export async function installMainCapture( const captureStateKeys = { databaseRequestIdentityStateKey: DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY, + databaseWorkerCancelCaptureFactorySource: + createDatabaseWorkerCancelCapture.toString(), + diagnosticsPerformancePhaseEventParserFactorySource: + createDiagnosticsPerformancePhaseEventParser.toString(), databaseWorkerPostGcCutoffApiFactorySource: createDatabaseWorkerPostGcCutoffApi.toString(), databaseWorkerPostGcFinalizationApiFactorySource: @@ -110,11 +227,30 @@ export async function installMainCapture( m3uImportPerformancePhaseEventChannel: M3U_IMPORT_PERFORMANCE_PHASE_EVENT_CHANNEL, m3uImportPerformancePhases: Object.values(M3U_IMPORT_PERFORMANCE_PHASE), + performanceTimelineMergeApiFactorySource: + createPerformanceTimelineMergeApi.toString(), stateKey: MAIN_CAPTURE_STATE_KEY, workerTerminationGenerationApiFactorySource: createWorkerTerminationGenerationApi.toString(), + xtreamIpcMarkerCaptureFactorySource: + createXtreamIpcMarkerCapture.toString(), + xtreamIpcMarkerCaptureOptions: { + actions: Object.values(XtreamCodeActions), + mainPhases: Object.values(XTREAM_MAIN_PERFORMANCE_PHASE), + methods: Object.values(XTREAM_PRELOAD_PERFORMANCE_METHOD), + schemaVersion: XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION, + }, + xtreamIpcMarkerProtocolFactorySource: + createXtreamIpcMarkerProtocol.toString(), + xtreamMainPerformancePhaseEventChannel: + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL, + xtreamMainLifecycleValidatorFactorySource: + createXtreamMainLifecycleValidator.toString(), + xtreamPreloadPerformanceMarkerChannel: + XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, }; - await electronApp.evaluate(async ({ app, BrowserWindow }, input) => { + await electronApp.evaluate(async (electron, input) => { + const { app, BrowserWindow, ipcMain } = electron; type JsonRecord = Record; type WorkerTransferable = import('node:worker_threads').Transferable; interface CpuProfileHandle { @@ -190,23 +326,29 @@ export async function installMainCapture( requestPerformance: WorkerRequestPerformance[]; responseEpochMs: number | null; samplePromise: Promise | null; - sampleTimer: NodeJS.Timeout | null; + samplingStarted: boolean; snapshotPath: string | null; terminatedEpochMs: number | null; worker: InstrumentedWorker; } interface TimelineRecord { + readonly action?: string | null; readonly boundary?: 'end' | 'start'; readonly byteCount?: number; + readonly categoryType?: string | null; + readonly contentType?: string | null; readonly durationMs?: number | null; readonly epochMs: number; readonly ipcCallId?: number; - readonly itemCount?: number; + readonly itemCount?: number | null; + readonly method?: string; readonly operation?: string; - readonly operationId?: string; + readonly operationId?: string | null; + readonly outcome?: 'error' | 'success' | null; readonly phase?: string; - readonly playlistId?: string; + readonly playlistId?: string | null; readonly requestId?: string; + readonly sessionId?: string | null; readonly success?: boolean; readonly sourceEpochMs?: number; readonly type: string; @@ -219,16 +361,20 @@ export async function installMainCapture( const databaseRequestIdentityCapture = target[ input.databaseRequestIdentityStateKey ] as DatabaseRequestIdentityCaptureApi; + const restoreFunction = (source: string): T => + new Function(`"use strict"; return (${source});`)() as T; const restoreFactory = (source: string): T => { - const factory = new Function( - `"use strict"; return (${source});` - )() as () => T; + const factory = restoreFunction<() => T>(source); return factory(); }; const databaseWorkerPostGcCutoffApi = restoreFactory( input.databaseWorkerPostGcCutoffApiFactorySource ); + const databaseWorkerCancelCapture = + restoreFactory( + input.databaseWorkerCancelCaptureFactorySource + ); const databaseWorkerPostGcFinalizationApi = restoreFactory( input.databaseWorkerPostGcFinalizationApiFactorySource @@ -253,9 +399,31 @@ export async function installMainCapture( restoreFactory( input.workerTerminationGenerationApiFactorySource ); + const diagnosticsPerformancePhaseEventParserFactory = restoreFunction< + typeof createDiagnosticsPerformancePhaseEventParser + >(input.diagnosticsPerformancePhaseEventParserFactorySource); + const performanceTimelineMergeApi = + restoreFactory( + input.performanceTimelineMergeApiFactorySource + ); + const xtreamIpcMarkerProtocolFactory = restoreFunction< + typeof createXtreamIpcMarkerProtocol + >(input.xtreamIpcMarkerProtocolFactorySource); + const xtreamIpcMarkerCaptureFactory = restoreFunction< + typeof createXtreamIpcMarkerCapture + >(input.xtreamIpcMarkerCaptureFactorySource); + const xtreamMainLifecycleValidatorFactory = restoreFunction< + typeof createXtreamMainLifecycleValidator + >(input.xtreamMainLifecycleValidatorFactorySource); + const xtreamIpcMarkerCapture = xtreamIpcMarkerCaptureFactory( + input.xtreamIpcMarkerCaptureOptions, + xtreamIpcMarkerProtocolFactory(input.xtreamIpcMarkerCaptureOptions), + xtreamMainLifecycleValidatorFactory() + ); const runtimeProcess = process as typeof process & { getBuiltinModule(id: string): unknown; + threadCpuUsage(previousValue?: NodeJS.CpuUsage): NodeJS.CpuUsage; }; const fs = runtimeProcess.getBuiltinModule( 'node:fs' @@ -290,6 +458,7 @@ export async function installMainCapture( identity: DatabaseRequestIdentity; operation: string; playlistId: string | null; + progress: XtreamMainCaptureOperationProgress | null; record: WorkerRecord; } >(); @@ -297,6 +466,9 @@ export async function installMainCapture( const state = { active: false, captureGeneration: 0, + captureInvalidReasons: [] as string[], + captureOptions: null as MainCaptureStartOptions | null, + captureStartedEpochMs: null as number | null, cpuStart: null as NodeJS.CpuUsage | null, diagnostic: false, eventLoopDelay: null as ReturnType< @@ -310,10 +482,13 @@ export async function installMainCapture( mainPeakRss: 0, mainProfilePath: null as string | null, mainSnapshotPath: null as string | null, + measurementPhase: 'armed' as 'armed' | 'frozen' | 'measuring', + measurementStartedEpochMs: null as number | null, outputDirectory: '', postGcHeap: null as number | null, postGcRss: null as number | null, rendererWindowSession: null as RendererWindowRssSession | null, + lateEventCount: 0, sampleTimer: null as NodeJS.Timeout | null, stopping: false, timeline: [] as TimelineRecord[], @@ -328,60 +503,175 @@ export async function installMainCapture( state.timeline.push({ epochMs: nowEpochMs(), ...record }); } }; - const m3uImportPhases = new Set( - input.m3uImportPerformancePhases - ); - diagnosticsChannel - .channel(input.m3uImportPerformancePhaseEventChannel) - .subscribe((incoming: unknown) => { + const invalidateCapture = (reason: string): void => { + if (!state.captureInvalidReasons.includes(reason)) { + state.captureInvalidReasons.push(reason); + } + }; + const readDatabaseProgress = ( + inputValue: unknown + ): XtreamMainCaptureOperationProgress | null => { + if (typeof inputValue !== 'object' || inputValue === null) { + invalidateCapture('database-operation-progress-invalid'); + return null; + } + const event = inputValue as JsonRecord; + const status = event['status']; + const phase = event['phase']; + const current = event['current']; + const total = event['total']; + if ( + typeof event['operation'] !== 'string' || + !/^[a-z][a-z0-9-]{0,127}$/.test(event['operation']) || + ![ + 'cancelled', + 'completed', + 'error', + 'progress', + 'started', + ].includes(String(status)) || + !( + phase === undefined || + phase === null || + (typeof phase === 'string' && + /^[a-z][a-z0-9-]{0,127}$/.test(phase)) + ) || + !( + current === undefined || + (typeof current === 'number' && + Number.isFinite(current) && + current >= 0) + ) || + !( + total === undefined || + (typeof total === 'number' && + Number.isFinite(total) && + total >= 0) + ) + ) { + invalidateCapture('database-operation-progress-invalid'); + return null; + } + return { + current: typeof current === 'number' ? current : null, + epochMs: nowEpochMs(), + phase: typeof phase === 'string' ? phase : null, + status: status as XtreamMainCaptureOperationProgress['status'], + total: typeof total === 'number' ? total : null, + }; + }; + const readOperationOutcomes = + (): XtreamMainCaptureOperationOutcome[] => { + const counts = new Map< + string, + { error: number; requests: number; success: number } + >(); + for (const entry of state.timeline) { + if ( + entry.type !== 'db-request' && + entry.type !== 'db-response' + ) { + continue; + } + if ( + typeof entry.operation !== 'string' || + !/^DB_[A-Z0-9_]{1,124}$/.test(entry.operation) + ) { + invalidateCapture('database-operation-outcome-invalid'); + continue; + } + const count = counts.get(entry.operation) ?? { + error: 0, + requests: 0, + success: 0, + }; + if (entry.type === 'db-request') { + count.requests += 1; + } else if (entry.success === true) { + count.success += 1; + } else if (entry.success === false) { + count.error += 1; + } else { + invalidateCapture('database-operation-outcome-invalid'); + } + counts.set(entry.operation, count); + } + const outcomes: XtreamMainCaptureOperationOutcome[] = []; + for (const operation of [...counts.keys()].sort()) { + const count = counts.get(operation); + if (!count) continue; + const pending = + count.requests - count.error - count.success; + if (pending < 0) { + invalidateCapture('database-operation-outcome-invalid'); + } + for (const [outcome, value] of [ + ['error', count.error], + ['pending', Math.max(0, pending)], + ['success', count.success], + ] as const) { + if (value > 0) { + outcomes.push({ count: value, operation, outcome }); + } + } + } + return outcomes; + }; + const parseM3uImportPhase = + diagnosticsPerformancePhaseEventParserFactory({ + allowedPhases: input.m3uImportPerformancePhases, + timelineType: 'm3u-import-phase', + }); + const parseXtreamMainPhase = + diagnosticsPerformancePhaseEventParserFactory({ + allowedPhases: input.xtreamIpcMarkerCaptureOptions.mainPhases, + timelineType: 'xtream-main-phase', + }); + const subscribeDiagnosticsPhase = ( + channelName: string, + parse: ReturnType< + typeof createDiagnosticsPerformancePhaseEventParser + >, + accept: (event: NonNullable>) => void + ): void => { + diagnosticsChannel.channel(channelName).subscribe((incoming) => { if (!state.active && !state.stopping) { return; } - if (typeof incoming !== 'object' || incoming === null) { - return; + const event = parse(incoming); + if (event) { + accept(event); } - const event = incoming as JsonRecord; - const boundary = event['boundary']; - const durationMs = event['durationMs']; - const epochMs = event['epochMs']; - const metadata = - typeof event['metadata'] === 'object' && - event['metadata'] !== null - ? (event['metadata'] as JsonRecord) - : null; - const phase = event['phase']; - const requestId = event['requestId']; - if ( - (boundary !== 'start' && boundary !== 'end') || - typeof epochMs !== 'number' || - !Number.isFinite(epochMs) || - typeof phase !== 'string' || - !m3uImportPhases.has(phase) || - typeof requestId !== 'string' || - requestId.length === 0 || - (boundary === 'start' && durationMs !== null) || - (boundary === 'end' && - (typeof durationMs !== 'number' || - !Number.isFinite(durationMs) || - durationMs < 0)) - ) { - return; - } - const byteCount = metadata?.['byteCount']; - const itemCount = metadata?.['itemCount']; - state.timeline.push({ - boundary, - ...(isCount(byteCount) ? { byteCount } : {}), - durationMs: durationMs as number | null, - epochMs, - ...(isCount(itemCount) ? { itemCount } : {}), - phase, - requestId, - type: 'm3u-import-phase', - }); }); - const isCount = (value: unknown): value is number => - Number.isSafeInteger(value) && Number(value) >= 0; + }; + subscribeDiagnosticsPhase( + input.m3uImportPerformancePhaseEventChannel, + parseM3uImportPhase, + (event) => state.timeline.push(event) + ); + subscribeDiagnosticsPhase( + input.xtreamMainPerformancePhaseEventChannel, + parseXtreamMainPhase, + (event) => { + xtreamIpcMarkerCapture.acceptMainPhase(event); + } + ); + ipcMain.on( + input.xtreamPreloadPerformanceMarkerChannel, + (event, marker) => { + let senderWebContentsId = -1; + try { + senderWebContentsId = event.sender.id; + } catch { + // Strict capture rejects an unreadable sender identity. + } + xtreamIpcMarkerCapture.acceptPreload( + senderWebContentsId, + nowEpochMs(), + marker + ); + } + ); const classifyRequest = ( message: JsonRecord ): 'database.worker' | 'playlist-refresh.worker' | null => { @@ -446,7 +736,7 @@ export async function installMainCapture( requestPerformance: [], responseEpochMs: null, samplePromise: null, - sampleTimer: null, + samplingStarted: false, snapshotPath: null, terminatedEpochMs: null, worker, @@ -458,6 +748,17 @@ export async function installMainCapture( return; } const message = incoming as JsonRecord; + if (message['type'] === 'performance-cancel-received') { + if (record.kind === 'database.worker') { + databaseWorkerCancelCapture.acceptReceipt({ + captureGeneration: state.captureGeneration, + message, + receivedEpochMs: nowEpochMs(), + recordGeneration: record.captureGeneration, + }); + } + return; + } if (!isCurrentCaptureRecord(record)) { return; } @@ -498,6 +799,34 @@ export async function installMainCapture( } return; } + if ( + message['type'] === 'event' && + typeof message['requestId'] === 'string' + ) { + const event = message['event']; + const request = dbRequests.get(message['requestId']); + if (request) { + request.progress = readDatabaseProgress(event); + } + if ( + request && + typeof event === 'object' && + event !== null && + (event as JsonRecord)['status'] === 'cancelled' && + typeof (event as JsonRecord)['operationId'] === 'string' + ) { + databaseWorkerCancelCapture.acceptTerminal({ + captureGeneration: state.captureGeneration, + operationId: (event as JsonRecord)[ + 'operationId' + ] as string, + receivedEpochMs: nowEpochMs(), + recordGeneration: record.captureGeneration, + requestId: message['requestId'], + }); + } + return; + } if ( message['type'] === 'response' && typeof message['requestId'] === 'string' @@ -590,9 +919,6 @@ export async function installMainCapture( return record.samplePromise; }; const resetWorkerForCapture = (record: WorkerRecord): void => { - if (record.sampleTimer) { - clearInterval(record.sampleTimer); - } record.cancelPostedEpochMs = null; record.captureGeneration = state.captureGeneration; record.cpuFirst = null; @@ -620,28 +946,26 @@ export async function installMainCapture( record.requestPerformance = []; record.responseEpochMs = null; record.samplePromise = null; - record.sampleTimer = null; + record.samplingStarted = false; record.snapshotPath = null; record.terminatedEpochMs = null; }; - const startWorker = (record: WorkerRecord): void => { - if (!state.active) { + const startWorker = async (record: WorkerRecord): Promise => { + if (!state.active || state.measurementPhase !== 'measuring') { return; } if (record.captureGeneration !== state.captureGeneration) { resetWorkerForCapture(record); } - if (record.sampleTimer !== null) { + if (record.samplingStarted) { return; } + record.samplingStarted = true; record.elu = null; record.eluStart = record.worker.performance?.eventLoopUtilization() ?? null; - void sampleWorker(record); - record.sampleTimer = setInterval( - () => void sampleWorker(record), - 20 - ); + const initialSample = sampleWorker(record); + let profileReady: Promise = Promise.resolve(); if ( state.diagnostic && typeof record.worker.startCpuProfile === 'function' @@ -653,7 +977,7 @@ export async function installMainCapture( const profileHandle = record.worker.startCpuProfile(); const profileCaptureKey = record.profileCaptureKey; record.profileHandle = profileHandle; - void profileHandle.then( + profileReady = profileHandle.then( (handle) => { if ( record.profileCaptureKey === profileCaptureKey && @@ -662,15 +986,15 @@ export async function installMainCapture( record.resolvedProfileHandle = handle; } }, - () => undefined + (error: unknown) => { + throw error; + } ); } + await Promise.all([initialSample, profileReady]); }; const stopWorkerSampling = (record: WorkerRecord): void => { - if (record.sampleTimer) { - clearInterval(record.sampleTimer); - record.sampleTimer = null; - } + record.samplingStarted = false; }; const joinFinalWorkerSample = async ( record: WorkerRecord @@ -689,6 +1013,9 @@ export async function installMainCapture( if (!profileHandle || !record.profilePath) { return; } + if (record.profileResult !== null) { + return; + } const handle = record.resolvedProfileHandle ?? (waitForHandle ? await profileHandle : null); @@ -715,7 +1042,8 @@ export async function installMainCapture( } fs.writeFileSync( record.profilePath, - JSON.stringify(normalizeWorkerCpuProfile(record.profileResult)) + JSON.stringify(normalizeWorkerCpuProfile(record.profileResult)), + { encoding: 'utf8', flag: 'wx', mode: 0o600 } ); record.profileHandle = null; record.profileResult = null; @@ -743,7 +1071,10 @@ export async function installMainCapture( const snapshot = await record.worker.getHeapSnapshot(); await streamPromises.pipeline( snapshot, - fs.createWriteStream(record.snapshotPath) + fs.createWriteStream(record.snapshotPath, { + flags: 'wx', + mode: 0o600, + }) ); }; const reportWorkerArtifactFailure = ( @@ -774,7 +1105,7 @@ export async function installMainCapture( record.finalizing ??= databaseWorkerPostGcFinalizationApi .finalize({ finalizationKey: record.finalizationKey, - joinFinalSample: () => joinFinalWorkerSample(record), + joinFinalSample: () => Promise.resolve(), probePostGc: () => selectionUnavailableReason === null ? databaseWorkerPostGcProbeApi.probe({ @@ -798,7 +1129,7 @@ export async function installMainCapture( }), reportAncillaryFailure: (stage, error) => reportWorkerArtifactFailure(record, stage, error), - stopProfile: () => stopWorkerProfile(record), + stopProfile: () => Promise.resolve(), stopSampling: () => stopWorkerSampling(record), ...(state.diagnostic ? { @@ -900,6 +1231,7 @@ export async function installMainCapture( ? databaseWorkerPostGcCutoffApi.observeDatabaseRequest() : null; if (requestDisposition === 'after-cutoff') { + state.lateEventCount += 1; state.timeline.push({ epochMs: nowEpochMs(), operation: @@ -922,7 +1254,27 @@ export async function installMainCapture( return; } const record = createWorkerRecord(this, kind); - startWorker(record); + if ( + state.active && + state.measurementPhase !== 'measuring' + ) { + invalidateCapture( + 'worker-request-before-measurement' + ); + } + if ( + state.active && + state.measurementPhase === 'measuring' && + kind === 'database.worker' && + state.captureOptions?.deferMeasurement === true && + (!isCurrentCaptureRecord(record) || + !record.samplingStarted) + ) { + invalidateCapture('database-worker-not-prearmed'); + } + void startWorker(record).catch(() => + invalidateCapture('worker-measurement-start-failed') + ); if ( isCurrentCaptureRecord(record) && kind === 'playlist-refresh.worker' @@ -954,8 +1306,13 @@ export async function installMainCapture( (payload as JsonRecord)['operationId'] ) : null; + const xtreamIdentity = + xtreamIpcMarkerCapture.matchDatabaseRequest( + value + ); const identity = - payloadOperationId === null + xtreamIdentity ?? + (payloadOperationId === null ? databaseRequestIdentityCapture.matchDatabaseRequest( value ) @@ -964,12 +1321,13 @@ export async function installMainCapture( operationId: payloadOperationId, operationIdUnavailableReason: null, sourceEpochMs: null, - }; + }); record.pendingCount += 1; dbRequests.set(value['requestId'], { identity, operation: value['operation'], playlistId: readDatabasePlaylistId(value), + progress: null, record, }); recordTimeline({ @@ -989,13 +1347,50 @@ export async function installMainCapture( value['type'] === 'cancel' && typeof value['operationId'] === 'string' ) { - const record = operationWorkers.get(value['operationId']); - if (record) { - record.cancelPostedEpochMs = nowEpochMs(); + const playlistRecord = operationWorkers.get( + value['operationId'] + ); + const cancelPostedEpochMs = nowEpochMs(); + if (playlistRecord) { + playlistRecord.cancelPostedEpochMs = + cancelPostedEpochMs; recordTimeline({ operationId: value['operationId'], type: 'playlist-cancel-posted', }); + } else { + const cancelIdentity = + xtreamIpcMarkerCapture.matchDatabaseCancel(value); + const matchingRequests = [ + ...dbRequests.entries(), + ].filter( + ([, request]) => + request.identity.operationId === + value['operationId'] + ); + const match = matchingRequests[0]; + if ( + matchingRequests.length === 1 && + match && + cancelIdentity + ) { + const [requestId, request] = match; + request.record.cancelPostedEpochMs = + cancelPostedEpochMs; + databaseWorkerCancelCapture.acceptDispatch({ + captureGeneration: state.captureGeneration, + ipcCallId: cancelIdentity.ipcCallId, + operationId: cancelIdentity.operationId, + postedEpochMs: cancelPostedEpochMs, + requestId, + sourceEpochMs: cancelIdentity.sourceEpochMs, + }); + } else { + recordTimeline({ + operationId: value['operationId'], + type: 'db-cancel-dispatch-correlation-invalid', + }); + } } } } @@ -1097,13 +1492,32 @@ export async function installMainCapture( state.mainPeakHeap = Math.max(state.mainPeakHeap, memory.heapUsed); state.mainPeakRss = Math.max(state.mainPeakRss, memory.rss); state.rendererWindowSession?.sample(); + for (const record of records.values()) { + if (record.samplingStarted && isCurrentCaptureRecord(record)) { + void sampleWorker(record); + } + } }; - const startCapture = async ( - options: MainCaptureStartOptions - ): Promise => { - state.rendererWindowSession?.detach(); - state.rendererWindowSession = null; - const rendererWindowSession = rendererWindowRssSessionApi.create({ + const beginMeasurement = async (): Promise => { + if ( + !state.active || + state.stopping || + state.measurementPhase !== 'armed' || + state.measurementStartedEpochMs !== null || + state.captureOptions === null + ) { + throw new Error('xtream-main-measurement-boundary-unavailable'); + } + const options = state.captureOptions; + const databaseRecords = options.deferMeasurement + ? [...records.values()].filter( + (record) => record.kind === 'database.worker' + ) + : []; + if (options.deferMeasurement && databaseRecords.length !== 1) { + throw new Error('xtream-database-worker-boundary-unavailable'); + } + state.rendererWindowSession = rendererWindowRssSessionApi.create({ browserWindowFromId: (browserWindowId) => BrowserWindow.fromId(browserWindowId), browserWindowId: options.rendererWindowIdentity.browserWindowId, @@ -1111,30 +1525,6 @@ export async function installMainCapture( rendererRssApi: rendererProcessRssApi, webContentsId: options.rendererWindowIdentity.webContentsId, }); - state.stopping = false; - state.captureGeneration += 1; - state.active = true; - databaseRequestIdentityCapture.start(); - dbRequests.clear(); - operationWorkers.clear(); - state.diagnostic = options.diagnostic; - state.outputDirectory = options.outputDirectory; - state.timeline = []; - state.mainPeakHeap = 0; - state.mainPeakRss = 0; - state.mainProfilePath = null; - state.mainSnapshotPath = null; - state.postGcHeap = null; - state.postGcRss = null; - state.rendererWindowSession = rendererWindowSession; - state.cpuStart = process.cpuUsage(); - state.eventLoopStart = perfHooks.performance.eventLoopUtilization(); - state.eventLoopDelay = perfHooks.monitorEventLoopDelay({ - resolution: 1, - }); - state.eventLoopDelay.enable(); - sampleMain(); - state.sampleTimer = setInterval(sampleMain, 20); if (state.diagnostic) { const session = new inspector.Session(); session.connect(); @@ -1150,6 +1540,64 @@ export async function installMainCapture( 'main.heapsnapshot' ); } + if (typeof runtimeProcess.threadCpuUsage !== 'function') { + throw new Error('electron-main-thread-cpu-unavailable'); + } + state.measurementStartedEpochMs = nowEpochMs(); + state.measurementPhase = 'measuring'; + state.cpuStart = runtimeProcess.threadCpuUsage(); + state.eventLoopStart = perfHooks.performance.eventLoopUtilization(); + state.eventLoopDelay = perfHooks.monitorEventLoopDelay({ + resolution: 1, + }); + state.eventLoopDelay.enable(); + state.mainPeakHeap = 0; + state.mainPeakRss = 0; + for (const record of databaseRecords) { + await startWorker(record); + } + sampleMain(); + state.sampleTimer = setInterval(sampleMain, 20); + return state.measurementStartedEpochMs; + }; + const startCapture = async ( + options: MainCaptureStartOptions + ): Promise => { + const captureStartedEpochMs = nowEpochMs(); + state.rendererWindowSession?.detach(); + state.rendererWindowSession = null; + state.stopping = false; + state.captureGeneration += 1; + state.active = true; + state.captureInvalidReasons = []; + state.captureOptions = { ...options }; + state.captureStartedEpochMs = captureStartedEpochMs; + state.measurementPhase = 'armed'; + state.measurementStartedEpochMs = null; + state.lateEventCount = 0; + databaseRequestIdentityCapture.start(); + databaseWorkerCancelCapture.start(state.captureGeneration); + xtreamIpcMarkerCapture.start( + options.rendererWindowIdentity.webContentsId, + captureStartedEpochMs + ); + dbRequests.clear(); + operationWorkers.clear(); + state.diagnostic = options.diagnostic; + state.outputDirectory = options.outputDirectory; + state.timeline = []; + state.mainPeakHeap = 0; + state.mainPeakRss = 0; + state.mainProfilePath = null; + state.mainSnapshotPath = null; + state.postGcHeap = null; + state.postGcRss = null; + state.cpuStart = null; + state.eventLoopStart = null; + state.eventLoopDelay = null; + if (!options.deferMeasurement) { + await beginMeasurement(); + } }; const api = { @@ -1196,12 +1644,47 @@ export async function installMainCapture( databaseWorkerPostGcCutoffApi.beginCapture(); await startCapture(options); }, + beginMeasurement: async (): Promise => beginMeasurement(), stop: async ( nextOptions?: MainCaptureStartOptions ): Promise => { + const captureCutoffEpochMs = nowEpochMs(); + const completedCaptureGeneration = state.captureGeneration; + const completedCaptureStartedEpochMs = + state.captureStartedEpochMs; + const completedMeasurementStartedEpochMs = + state.measurementStartedEpochMs; + if ( + !Number.isSafeInteger(completedCaptureGeneration) || + completedCaptureGeneration <= 0 || + completedCaptureStartedEpochMs === null || + state.measurementPhase !== 'measuring' || + completedMeasurementStartedEpochMs === null + ) { + throw new Error( + 'xtream-main-capture-generation-unavailable' + ); + } databaseWorkerPostGcCutoffApi.beginStop(); state.stopping = true; state.active = false; + state.measurementPhase = 'frozen'; + const operationOutcomes = readOperationOutcomes(); + const xtreamIpcSnapshot = xtreamIpcMarkerCapture.stop(); + const databaseWorkerCancelSnapshot = + databaseWorkerCancelCapture.stop(); + for (const reason of databaseWorkerCancelSnapshot.invalidReasons) { + state.timeline.push({ + epochMs: nowEpochMs(), + type: `database-worker-cancel-capture-invalid:${reason}`, + }); + } + for (const reason of xtreamIpcSnapshot.invalidReasons) { + state.timeline.push({ + epochMs: nowEpochMs(), + type: `xtream-capture-invalid:${reason}`, + }); + } for (const marker of databaseRequestIdentityCapture.takeSuccessMarkers()) { state.timeline.push({ epochMs: marker.sourceEpochMs, @@ -1223,6 +1706,9 @@ export async function installMainCapture( (record) => record.captureGeneration === state.captureGeneration ); + const currentDatabaseRecords = currentWorkerRecords.filter( + (record) => record.kind === 'database.worker' + ); for (const record of currentWorkerRecords) { stopWorkerSampling(record); } @@ -1235,7 +1721,9 @@ export async function installMainCapture( if (rendererWindow === null) { throw new Error('renderer-window-session-missing'); } - const cpu = process.cpuUsage(state.cpuStart ?? undefined); + const cpu = runtimeProcess.threadCpuUsage( + state.cpuStart ?? undefined + ); const eluEnd = perfHooks.performance.eventLoopUtilization(); const elu = perfHooks.performance.eventLoopUtilization( state.eventLoopStart ?? undefined @@ -1261,9 +1749,20 @@ export async function installMainCapture( ); fs.writeFileSync( state.mainProfilePath, - JSON.stringify(result['profile']) + JSON.stringify(result['profile']), + { encoding: 'utf8', flag: 'wx', mode: 0o600 } ); } + await Promise.all( + currentDatabaseRecords.map((record) => + joinFinalWorkerSample(record) + ) + ); + await Promise.all( + currentDatabaseRecords.map((record) => + stopWorkerProfile(record) + ) + ); await Promise.all( currentWorkerRecords .filter( @@ -1280,9 +1779,6 @@ export async function installMainCapture( currentWorkerRecords, state.captureGeneration ); - const currentDatabaseRecords = currentWorkerRecords.filter( - (record) => record.kind === 'database.worker' - ); if (databaseSelection.selected) { await finalizeDatabaseWorker( databaseSelection.selected, @@ -1305,7 +1801,10 @@ export async function installMainCapture( state.postGcHeap = postGc.heapUsed; state.postGcRss = postGc.rss; if (state.diagnostic && state.mainSnapshotPath) { - const output = fs.createWriteStream(state.mainSnapshotPath); + const output = fs.createWriteStream( + state.mainSnapshotPath, + { flags: 'wx', mode: 0o600 } + ); const onChunk = (message: { params: { chunk: string } }) => output.write(message.params.chunk); session.on('HeapProfiler.addHeapSnapshotChunk', onChunk); @@ -1397,6 +1896,13 @@ export async function installMainCapture( success: request.success, })), })); + state.timeline = performanceTimelineMergeApi.merge( + state.timeline, + [ + ...xtreamIpcSnapshot.timeline, + ...databaseWorkerCancelSnapshot.timeline, + ] + ); const transport: MainCaptureGenerationTransport = { captureGeneration: state.captureGeneration, metrics: { @@ -1430,6 +1936,25 @@ export async function installMainCapture( }, workers, }; + const captureStoppedEpochMs = nowEpochMs(); + const xtream = { + cancelCapture: databaseWorkerCancelSnapshot, + captureCutoffEpochMs, + captureGeneration: completedCaptureGeneration, + captureStartedEpochMs: completedCaptureStartedEpochMs, + captureStoppedEpochMs, + databaseWorkerCount: currentDatabaseRecords.length, + invalidReasons: [...state.captureInvalidReasons], + ipcCapture: xtreamIpcSnapshot, + lateDatabaseRequestCount: cutoff.lateRequestCount, + lateEventCount: state.lateEventCount, + measurementStartedEpochMs: + completedMeasurementStartedEpochMs, + operationOutcomes, + playlistRefreshWorkerCount: currentWorkerRecords.filter( + ({ kind }) => kind === 'playlist-refresh.worker' + ).length, + }; let rollover: MainCaptureRolloverStatus | null = null; if (nextOptions) { const databaseRecord = @@ -1475,13 +2000,425 @@ export async function installMainCapture( databaseWorkerPostGcCutoffApi.finishStop(); state.stopping = false; } - return { ...transport, rollover }; + return { ...transport, rollover, xtream }; + }, + xtreamStatus: (): XtreamMainCaptureStatus => { + if ( + !Number.isSafeInteger(state.captureGeneration) || + state.captureGeneration <= 0 || + state.captureStartedEpochMs === null + ) { + throw new Error( + 'xtream-main-capture-generation-unavailable' + ); + } + const cutoff = databaseWorkerPostGcCutoffApi.snapshot(); + const currentWorkers = [...records.values()].filter( + (record) => + record.captureGeneration === state.captureGeneration + ); + return { + active: state.active, + activeOperations: [...dbRequests.entries()] + .map(([requestId, request]) => ({ + operation: request.operation, + operationId: request.identity.operationId, + playlistId: request.playlistId, + progress: request.progress + ? { ...request.progress } + : null, + requestId, + })) + .sort( + (left, right) => + left.operation.localeCompare(right.operation) || + left.requestId.localeCompare(right.requestId) + ), + captureGeneration: state.captureGeneration, + captureStartedEpochMs: state.captureStartedEpochMs, + cutoffPhase: cutoff.phase, + databaseWorkerCount: currentWorkers.filter( + ({ kind }) => kind === 'database.worker' + ).length, + invalidReasons: [...state.captureInvalidReasons], + lateDatabaseRequestCount: cutoff.lateRequestCount, + lateEventCount: state.lateEventCount, + measurementPhase: state.measurementPhase, + measurementStartedEpochMs: state.measurementStartedEpochMs, + operationOutcomes: readOperationOutcomes(), + playlistRefreshWorkerCount: currentWorkers.filter( + ({ kind }) => kind === 'playlist-refresh.worker' + ).length, + }; }, }; target[input.stateKey] = api; }, captureStateKeys); } +const XTREAM_CAPTURE_OPERATION_PATTERN = /^DB_[A-Z0-9_]{1,124}$/; +const XTREAM_CAPTURE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const XTREAM_CAPTURE_REASON_PATTERN = /^[a-z0-9][a-z0-9:-]{0,159}$/; +const XTREAM_IPC_TIMELINE_TYPES = new Set([ + 'xtream-main-phase', + 'xtream-preload-marker', +]); +const XTREAM_CANCEL_TIMELINE_TYPES = new Set([ + 'db-cancel-dispatched', + 'db-cancel-received', + 'db-cancel-terminal-received', +]); + +function xtreamCaptureInvalid(kind: 'status' | 'transport'): never { + throw new Error(`xtream-main-capture-${kind}-invalid`); +} + +function isFinitePositive(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function snapshotXtreamReasons( + input: readonly string[], + kind: 'status' | 'transport' +): readonly string[] { + if ( + !Array.isArray(input) || + input.some( + (reason) => + typeof reason !== 'string' || + !XTREAM_CAPTURE_REASON_PATTERN.test(reason) + ) + ) { + xtreamCaptureInvalid(kind); + } + return Object.freeze([...input]); +} + +function snapshotXtreamOperationOutcomes( + input: readonly XtreamMainCaptureOperationOutcome[], + kind: 'status' | 'transport' +): readonly XtreamMainCaptureOperationOutcome[] { + if (!Array.isArray(input)) xtreamCaptureInvalid(kind); + const seen = new Set(); + const outcomes = input.map((entry) => { + if ( + typeof entry !== 'object' || + entry === null || + !XTREAM_CAPTURE_OPERATION_PATTERN.test(entry.operation) || + !['error', 'pending', 'success'].includes(entry.outcome) || + !Number.isSafeInteger(entry.count) || + entry.count <= 0 + ) { + xtreamCaptureInvalid(kind); + } + const key = `${entry.operation}:${entry.outcome}`; + if (seen.has(key)) xtreamCaptureInvalid(kind); + seen.add(key); + return Object.freeze({ ...entry }); + }); + return Object.freeze(outcomes); +} + +function snapshotXtreamProgress( + input: XtreamMainCaptureOperationProgress | null, + captureStartedEpochMs: number +): XtreamMainCaptureOperationProgress | null { + if (input === null) return null; + if ( + typeof input !== 'object' || + !isFinitePositive(input.epochMs) || + input.epochMs < captureStartedEpochMs || + !['cancelled', 'completed', 'error', 'progress', 'started'].includes( + input.status + ) || + !( + input.phase === null || /^[a-z][a-z0-9-]{0,127}$/.test(input.phase) + ) || + !( + input.current === null || + (Number.isFinite(input.current) && input.current >= 0) + ) || + !( + input.total === null || + (Number.isFinite(input.total) && input.total >= 0) + ) + ) { + xtreamCaptureInvalid('status'); + } + return Object.freeze({ ...input }); +} + +function snapshotXtreamStatus( + input: XtreamMainCaptureStatus +): XtreamMainCaptureStatus { + if ( + typeof input !== 'object' || + input === null || + typeof input.active !== 'boolean' || + !Number.isSafeInteger(input.captureGeneration) || + input.captureGeneration <= 0 || + !isFinitePositive(input.captureStartedEpochMs) || + !['armed', 'frozen', 'measuring'].includes(input.measurementPhase) || + !( + (input.measurementPhase === 'armed' && + input.measurementStartedEpochMs === null) || + (input.measurementPhase !== 'armed' && + isFinitePositive(input.measurementStartedEpochMs) && + input.measurementStartedEpochMs >= input.captureStartedEpochMs) + ) || + !['active', 'idle', 'stopping'].includes(input.cutoffPhase) || + !isNonNegativeSafeInteger(input.databaseWorkerCount) || + !isNonNegativeSafeInteger(input.playlistRefreshWorkerCount) || + !isNonNegativeSafeInteger(input.lateDatabaseRequestCount) || + !isNonNegativeSafeInteger(input.lateEventCount) || + input.lateEventCount !== input.lateDatabaseRequestCount || + !Array.isArray(input.activeOperations) + ) { + xtreamCaptureInvalid('status'); + } + const activeOperations = input.activeOperations.map((entry) => { + if ( + typeof entry !== 'object' || + entry === null || + !XTREAM_CAPTURE_OPERATION_PATTERN.test(entry.operation) || + !XTREAM_CAPTURE_IDENTIFIER_PATTERN.test(entry.requestId) || + !( + entry.operationId === null || + XTREAM_CAPTURE_IDENTIFIER_PATTERN.test(entry.operationId) + ) || + !( + entry.playlistId === null || + XTREAM_CAPTURE_IDENTIFIER_PATTERN.test(entry.playlistId) + ) + ) { + xtreamCaptureInvalid('status'); + } + return Object.freeze({ + ...entry, + progress: snapshotXtreamProgress( + entry.progress, + input.captureStartedEpochMs + ), + }); + }); + return Object.freeze({ + ...input, + activeOperations: Object.freeze(activeOperations), + invalidReasons: snapshotXtreamReasons(input.invalidReasons, 'status'), + operationOutcomes: snapshotXtreamOperationOutcomes( + input.operationOutcomes, + 'status' + ), + }); +} + +function snapshotXtreamIpcTimeline( + input: XtreamIpcMarkerCaptureSnapshot, + startedEpochMs: number, + cutoffEpochMs: number +): readonly XtreamIpcTimelineRecord[] { + if (!Array.isArray(input.timeline)) xtreamCaptureInvalid('transport'); + return Object.freeze( + input.timeline.map((record) => { + if ( + typeof record !== 'object' || + record === null || + !XTREAM_IPC_TIMELINE_TYPES.has(record.type) || + !['end', 'start'].includes(record.boundary) || + !isFinitePositive(record.epochMs) || + record.epochMs < startedEpochMs || + record.epochMs > cutoffEpochMs + ) { + xtreamCaptureInvalid('transport'); + } + return Object.freeze({ ...record }); + }) + ); +} + +function snapshotXtreamCancelTimeline( + input: DatabaseWorkerCancelCaptureSnapshot, + startedEpochMs: number, + cutoffEpochMs: number +): readonly DatabaseWorkerCancelTimelineRecord[] { + if (!Array.isArray(input.timeline)) xtreamCaptureInvalid('transport'); + return Object.freeze( + input.timeline.map((record) => { + if ( + typeof record !== 'object' || + record === null || + !XTREAM_CANCEL_TIMELINE_TYPES.has(record.type) || + !isFinitePositive(record.epochMs) || + record.epochMs < startedEpochMs || + record.epochMs > cutoffEpochMs || + !Number.isSafeInteger(record.ipcCallId) || + record.ipcCallId <= 0 || + !XTREAM_CAPTURE_IDENTIFIER_PATTERN.test(record.operationId) || + !XTREAM_CAPTURE_IDENTIFIER_PATTERN.test(record.requestId) || + !isFinitePositive(record.sourceEpochMs) || + record.sourceEpochMs > record.epochMs + ) { + xtreamCaptureInvalid('transport'); + } + return Object.freeze({ ...record }); + }) + ); +} + +function snapshotXtreamResult( + input: XtreamMainCaptureTransport +): XtreamMainCaptureResult { + try { + const evidence = input.xtream; + if ( + !Number.isSafeInteger(input.captureGeneration) || + input.captureGeneration <= 0 || + evidence.captureGeneration !== input.captureGeneration || + !isFinitePositive(evidence.captureStartedEpochMs) || + !isFinitePositive(evidence.measurementStartedEpochMs) || + !isFinitePositive(evidence.captureCutoffEpochMs) || + !isFinitePositive(evidence.captureStoppedEpochMs) || + evidence.captureCutoffEpochMs < evidence.captureStartedEpochMs || + evidence.measurementStartedEpochMs < + evidence.captureStartedEpochMs || + evidence.captureCutoffEpochMs < + evidence.measurementStartedEpochMs || + evidence.captureStoppedEpochMs < evidence.captureCutoffEpochMs || + !isNonNegativeSafeInteger(evidence.databaseWorkerCount) || + !isNonNegativeSafeInteger(evidence.playlistRefreshWorkerCount) || + !isNonNegativeSafeInteger( + evidence.ipcCapture.quarantinedKeyCount + ) || + !isNonNegativeSafeInteger(evidence.lateDatabaseRequestCount) || + !isNonNegativeSafeInteger(evidence.lateEventCount) || + evidence.lateEventCount !== evidence.lateDatabaseRequestCount + ) { + xtreamCaptureInvalid('transport'); + } + const capture = selectMainCaptureGeneration(input); + const workers = Object.freeze([...capture.workers]); + const requests = Object.freeze( + workers.flatMap((worker) => worker.requests) + ); + if ( + workers.filter(({ kind }) => kind === 'database.worker').length !== + evidence.databaseWorkerCount || + workers.filter(({ kind }) => kind === 'playlist-refresh.worker') + .length !== evidence.playlistRefreshWorkerCount + ) { + xtreamCaptureInvalid('transport'); + } + const rollover = + input.rollover === null + ? null + : Object.freeze({ ...input.rollover }); + if ( + rollover !== null && + (typeof rollover.nextCaptureStarted !== 'boolean' || + !( + rollover.nextCaptureUnavailableReason === null || + typeof rollover.nextCaptureUnavailableReason === 'string' + )) + ) { + xtreamCaptureInvalid('transport'); + } + const operationOutcomes = snapshotXtreamOperationOutcomes( + evidence.operationOutcomes, + 'transport' + ); + const completedOutcomes = new Map(); + for (const worker of workers) { + if (worker.kind !== 'database.worker') continue; + for (const request of worker.requests) { + if ( + typeof request.operation !== 'string' || + !XTREAM_CAPTURE_OPERATION_PATTERN.test(request.operation) + ) { + xtreamCaptureInvalid('transport'); + } + const key = `${request.operation}:${ + request.success ? 'success' : 'error' + }`; + completedOutcomes.set( + key, + (completedOutcomes.get(key) ?? 0) + 1 + ); + } + } + const capturedCompletedOutcomes = new Map( + operationOutcomes + .filter(({ outcome }) => outcome !== 'pending') + .map(({ count, operation, outcome }) => [ + `${operation}:${outcome}`, + count, + ]) + ); + if ( + completedOutcomes.size !== capturedCompletedOutcomes.size || + [...completedOutcomes].some( + ([key, count]) => capturedCompletedOutcomes.get(key) !== count + ) + ) { + xtreamCaptureInvalid('transport'); + } + const ipcTimeline = snapshotXtreamIpcTimeline( + evidence.ipcCapture, + evidence.captureStartedEpochMs, + evidence.captureCutoffEpochMs + ); + const cancelTimeline = snapshotXtreamCancelTimeline( + evidence.cancelCapture, + evidence.captureStartedEpochMs, + evidence.captureCutoffEpochMs + ); + return Object.freeze({ + cancelTimeline, + capture: Object.freeze({ ...capture, workers }), + captureCutoffEpochMs: evidence.captureCutoffEpochMs, + captureGeneration: evidence.captureGeneration, + captureStartedEpochMs: evidence.captureStartedEpochMs, + captureStoppedEpochMs: evidence.captureStoppedEpochMs, + databaseWorkerCount: evidence.databaseWorkerCount, + invalidReasons: Object.freeze({ + capture: snapshotXtreamReasons( + evidence.invalidReasons, + 'transport' + ), + databaseWorkerCancel: snapshotXtreamReasons( + evidence.cancelCapture.invalidReasons, + 'transport' + ), + xtreamIpc: snapshotXtreamReasons( + evidence.ipcCapture.invalidReasons, + 'transport' + ), + }), + ipcTimeline, + lateDatabaseRequestCount: evidence.lateDatabaseRequestCount, + lateEventCount: evidence.lateEventCount, + measurementStartedEpochMs: evidence.measurementStartedEpochMs, + operationOutcomes, + playlistRefreshWorkerCount: evidence.playlistRefreshWorkerCount, + quarantinedIpcMarkerCount: evidence.ipcCapture.quarantinedKeyCount, + requests, + rollover, + workers, + }); + } catch (error) { + if ( + error instanceof Error && + error.message === 'xtream-main-capture-transport-invalid' + ) { + throw error; + } + return xtreamCaptureInvalid('transport'); + } +} + export async function startMainCapture( electronApp: ElectronApplication, options: MainCaptureStartOptions @@ -1498,6 +2435,18 @@ export async function startMainCapture( ); } +export async function beginXtreamMainMeasurement( + electronApp: ElectronApplication +): Promise { + return electronApp.evaluate(async (_electron, stateKey) => { + const target = globalThis as unknown as Record; + const api = target[stateKey] as { + beginMeasurement(): Promise; + }; + return api.beginMeasurement(); + }, MAIN_CAPTURE_STATE_KEY); +} + export async function readMainCaptureStatus( electronApp: ElectronApplication ): Promise { @@ -1510,6 +2459,19 @@ export async function readMainCaptureStatus( }, MAIN_CAPTURE_STATE_KEY); } +export async function readXtreamMainCaptureStatus( + electronApp: ElectronApplication +): Promise { + const status = await electronApp.evaluate((_electron, stateKey) => { + const target = globalThis as unknown as Record; + const api = target[stateKey] as { + xtreamStatus(): XtreamMainCaptureStatus; + }; + return api.xtreamStatus(); + }, MAIN_CAPTURE_STATE_KEY); + return snapshotXtreamStatus(status); +} + export async function rolloverMainCapture( electronApp: ElectronApplication, options: MainCaptureStartOptions @@ -1552,3 +2514,46 @@ export async function stopMainCapture( ); return selectMainCaptureGeneration(transport); } + +export async function rolloverXtreamMainCapture( + electronApp: ElectronApplication, + options: MainCaptureStartOptions +): Promise { + const transport = await electronApp.evaluate( + async (_electron, input) => { + const target = globalThis as unknown as Record; + const api = target[input.stateKey] as { + stop( + nextOptions?: MainCaptureStartOptions + ): Promise; + }; + return api.stop(input.options); + }, + { options, stateKey: MAIN_CAPTURE_STATE_KEY } + ); + const result = snapshotXtreamResult(transport); + if (result.rollover === null) { + throw new Error('xtream-main-capture-rollover-status-missing'); + } + return result; +} + +export async function stopXtreamMainCapture( + electronApp: ElectronApplication +): Promise { + const transport = await electronApp.evaluate( + async (_electron, stateKey) => { + const target = globalThis as unknown as Record; + const api = target[stateKey] as { + stop(): Promise; + }; + return api.stop(); + }, + MAIN_CAPTURE_STATE_KEY + ); + const result = snapshotXtreamResult(transport); + if (result.rollover !== null) { + throw new Error('xtream-main-capture-stop-rollover-unexpected'); + } + return result; +} diff --git a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts index a759db490..2c574b3aa 100644 --- a/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts +++ b/apps/electron-backend-e2e/src/performance/m3u-refresh-renderer-capture.ts @@ -1,8 +1,8 @@ /* eslint-disable max-lines -- Renderer CDP and injected-page lifecycle are kept together so raw artifact boundaries stay auditable. */ -import { once } from 'node:events'; import { createWriteStream } from 'node:fs'; import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { finished } from 'node:stream/promises'; import type { CDPSession, Page } from '@playwright/test'; @@ -106,7 +106,8 @@ export async function startRendererCapture( await session.send('Profiler.enable'); await session.send('Profiler.start'); traceOutput = createWriteStream( - requireArtifactPath(tracePath, 'renderer trace') + requireArtifactPath(tracePath, 'renderer trace'), + { flags: 'wx', mode: 0o600 } ); traceOutput.write('{"traceEvents":['); session.on('Tracing.dataCollected', onTraceData); @@ -143,7 +144,7 @@ export async function startRendererCapture( await writeFile( requireArtifactPath(cpuProfilePath, 'renderer CPU profile'), JSON.stringify(cpuProfile['profile']), - 'utf8' + { encoding: 'utf8', flag: 'wx', mode: 0o600 } ); await session.send('Tracing.end'); await traceComplete; @@ -152,7 +153,7 @@ export async function startRendererCapture( traceOutput?.write(']}'); traceOutput?.end(); if (traceOutput) { - await once(traceOutput, 'finish'); + await finished(traceOutput); } } @@ -167,7 +168,8 @@ export async function startRendererCapture( requireArtifactPath( heapSnapshotPath, 'renderer heap snapshot' - ) + ), + { flags: 'wx', mode: 0o600 } ); const onSnapshotChunk = (message: { readonly chunk: string; @@ -186,7 +188,7 @@ export async function startRendererCapture( onSnapshotChunk ); output.end(); - await once(output, 'finish'); + await finished(output); } await session.detach(); @@ -211,8 +213,7 @@ export async function startRendererCapture( unknown >; const state = target[stateKey] as - | { cancelClickEpochMs: number | null } - | undefined; + { cancelClickEpochMs: number | null } | undefined; return typeof state?.cancelClickEpochMs === 'number'; }, RENDERER_CAPTURE_STATE_KEY, @@ -228,8 +229,7 @@ export async function startRendererCapture( unknown >; const state = target[stateKey] as - | { uiPaintedEpochMs: number | null } - | undefined; + { uiPaintedEpochMs: number | null } | undefined; return typeof state?.uiPaintedEpochMs === 'number'; }, RENDERER_CAPTURE_STATE_KEY, diff --git a/apps/electron-backend-e2e/src/performance/main-capture-sampling-scheduler.spec.ts b/apps/electron-backend-e2e/src/performance/main-capture-sampling-scheduler.spec.ts new file mode 100644 index 000000000..38dee2517 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/main-capture-sampling-scheduler.spec.ts @@ -0,0 +1,81 @@ +/* eslint-disable playwright/expect-expect -- This is a Node assertion-based performance contract test. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' +); + +function section(startMarker: string, endMarker: string): string { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start); + assert.ok(start >= 0, `missing start marker: ${startMarker}`); + assert.ok(end > start, `missing end marker: ${endMarker}`); + return source.slice(start, end); +} + +test('samples main and worker memory from one shared interval scheduler', () => { + const workerRecord = section( + 'interface WorkerRecord', + 'interface TimelineRecord' + ); + const resetWorker = section( + 'const resetWorkerForCapture', + 'const startWorker' + ); + const startWorker = section( + 'const startWorker', + 'const stopWorkerSampling' + ); + const stopWorker = section( + 'const stopWorkerSampling', + 'const joinFinalWorkerSample' + ); + const joinFinalWorker = section( + 'const joinFinalWorkerSample', + 'const stopWorkerProfile' + ); + const sampleMain = section('const sampleMain', 'const startCapture'); + const beginMeasurement = section( + 'const beginMeasurement = async', + 'const startCapture' + ); + const armCapture = section('const startCapture', 'const api ='); + const stopCapture = section( + 'stop: async (', + 'xtreamStatus: (): XtreamMainCaptureStatus' + ); + + assert.match(workerRecord, /samplingStarted: boolean/); + assert.doesNotMatch(workerRecord, /sampleTimer/); + assert.match(resetWorker, /record\.samplingStarted = false/); + assert.match(startWorker, /if \(record\.samplingStarted\)/); + assert.match( + startWorker, + /record\.samplingStarted = true[\s\S]*const initialSample = sampleWorker\(record\)/ + ); + assert.doesNotMatch(startWorker, /setInterval/); + assert.match(stopWorker, /record\.samplingStarted = false/); + assert.match(joinFinalWorker, /await sampleWorker\(record\)/); + assert.match( + sampleMain, + /for \(const record of records\.values\(\)\)[\s\S]*record\.samplingStarted &&[\s\S]*isCurrentCaptureRecord\(record\)[\s\S]*void sampleWorker\(record\)/ + ); + assert.match( + beginMeasurement, + /sampleMain\(\)[\s\S]*state\.sampleTimer = setInterval\(sampleMain, 20\)/ + ); + assert.doesNotMatch(armCapture, /setInterval\(sampleMain/); + assert.match( + stopCapture, + /clearInterval\(state\.sampleTimer\)[\s\S]*state\.sampleTimer = null[\s\S]*await startCapture\(nextOptions\)/ + ); + + assert.equal( + source.match(/\bsetInterval\s*\(/g)?.length, + 1, + 'the injected main capture must own exactly one interval timer' + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/performance-phase-duration-envelope.ts b/apps/electron-backend-e2e/src/performance/performance-phase-duration-envelope.ts new file mode 100644 index 000000000..727c7dc21 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-phase-duration-envelope.ts @@ -0,0 +1,18 @@ +/** + * Phase duration and epoch endpoints are read from separate clocks in separate + * calls. The enclosing request/operation window is the causal upper bound. + */ +export function fitsPerformanceDurationEnvelope( + durationMs: number, + envelopeStartEpochMs: number, + envelopeEndEpochMs: number +): boolean { + return ( + Number.isFinite(durationMs) && + durationMs >= 0 && + Number.isFinite(envelopeStartEpochMs) && + Number.isFinite(envelopeEndEpochMs) && + envelopeEndEpochMs >= envelopeStartEpochMs && + durationMs <= envelopeEndEpochMs - envelopeStartEpochMs + ); +} diff --git a/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts b/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts index dd471db9b..d6b32dd5f 100644 --- a/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts +++ b/apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts @@ -2,11 +2,74 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { + createDiagnosticsPerformancePhaseEventParser, pairPerformancePhaseEvents, requirePerformancePhase, } from './performance-phase-events'; describe('performance phase event pairing', () => { + it('restores and invokes the diagnostics parser without module-scoped helpers', () => { + const restore = (source: string): T => + new Function(`"use strict"; return (${source});`)() as T; + const restored = restore< + typeof createDiagnosticsPerformancePhaseEventParser + >(createDiagnosticsPerformancePhaseEventParser.toString()); + const parse = restored({ + allowedPhases: ['xtream.network.total'], + timelineType: 'xtream-main-phase', + }); + + assert.deepEqual( + parse({ + boundary: 'start', + durationMs: null, + epochMs: 100, + phase: 'xtream.network.total', + requestId: 'xtream-mock1-1', + }), + { + boundary: 'start', + durationMs: null, + epochMs: 100, + phase: 'xtream.network.total', + requestId: 'xtream-mock1-1', + type: 'xtream-main-phase', + } + ); + }); + + it('keeps the valid M3U diagnostics normalization byte-identical when subscriptions are parameterized', () => { + const parse = createDiagnosticsPerformancePhaseEventParser({ + allowedPhases: ['data.acquire', 'parse.m3u', 'normalize'], + timelineType: 'm3u-import-phase', + }); + const incoming = { + boundary: 'end', + durationMs: 12.5, + epochMs: 113, + metadata: { byteCount: 2048, itemCount: 10 }, + phase: 'data.acquire', + requestId: 'request-1', + }; + const legacyNormalized = { + boundary: 'end', + byteCount: 2048, + durationMs: 12.5, + epochMs: 113, + itemCount: 10, + phase: 'data.acquire', + requestId: 'request-1', + type: 'm3u-import-phase', + }; + + assert.equal( + JSON.stringify(parse(incoming)), + JSON.stringify(legacyNormalized) + ); + assert.equal(parse({ ...incoming, secret: 'not allowed' }), null); + assert.equal(parse({ ...incoming, metadata: { itemCount: -1 } }), null); + }); + it('pairs request-scoped boundaries and preserves end metadata', () => { const phases = pairPerformancePhaseEvents([ { diff --git a/apps/electron-backend-e2e/src/performance/performance-phase-events.ts b/apps/electron-backend-e2e/src/performance/performance-phase-events.ts index 15468b018..599062d9c 100644 --- a/apps/electron-backend-e2e/src/performance/performance-phase-events.ts +++ b/apps/electron-backend-e2e/src/performance/performance-phase-events.ts @@ -18,6 +18,107 @@ interface ParsedPerformancePhaseEvent { readonly requestId: string; } +export interface DiagnosticsPerformancePhaseEventParserOptions { + readonly allowedPhases: readonly string[]; + readonly timelineType: string; +} + +export interface DiagnosticsPerformancePhaseTimelineEvent { + readonly boundary: 'end' | 'start'; + readonly byteCount?: number; + readonly durationMs: number | null; + readonly epochMs: number; + readonly itemCount?: number; + readonly phase: string; + readonly requestId: string; + readonly type: string; +} + +export function createDiagnosticsPerformancePhaseEventParser( + options: DiagnosticsPerformancePhaseEventParserOptions +): (input: unknown) => DiagnosticsPerformancePhaseTimelineEvent | null { + const allowedPhases = new Set(options.allowedPhases); + const eventKeys = new Set([ + 'boundary', + 'durationMs', + 'epochMs', + 'metadata', + 'phase', + 'requestId', + ]); + const metadataKeys = new Set(['byteCount', 'itemCount']); + const helpers = { + isCount(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; + }, + isRecord(value: unknown): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ); + }, + }; + + return (input: unknown) => { + if ( + !helpers.isRecord(input) || + Object.keys(input).some((key) => !eventKeys.has(key)) + ) { + return null; + } + const boundary = input['boundary']; + const durationMs = input['durationMs']; + const epochMs = input['epochMs']; + const metadata = input['metadata']; + const phase = input['phase']; + const requestId = input['requestId']; + if ( + (boundary !== 'start' && boundary !== 'end') || + typeof epochMs !== 'number' || + !Number.isFinite(epochMs) || + epochMs < 0 || + typeof phase !== 'string' || + !allowedPhases.has(phase) || + typeof requestId !== 'string' || + requestId.length === 0 || + (boundary === 'start' && durationMs !== null) || + (boundary === 'end' && + (typeof durationMs !== 'number' || + !Number.isFinite(durationMs) || + durationMs < 0)) + ) { + return null; + } + if ( + metadata !== undefined && + (!helpers.isRecord(metadata) || + Object.keys(metadata).some((key) => !metadataKeys.has(key)) || + Object.values(metadata).some( + (value) => !helpers.isCount(value) + )) + ) { + return null; + } + const byteCount = helpers.isRecord(metadata) + ? metadata['byteCount'] + : undefined; + const itemCount = helpers.isRecord(metadata) + ? metadata['itemCount'] + : undefined; + return { + boundary, + ...(helpers.isCount(byteCount) ? { byteCount } : {}), + durationMs: durationMs as number | null, + epochMs, + ...(helpers.isCount(itemCount) ? { itemCount } : {}), + phase, + requestId, + type: options.timelineType, + }; + }; +} + export function pairPerformancePhaseEvents( input: readonly unknown[] ): readonly PairedPerformancePhase[] { diff --git a/apps/electron-backend-e2e/src/performance/performance-raw-artifact-permissions.spec.ts b/apps/electron-backend-e2e/src/performance/performance-raw-artifact-permissions.spec.ts new file mode 100644 index 000000000..d913f8233 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-raw-artifact-permissions.spec.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, test } from 'node:test'; + +import { + createRendererArtifactCapture, + startRendererDiagnosticCapture, + stopRendererDiagnosticCapture, + takeRendererHeapSnapshot, +} from './m3u-import-renderer-artifacts'; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +test('creates renderer raw profiles exclusively with owner-only permissions', async () => { + const directory = await createDirectory(); + const session = new FakeCdpSession(); + const artifacts = createRendererArtifactCapture({ + diagnostic: true, + outputDirectory: directory, + }); + + await startRendererDiagnosticCapture(session as never, artifacts); + await stopRendererDiagnosticCapture(session as never, artifacts); + await takeRendererHeapSnapshot( + session as never, + artifacts.heapSnapshotPath + ); + + for (const filename of [ + 'renderer.cpuprofile', + 'renderer.heapsnapshot', + 'renderer.trace.json', + ]) { + assert.equal( + (await stat(join(directory, filename))).mode & 0o777, + 0o600, + filename + ); + } +}); + +test('never replaces an existing renderer CPU profile', async () => { + const directory = await createDirectory(); + const profilePath = join(directory, 'renderer.cpuprofile'); + await writeFile(profilePath, 'original', 'utf8'); + const session = new FakeCdpSession(); + const artifacts = createRendererArtifactCapture({ + diagnostic: true, + outputDirectory: directory, + }); + + await startRendererDiagnosticCapture(session as never, artifacts); + await assert.rejects( + stopRendererDiagnosticCapture(session as never, artifacts), + { code: 'EEXIST' } + ); + assert.equal(await readFile(profilePath, 'utf8'), 'original'); +}); + +test('uses exclusive owner-only writes for main and worker raw artifacts', async () => { + const source = await readFile( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + + assert.match( + source, + /fs\.writeFileSync\(\s*record\.profilePath,[\s\S]{0,220}flag: 'wx',[\s\S]{0,80}mode: 0o600/ + ); + assert.match( + source, + /fs\.createWriteStream\(record\.snapshotPath,\s*\{[\s\S]{0,80}flags: 'wx',[\s\S]{0,80}mode: 0o600/ + ); + assert.match( + source, + /fs\.writeFileSync\(\s*state\.mainProfilePath,[\s\S]{0,220}flag: 'wx',[\s\S]{0,80}mode: 0o600/ + ); + assert.match( + source, + /fs\.createWriteStream\(\s*state\.mainSnapshotPath,\s*\{[\s\S]{0,80}flags: 'wx',[\s\S]{0,80}mode: 0o600/ + ); +}); + +class FakeCdpSession extends EventEmitter { + async send(method: string): Promise> { + if (method === 'Profiler.stop') { + return { + profile: { + endTime: 2, + nodes: [{ callFrame: {}, hitCount: 1, id: 1 }], + samples: [1], + startTime: 1, + timeDeltas: [1], + }, + }; + } + if (method === 'Tracing.end') { + this.emit('Tracing.tracingComplete'); + } + if (method === 'HeapProfiler.takeHeapSnapshot') { + this.emit('HeapProfiler.addHeapSnapshotChunk', { + chunk: '{"snapshot":{}}', + }); + } + return {}; + } +} + +async function createDirectory(): Promise { + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-performance-artifact-') + ); + directories.push(directory); + return directory; +} diff --git a/apps/electron-backend-e2e/src/performance/performance-timeline-merge.spec.ts b/apps/electron-backend-e2e/src/performance/performance-timeline-merge.spec.ts new file mode 100644 index 000000000..158e20ca5 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-timeline-merge.spec.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + createPerformanceTimelineMergeApi, + type PerformanceTimelineMergeApi, +} from './performance-timeline-merge'; + +describe('performance timeline merge', () => { + it('restores without module closures for Electron-main injection', () => { + const source = createPerformanceTimelineMergeApi.toString(); + assert.doesNotMatch(source, /__name/); + const factory = new Function( + `"use strict"; return (${source});` + )() as typeof createPerformanceTimelineMergeApi; + assert.equal(factory().merge([], []).length, 0); + }); + + it('inserts Xtream records by epoch without reordering existing M3U JSON', () => { + const api: PerformanceTimelineMergeApi = + createPerformanceTimelineMergeApi(); + const baseline = [ + { epochMs: 100, type: 'm3u-a' }, + { epochMs: 300, type: 'm3u-b' }, + { epochMs: 300, type: 'm3u-c' }, + ]; + const additions = [ + { epochMs: 300, type: 'xtream-c' }, + { epochMs: 50, type: 'xtream-a' }, + { epochMs: 200, type: 'xtream-b' }, + { epochMs: 400, type: 'xtream-d' }, + ]; + + const merged = api.merge(baseline, additions); + + assert.deepEqual( + merged.map(({ type }) => type), + [ + 'xtream-a', + 'm3u-a', + 'xtream-b', + 'm3u-b', + 'm3u-c', + 'xtream-c', + 'xtream-d', + ] + ); + assert.equal( + JSON.stringify(merged.filter(({ type }) => type.startsWith('m3u'))), + JSON.stringify(baseline) + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/performance-timeline-merge.ts b/apps/electron-backend-e2e/src/performance/performance-timeline-merge.ts new file mode 100644 index 000000000..2b13a3cbe --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-timeline-merge.ts @@ -0,0 +1,62 @@ +export interface PerformanceTimelineRecord { + readonly epochMs: number; +} + +export interface PerformanceTimelineMergeApi { + merge( + baseline: readonly T[], + additions: readonly T[] + ): T[]; +} + +export function createPerformanceTimelineMergeApi(): PerformanceTimelineMergeApi { + const helpers = { + compare( + left: { + readonly index: number; + readonly record: PerformanceTimelineRecord; + }, + right: { + readonly index: number; + readonly record: PerformanceTimelineRecord; + } + ): number { + return ( + left.record.epochMs - right.record.epochMs || + left.index - right.index + ); + }, + }; + const api: PerformanceTimelineMergeApi = { + merge( + baseline: readonly T[], + additions: readonly T[] + ): T[] { + const indexed: Array<{ index: number; record: T }> = []; + for (let index = 0; index < additions.length; index += 1) { + indexed.push({ index, record: additions[index] as T }); + } + indexed.sort(helpers.compare); + + const merged: T[] = []; + let additionIndex = 0; + for (const record of baseline) { + while ( + additionIndex < indexed.length && + (indexed[additionIndex]?.record.epochMs ?? + Number.POSITIVE_INFINITY) < record.epochMs + ) { + merged.push(indexed[additionIndex]?.record as T); + additionIndex += 1; + } + merged.push(record); + } + while (additionIndex < indexed.length) { + merged.push(indexed[additionIndex]?.record as T); + additionIndex += 1; + } + return merged; + }, + }; + return Object.freeze(api); +} diff --git a/apps/electron-backend-e2e/src/performance/worker-performance-phase-duration.spec.ts b/apps/electron-backend-e2e/src/performance/worker-performance-phase-duration.spec.ts new file mode 100644 index 000000000..4331ac52c --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-performance-phase-duration.spec.ts @@ -0,0 +1,107 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseWorkerPerformancePhaseEvents } from './worker-performance-phase-events'; +import { parseXtreamWorkerPerformancePhaseEvents } from './xtream-worker-phase-events'; + +function phasePair( + phase: string, + startEpochMs: number, + durationMs: number, + requestId = 'request-1' +) { + return [ + { + boundary: 'start', + durationMs: null, + epochMs: startEpochMs, + phase, + requestId, + }, + { + boundary: 'end', + durationMs, + epochMs: startEpochMs + 1, + metadata: { itemCount: 1 }, + phase, + requestId, + }, + ]; +} + +test('accepts producer-shaped worker output when separately read clocks have a wider monotonic duration', () => { + const events = phasePair('sqlite.categories.read', 11, 1.6115); + const parsed = parseXtreamWorkerPerformancePhaseEvents( + 'DB_GET_CATEGORIES', + events, + 10, + 30, + true + ); + assert.deepEqual(parsed, events); + assert.equal(Number(events[1]?.epochMs) - Number(events[0]?.epochMs), 1); +}); + +test('rejects an Xtream worker duration outside its request envelope', () => { + assert.equal( + parseXtreamWorkerPerformancePhaseEvents( + 'DB_GET_CATEGORIES', + phasePair('sqlite.categories.read', 11, 999), + 10, + 30, + true + ), + null + ); +}); + +test('rejects an out-of-envelope duration in a failed partial DB_SAVE_CONTENT capture', () => { + const events = [ + ...phasePair('sqlite.content.category-map-read', 11, 1), + ...phasePair('normalize.content', 13, 999), + ]; + + assert.equal( + parseXtreamWorkerPerformancePhaseEvents( + 'DB_SAVE_CONTENT', + events, + 10, + 30, + false + ), + null + ); +}); + +test('accepts cross-clock skew but rejects an app-playlist duration outside the request envelope', () => { + const producerShaped = [ + ...phasePair('sqlite.read', 11, 1.6115), + ...phasePair('deserialize.playlist', 13, 1), + ]; + assert.deepEqual( + parseWorkerPerformancePhaseEvents( + 'DB_GET_APP_PLAYLIST', + producerShaped, + 10, + 30, + true + ), + producerShaped + ); + + const outsideEnvelope = [ + ...phasePair('sqlite.read', 11, 20.001), + ...phasePair('deserialize.playlist', 13, 1), + ]; + assert.equal( + parseWorkerPerformancePhaseEvents( + 'DB_GET_APP_PLAYLIST', + outsideEnvelope, + 10, + 30, + true + ), + null + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts b/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts index 1423da627..319dd71c2 100644 --- a/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts +++ b/apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts @@ -2,9 +2,12 @@ import { APP_PLAYLIST_GET_PERFORMANCE_PHASE, APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, type AppPlaylistPerformancePhase, + type DatabaseWorkerPerformancePhase, type PerformancePhaseEvent, type PerformancePhaseMetadata, } from '@iptvnator/shared/interfaces'; +import { fitsPerformanceDurationEnvelope } from './performance-phase-duration-envelope'; +import { parseXtreamWorkerPerformancePhaseEvents } from './xtream-worker-phase-events'; const PHASE_SEQUENCES = { DB_GET_APP_PLAYLIST: [ @@ -31,8 +34,20 @@ export function parseWorkerPerformancePhaseEvents( operation: string | null, input: unknown, workStartedEpochMs: number, - workEndedEpochMs: number -): readonly PerformancePhaseEvent[] | null { + workEndedEpochMs: number, + requestSucceeded: boolean +): readonly PerformancePhaseEvent[] | null { + const xtreamEvents = parseXtreamWorkerPerformancePhaseEvents( + operation, + input, + workStartedEpochMs, + workEndedEpochMs, + requestSucceeded + ); + if (xtreamEvents !== undefined) { + return xtreamEvents; + } + const phaseSequence = getPhaseSequence(operation); if (phaseSequence.length === 0) { return input === undefined || @@ -40,15 +55,11 @@ export function parseWorkerPerformancePhaseEvents( ? [] : null; } - if ( - !Array.isArray(input) || - input.length !== phaseSequence.length * 2 - ) { + if (!Array.isArray(input) || input.length !== phaseSequence.length * 2) { return null; } - const parsed: PerformancePhaseEvent[] = - []; + const parsed: PerformancePhaseEvent[] = []; let lastEpochMs = workStartedEpochMs; for (let index = 0; index < input.length; index += 1) { const raw = input[index]; @@ -81,6 +92,18 @@ export function parseWorkerPerformancePhaseEvents( ) { return null; } + const phaseStart = parsed[parsed.length - 1]; + if ( + boundary === 'end' && + (!phaseStart || + !fitsPerformanceDurationEnvelope( + Number(durationMs), + workStartedEpochMs, + workEndedEpochMs + )) + ) { + return null; + } parsed.push({ boundary: boundary as 'end' | 'start', diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts index 59bc4ecc6..b68b8023d 100644 --- a/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts @@ -21,10 +21,7 @@ const validCapture = { workStartedEpochMs: 1_010, } as const; -function normalizeForOperation( - operation: string, - performanceCapture: unknown -) { +function normalizeForOperation(operation: string, performanceCapture: unknown) { return normalizeWorkerRequestPerformanceOutcome({ ipcCallId: 2, operation, @@ -40,7 +37,7 @@ function normalizeForOperation( } function normalize(performanceCapture: unknown) { - return normalizeForOperation('DB_GET_CONTENT', performanceCapture); + return normalizeForOperation('DB_HAS_CONTENT', performanceCapture); } function assertClosedCapture( @@ -313,10 +310,7 @@ test('requires the exact operation-specific worker phase sequence', () => { ); } - for (const operation of [ - 'DB_GET_APP_PLAYLIST', - 'DB_UPSERT_APP_PLAYLIST', - ]) { + for (const operation of ['DB_GET_APP_PLAYLIST', 'DB_UPSERT_APP_PLAYLIST']) { assertClosedCapture( normalizeForOperation(operation, validCapture), 'worker-performance-capture-invalid' @@ -325,3 +319,68 @@ test('requires the exact operation-specific worker phase sequence', () => { assert.deepEqual(normalize(validCapture).phaseEvents, []); }); + +test('routes finalized Xtream phase sequences before the legacy M3U parser', () => { + const xtreamPhaseEvents = [ + { + boundary: 'start', + durationMs: null, + epochMs: 1_015, + phase: 'sqlite.content.category-map-read', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 2, + epochMs: 1_017, + metadata: { itemCount: 60 }, + phase: 'sqlite.content.category-map-read', + requestId: 'request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 1_018, + phase: 'normalize.content', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 5, + epochMs: 1_023, + metadata: { itemCount: 60_000 }, + phase: 'normalize.content', + requestId: 'request-1', + }, + { + boundary: 'start', + durationMs: null, + epochMs: 1_024, + phase: 'sqlite.content.write-transactions', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 10, + epochMs: 1_034, + metadata: { itemCount: 60_000 }, + phase: 'sqlite.content.write-transactions', + requestId: 'request-1', + }, + ] as const; + + assert.deepEqual( + normalizeForOperation('DB_SAVE_CONTENT', { + ...validCapture, + phaseEvents: xtreamPhaseEvents, + }).phaseEvents, + xtreamPhaseEvents + ); + assertClosedCapture( + normalizeForOperation('DB_SAVE_CONTENT', { + ...validCapture, + phaseEvents: xtreamPhaseEvents.slice(0, -2), + }), + 'worker-performance-capture-invalid' + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts index e5149e442..9a1134a92 100644 --- a/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts @@ -55,7 +55,7 @@ function requestTransport( ) { return { ipcCallId: 7, - operation: 'DB_GET_CONTENT', + operation: 'DB_HAS_CONTENT', operationId: 'operation-42', operationIdUnavailableReason: null, performanceCapture, @@ -227,7 +227,7 @@ test('main capture retains raw request identity, timestamps, metrics, and reason assert.match(source, /ipcCallId: request\.identity\.ipcCallId/); assert.match(source, /identity: request\.identity/); assert.match(source, /performanceCapture:\s+message\['performance'\]/); - assert.match(source, /sourceEpochMs: request\.identity\.sourceEpochMs/); + assert.match(source, /sourceEpochMs:\s+request\.identity\.sourceEpochMs/); assert.match(source, /captureGeneration: state\.captureGeneration/); assert.doesNotMatch( source, diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts index 4a5cc00ac..cc20f5b2a 100644 --- a/apps/electron-backend-e2e/src/performance/worker-request-performance.ts +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts @@ -152,7 +152,8 @@ function parseEventLoopDelay( function parseWorkerPerformanceCapture( input: unknown, - operation: string | null + operation: string | null, + requestSucceeded: boolean ): ParsedWorkerPerformanceCapture | null { if (!isRecord(input)) { return null; @@ -203,7 +204,8 @@ function parseWorkerPerformanceCapture( operation, input['phaseEvents'], workStartedEpochMs, - workEndedEpochMs + workEndedEpochMs, + requestSucceeded ); if (phaseEvents === null) { return null; @@ -318,7 +320,8 @@ export function normalizeWorkerRequestPerformanceOutcome( const performanceCapture = parseWorkerPerformanceCapture( input.performanceCapture, - input.operation + input.operation, + input.success ); if (!performanceCapture) { return unavailable('worker-performance-capture-invalid'); diff --git a/apps/electron-backend-e2e/src/performance/xtream-account-inventory.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-account-inventory.spec.ts new file mode 100644 index 000000000..220712d6f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-account-inventory.spec.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { attributeXtreamPhases } from './xtream-phase-attribution'; +import { scenarioCapture } from './xtream-phase-attribution.fixtures'; +import { + expectedXtreamIpc, + expectedXtreamPhases, + XTREAM_ATTRIBUTION_PHASE as PHASE, + XTREAM_PHASE_SEMANTIC_TYPE as SEMANTIC, +} from './xtream-phase-inventory'; + +const EXPECTED_PROVIDER_CALLS: Readonly> = { + [XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE]: 8, + [XTREAM_SCENARIO_ID.REFRESH_LARGE]: 8, + [XTREAM_SCENARIO_ID.DELETE_LARGE]: 0, + [XTREAM_SCENARIO_ID.CANCEL_IMPORT]: 6, + [XTREAM_SCENARIO_ID.BACKGROUND_UI]: 8, +}; + +const EXPECTED_ACCOUNT_CALLS: Readonly> = { + [XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE]: 2, + [XTREAM_SCENARIO_ID.REFRESH_LARGE]: 2, + [XTREAM_SCENARIO_ID.DELETE_LARGE]: 0, + [XTREAM_SCENARIO_ID.CANCEL_IMPORT]: 2, + [XTREAM_SCENARIO_ID.BACKGROUND_UI]: 2, +}; + +describe('Xtream account phase inventory', () => { + it('counts every scenario-specific provider IPC and phase lifecycle', () => { + for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) { + const providerCalls = EXPECTED_PROVIDER_CALLS[scenarioId]; + assert.equal( + expectedXtreamPhases(scenarioId).get(PHASE.NETWORK_TOTAL) + ?.occurrences, + providerCalls + ); + assert.equal( + expectedXtreamIpc(scenarioId).get('xtreamRequest')?.success ?? + 0, + providerCalls + ); + } + }); + + it('fixtures contain the exact account lifecycle count and remain valid', () => { + for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) { + const capture = scenarioCapture(scenarioId); + const accounts = capture.events.filter( + ({ boundary, phase, semanticType }) => + boundary === 'start' && + phase === PHASE.NETWORK_TOTAL && + semanticType === SEMANTIC.ACCOUNT + ); + assert.equal(accounts.length, EXPECTED_ACCOUNT_CALLS[scenarioId]); + assert.doesNotThrow(() => attributeXtreamPhases(capture)); + } + }); + + it('allows the two independent fresh-add account phases to overlap', () => { + const capture = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const events = capture.events.map((event) => + event.correlationId === 'provider-account-route' + ? { ...event, epochMs: event.epochMs - 6.5 } + : event + ); + + assert.doesNotThrow(() => + attributeXtreamPhases({ ...capture, events }) + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-account-phase-correlation.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-account-phase-correlation.spec.ts new file mode 100644 index 000000000..8770fa25d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-account-phase-correlation.spec.ts @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + XTREAM_MAIN_PERFORMANCE_PHASE, + XTREAM_PRELOAD_PERFORMANCE_METHOD, + XtreamCodeActions, +} from '@iptvnator/shared/interfaces'; + +import { + createXtreamIpcMarkerCapture, + type XtreamIpcMarkerCapture, + type XtreamIpcMarkerCaptureOptions, +} from './xtream-ipc-marker-events'; + +const OPTIONS: XtreamIpcMarkerCaptureOptions = { + actions: Object.values(XtreamCodeActions), + mainPhases: Object.values(XTREAM_MAIN_PERFORMANCE_PHASE), + methods: Object.values(XTREAM_PRELOAD_PERFORMANCE_METHOD), + schemaVersion: 1, +}; + +describe('Xtream account phase correlation', () => { + it('accepts overlapping null-session calls and links main phases FIFO', () => { + const capture = createXtreamIpcMarkerCapture(OPTIONS); + capture.start(42, 90); + assert.equal( + capture.acceptPreload(42, 100, accountMarker(1, 99)), + true + ); + assert.equal( + capture.acceptPreload(42, 102, accountMarker(2, 101)), + true + ); + acceptAccountMainLifecycle(capture, 1, 103); + assert.equal( + capture.acceptPreload(42, 113, accountMarker(1, 112, 'end')), + true + ); + acceptAccountMainLifecycle(capture, 2, 114); + assert.equal( + capture.acceptPreload(42, 124, accountMarker(2, 123, 'end')), + true + ); + + const snapshot = capture.stop(); + assert.deepEqual(snapshot.invalidReasons, []); + assert.equal(snapshot.quarantinedKeyCount, 0); + const phases = snapshot.timeline.filter( + ({ type }) => type === 'xtream-main-phase' + ); + assert.deepEqual( + phases.map(({ action, ipcCallId }) => ({ action, ipcCallId })), + [ + ...Array.from({ length: 6 }, () => ({ + action: XtreamCodeActions.GetAccountInfo, + ipcCallId: 1, + })), + ...Array.from({ length: 6 }, () => ({ + action: XtreamCodeActions.GetAccountInfo, + ipcCallId: 2, + })), + ] + ); + }); + + it('fails closed for duplicate and malformed preload call IDs', () => { + const capture = createXtreamIpcMarkerCapture(OPTIONS); + capture.start(42, 90); + assert.equal( + capture.acceptPreload(42, 100, accountMarker(1, 99)), + true + ); + assert.equal( + capture.acceptPreload(42, 102, accountMarker(1, 101)), + false + ); + assert.equal( + capture.acceptPreload(42, 104, accountMarker(0, 103)), + false + ); + + const snapshot = capture.stop(); + assert.equal(snapshot.quarantinedKeyCount, 1); + assert.ok( + snapshot.invalidReasons.includes('preload-marker-duplicate-key') + ); + }); +}); + +function acceptAccountMainLifecycle( + capture: XtreamIpcMarkerCapture, + ipcCallId: number, + offset: number +): void { + const requestId = `xtream-account-${ipcCallId}`; + acceptMainBoundary( + capture, + requestId, + 'xtream.network.total', + offset + 1, + 'start' + ); + acceptMainPair( + capture, + requestId, + 'xtream.json.transform', + offset + 2, + offset + 3 + ); + acceptMainBoundary( + capture, + requestId, + 'xtream.network.total', + offset + 6, + 'end', + 5 + ); + acceptMainPair( + capture, + requestId, + 'xtream.response.ready', + offset + 7, + offset + 8 + ); +} + +function acceptMainBoundary( + capture: XtreamIpcMarkerCapture, + requestId: string, + phase: string, + epochMs: number, + boundary: 'end' | 'start', + durationMs: number | null = null +): void { + assert.equal( + capture.acceptMainPhase({ + boundary, + durationMs, + epochMs, + phase, + requestId, + }), + true + ); +} + +function acceptMainPair( + capture: XtreamIpcMarkerCapture, + requestId: string, + phase: string, + startEpochMs: number, + endEpochMs: number +): void { + assert.equal( + capture.acceptMainPhase({ + boundary: 'start', + durationMs: null, + epochMs: startEpochMs, + phase, + requestId, + }), + true + ); + assert.equal( + capture.acceptMainPhase({ + boundary: 'end', + durationMs: endEpochMs - startEpochMs, + epochMs: endEpochMs, + phase, + requestId, + }), + true + ); +} + +function accountMarker( + ipcCallId: number, + sourceEpochMs: number, + boundary: 'end' | 'start' = 'start' +): Record { + return { + schemaVersion: 1, + sourceEpochMs, + ipcCallId, + method: 'xtreamRequest', + boundary, + outcome: boundary === 'start' ? null : 'success', + playlistId: null, + operationId: null, + sessionId: null, + action: XtreamCodeActions.GetAccountInfo, + categoryType: null, + contentType: null, + itemCount: null, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-acquisition-duration.ts b/apps/electron-backend-e2e/src/performance/xtream-acquisition-duration.ts new file mode 100644 index 000000000..1ec8508a3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-acquisition-duration.ts @@ -0,0 +1,15 @@ +export function deriveXtreamAcquisitionDurationMs( + networkTotalDurationMs: number, + jsonTransformDurationMs: number +): number { + if ( + !Number.isFinite(networkTotalDurationMs) || + networkTotalDurationMs < 0 || + !Number.isFinite(jsonTransformDurationMs) || + jsonTransformDurationMs < 0 || + jsonTransformDurationMs > networkTotalDurationMs + ) { + throw new Error('xtream-acquisition-duration-invalid'); + } + return networkTotalDurationMs - jsonTransformDurationMs; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-artifacts.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-artifacts.ts new file mode 100644 index 000000000..9452f9d07 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-artifacts.ts @@ -0,0 +1,55 @@ +import { join } from 'node:path'; + +import { + serializeXtreamArtifactForPersistence, + type XtreamDiagnosticArtifactEvidence, +} from './xtream-diagnostic-artifacts'; +import { writeXtreamExclusiveArtifact } from './xtream-exclusive-artifact-writer'; +import { persistThenValidateXtreamIteration } from './xtream-benchmark-report'; +import { XTREAM_SYNTHETIC_CREDENTIALS } from './xtream-scenario-driver'; +import type { + XtreamArtifactSafetyContext, + XtreamRawIterationResult, + XtreamValidatedIterationResult, +} from './xtream-summary-contract'; + +export interface PersistedXtreamIteration { + readonly diagnosticEvidence: XtreamDiagnosticArtifactEvidence | null; + readonly iteration: XtreamValidatedIterationResult; +} + +export function createXtreamArtifactSafetyContext( + controlToken: string +): XtreamArtifactSafetyContext { + return Object.freeze({ + controlToken, + syntheticCredentials: XTREAM_SYNTHETIC_CREDENTIALS, + }); +} + +export async function writeXtreamSafeJson( + absolutePath: string, + value: unknown, + safety: XtreamArtifactSafetyContext +): Promise { + const serialized = serializeXtreamArtifactForPersistence(value, safety); + await writeXtreamExclusiveArtifact(absolutePath, serialized); +} + +export async function persistXtreamIteration( + iterationDirectory: string, + raw: XtreamRawIterationResult, + diagnosticEvidence: XtreamDiagnosticArtifactEvidence | null, + safety: XtreamArtifactSafetyContext +): Promise { + const iteration = await persistThenValidateXtreamIteration( + raw, + safety, + (serialized) => + writeXtreamExclusiveArtifact( + join(iterationDirectory, 'result.json'), + serialized + ) + ); + return Object.freeze({ diagnosticEvidence, iteration }); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.spec.ts new file mode 100644 index 000000000..8a2a4566b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.spec.ts @@ -0,0 +1,387 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import { workspaceRoot } from '../electron-test-fixtures'; +import { createXtreamIterationDefinitions } from './xtream-benchmark-contract'; +import { BUILD_IDENTITY } from './xtream-benchmark-report.fixtures'; +import { + type XtreamBenchmarkConfiguration, + assertXtreamBenchmarkBuildUnchanged, + assertXtreamBenchmarkOutputPathHasNoSymlinks, + createXtreamBenchmarkManifest, + resolveXtreamBenchmarkCdpPort, + resolveXtreamBenchmarkConfiguration, + resolveXtreamBenchmarkOutputLayout, + XTREAM_BENCHMARK_MOCK_ORIGIN, +} from './xtream-benchmark-configuration'; +import { XTREAM_MEMORY_ACCOUNTING } from './xtream-summary-contract'; + +const SOURCE = Object.freeze({ + gitCommit: '0123456789abcdef0123456789abcdef01234567', + gitDiffSha256: + 'abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd', + gitDirty: false, +}); +const FIXTURE = Object.freeze({ + bytes: 12_345, + catalogSha256: + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', + counts: { + categories: { live: 60, vod: 20, series: 20, total: 100 }, + items: { + live: 60_000, + vod: 20_000, + series: 20_000, + total: 100_000, + }, + }, + epoch: 1, + scenario: 'performance-100k', + seed: 91_001, +} as const); +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +describe('Xtream benchmark output configuration', () => { + it('accepts only a fresh timestamp directory directly below dist/performance', async () => { + const repositoryRoot = await createRepositoryRoot(); + const performanceRoot = join(repositoryRoot, 'dist', 'performance'); + const outputRoot = join( + performanceRoot, + '20260728T123456Z-xtream-smoke' + ); + + assert.deepEqual( + resolveXtreamBenchmarkOutputLayout( + repositoryRoot, + outputRoot, + 'baseline' + ), + { + outputDirectory: join(outputRoot, 'baseline'), + outputRoot, + performanceRoot, + } + ); + + for (const requestedRoot of [ + 'dist/performance/20260728T123456Z', + performanceRoot, + join(performanceRoot, 'nested', '20260728T123456Z'), + join(performanceRoot, 'not-a-timestamp'), + join(repositoryRoot, 'outside', '20260728T123456Z'), + ]) { + assert.throws( + () => + resolveXtreamBenchmarkOutputLayout( + repositoryRoot, + requestedRoot, + 'baseline' + ), + /absolute fresh timestamp path below dist\/performance/ + ); + } + assert.throws( + () => + resolveXtreamBenchmarkOutputLayout( + repositoryRoot, + outputRoot, + '../after' + ), + /safe Xtream variant/ + ); + }); + + it('rejects every existing symbolic-link component', async () => { + const repositoryRoot = await createRepositoryRoot(); + const realDist = join(repositoryRoot, 'real-dist'); + await mkdir(realDist); + await import('node:fs/promises').then(({ symlink }) => + symlink(realDist, join(repositoryRoot, 'dist')) + ); + + await assert.rejects( + () => + assertXtreamBenchmarkOutputPathHasNoSymlinks( + repositoryRoot, + join( + repositoryRoot, + 'dist', + 'performance', + '20260728T123456Z' + ) + ), + /contains a symbolic link/ + ); + }); +}); + +describe('Xtream benchmark mode configuration', () => { + it('pins formal CDP to 9222 and bounds smoke-only alternate ports', () => { + assert.equal(resolveXtreamBenchmarkCdpPort('formal', undefined), 9222); + assert.equal(resolveXtreamBenchmarkCdpPort('smoke', '9322'), 9322); + assert.throws( + () => resolveXtreamBenchmarkCdpPort('formal', '9322'), + /Formal Xtream performance runs require CDP port 9222/ + ); + for (const requested of ['1023', '65536', '9322.5', '0x246a']) { + assert.throws( + () => resolveXtreamBenchmarkCdpPort('smoke', requested), + /IPTVNATOR_PERF_CDP_PORT is invalid/ + ); + } + }); + + it('creates output only after preflight and uses exclusive child mkdir calls', async () => { + const repositoryRoot = await createRepositoryRoot(); + const performanceRoot = join(repositoryRoot, 'dist', 'performance'); + await mkdir(performanceRoot, { recursive: true }); + const outputRoot = join( + performanceRoot, + '20260728T123456Z-xtream-smoke' + ); + const calls: string[] = []; + const configuration = await resolveXtreamBenchmarkConfiguration({ + dependencies: { + assertCapacity: async () => { + calls.push('preflight'); + return { + availableBytes: BigInt(3 * 1_024 ** 3), + checkedPath: performanceRoot, + minimumBytes: BigInt(2 * 1_024 ** 3), + }; + }, + captureBuildIdentity: async () => BUILD_IDENTITY, + mkdir: async (path, options) => { + calls.push( + `mkdir:${path}:${String( + options?.recursive ?? false + )}:${String(options?.mode ?? 'default')}` + ); + await mkdir(path, options); + }, + readElectronVersion: async () => '41.0.0', + readGitSourceState: () => SOURCE, + }, + environment: { + IPTVNATOR_PERF_CDP_PORT: '9322', + IPTVNATOR_PERF_OUTPUT_DIR: outputRoot, + IPTVNATOR_PERF_SMOKE: '1', + IPTVNATOR_PERF_VARIANT: 'smoke', + IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'a'.repeat(64), + }, + workspaceRoot: repositoryRoot, + }); + + assert.equal(calls[0], 'preflight'); + assert.deepEqual(calls.slice(1), [ + `mkdir:${performanceRoot}:true:${0o700}`, + `mkdir:${outputRoot}:false:${0o700}`, + `mkdir:${join(outputRoot, 'smoke')}:false:${0o700}`, + ]); + assert.equal((await stat(outputRoot)).mode & 0o777, 0o700); + assert.equal( + (await stat(join(outputRoot, 'smoke'))).mode & 0o777, + 0o700 + ); + assert.equal(configuration.mode, 'smoke'); + assert.equal(configuration.measuredRuns, 1); + assert.equal(configuration.outputDirectory, join(outputRoot, 'smoke')); + assert.equal(configuration.mockOrigin, XTREAM_BENCHMARK_MOCK_ORIGIN); + assert.equal( + createXtreamIterationDefinitions(configuration.smoke) + .filter((entry) => entry.kind === 'measured') + .every((entry) => !entry.eligibleForComparison), + true + ); + }); + + it('fails formal resolution before preflight when tracked source is dirty', async () => { + const repositoryRoot = await createRepositoryRoot(); + let preflightCalled = false; + + await assert.rejects( + () => + resolveXtreamBenchmarkConfiguration({ + dependencies: { + assertCapacity: async () => { + preflightCalled = true; + throw new Error('unexpected preflight'); + }, + captureBuildIdentity: async () => BUILD_IDENTITY, + readElectronVersion: async () => '41.0.0', + readGitSourceState: () => ({ + ...SOURCE, + gitDirty: true, + }), + }, + environment: { + IPTVNATOR_PERF_OUTPUT_DIR: join( + repositoryRoot, + 'dist', + 'performance', + '20260728T123456Z-xtream' + ), + IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'b'.repeat(64), + }, + workspaceRoot: repositoryRoot, + }), + /Formal Xtream performance runs require a clean Git worktree/ + ); + assert.equal(preflightCalled, false); + }); +}); + +describe('Xtream benchmark manifest provenance', () => { + it('creates an exact manifest from the canonical prepared mock fixture only', () => { + const configuration = configurationFixture(); + const manifest = createXtreamBenchmarkManifest(configuration, FIXTURE); + + assert.equal(manifest.fixture.categories, 100); + assert.equal(manifest.fixture.items, 100_000); + assert.deepEqual(manifest.build, BUILD_IDENTITY); + assert.deepEqual(manifest.source, SOURCE); + assert.deepEqual(manifest.memoryAccounting, XTREAM_MEMORY_ACCOUNTING); + assert.equal( + JSON.stringify(manifest).includes(configuration.controlToken), + false + ); + assert.throws( + () => + createXtreamBenchmarkManifest(configuration, { + ...FIXTURE, + scenario: 'large', + }), + /invalid Xtream performance fixture/ + ); + }); + + it('recaptures and requires byte-identical build identity at suite end', async () => { + const configuration = configurationFixture(); + await assert.doesNotReject(() => + assertXtreamBenchmarkBuildUnchanged(configuration, async () => + structuredClone(BUILD_IDENTITY) + ) + ); + await assert.rejects( + () => + assertXtreamBenchmarkBuildUnchanged( + configuration, + async () => ({ + ...BUILD_IDENTITY, + renderer: { + ...BUILD_IDENTITY.renderer, + aggregateSha256: 'f'.repeat(64), + }, + }) + ), + /Xtream performance build changed during the suite/ + ); + }); +}); + +describe('Xtream Playwright and Nx plumbing', () => { + it('uses one isolated control-enabled mock and one no-retry 30-minute test', async () => { + const configPath = join( + workspaceRoot, + 'apps/electron-backend-e2e/playwright.xtream-performance.config.ts' + ); + const source = await readFile(configPath, 'utf8'); + + assert.match(source, /randomBytes\(32\)/); + assert.match( + source, + /process\.env\['IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN'\]\s*\?\?/ + ); + assert.match(source, /http:\/\/127\.0\.0\.1:3221\/health/); + assert.match(source, /reuseExistingServer:\s*false/); + assert.match(source, /IPTVNATOR_XTREAM_MOCK_CONTROL:\s*'1'/); + assert.match( + source, + /IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN:\s*controlToken/ + ); + assert.match(source, /HOST:\s*'127\.0\.0\.1'/); + assert.match(source, /PORT:\s*'3221'/); + assert.match(source, /retries:\s*0/); + assert.match(source, /workers:\s*1/); + assert.match(source, /timeout:\s*30\s*\*\s*60\s*\*\s*1_000/); + }); + + it('pins the Nx target and keeps the entrypoint to one orchestrator call', async () => { + const [project, entrypoint] = await Promise.all([ + readFile( + join(workspaceRoot, 'apps/electron-backend-e2e/project.json'), + 'utf8' + ).then( + (value) => + JSON.parse(value) as { + targets: Record>; + } + ), + readFile( + join( + workspaceRoot, + 'apps/electron-backend-e2e/src/xtream.performance.ts' + ), + 'utf8' + ), + ]); + const target = project.targets['benchmark-xtream']; + + assert.deepEqual(target?.['dependsOn'], [ + 'electron-backend:build-performance', + ]); + assert.equal(target?.['cache'], false); + assert.equal(target?.['parallelism'], false); + assert.equal( + (target?.['options'] as Record)?.['command'], + 'pnpm exec playwright test --config=playwright.xtream-performance.config.ts src/xtream.performance.ts' + ); + assert.equal( + (entrypoint.match(/runXtreamBenchmark\(\)/g) ?? []).length, + 1 + ); + assert.equal((entrypoint.match(/\btest\(/g) ?? []).length, 1); + }); +}); + +async function createRepositoryRoot(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'iptvnator-xtream-config-')); + temporaryDirectories.push(directory); + return directory; +} + +function configurationFixture(): XtreamBenchmarkConfiguration { + return { + build: BUILD_IDENTITY, + cdpPort: 9222, + controlToken: 'c'.repeat(64), + electronVersion: '41.0.0', + measuredRuns: 5, + mockOrigin: XTREAM_BENCHMARK_MOCK_ORIGIN, + mode: 'formal' as const, + outputDirectory: resolve('/tmp/xtream/baseline'), + outputRoot: resolve('/tmp/xtream'), + runtime: { + arch: 'arm64', + electronVersion: '41.0.0', + harnessNodeVersion: '22.0.0', + platform: 'darwin', + }, + smoke: false, + source: SOURCE, + variant: 'baseline', + workspaceRoot: resolve('/repository'), + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.ts new file mode 100644 index 000000000..2be796140 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-configuration.ts @@ -0,0 +1,278 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { isDeepStrictEqual } from 'node:util'; +import { mkdir, readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { workspaceRoot as defaultWorkspaceRoot } from '../electron-test-fixtures'; +import type { + XtreamBenchmarkManifest, + XtreamBenchmarkRuntime, + XtreamBenchmarkSource, + XtreamPerformanceManifest, +} from './xtream-benchmark-contract'; +import { parseXtreamBenchmarkManifest } from './xtream-benchmark-manifest-schema'; +import { + captureXtreamBuildIdentity, + type XtreamBenchmarkBuildIdentity, +} from './xtream-build-identity'; +import { parseXtreamBenchmarkBuildIdentity } from './xtream-build-identity-schema'; +import { parseXtreamManifest } from './xtream-control-state-schema'; +import { + assertXtreamBenchmarkOutputPathHasNoSymlinks, + createExclusiveXtreamBenchmarkOutputLayout, + resolveXtreamBenchmarkCdpPort, + resolveXtreamBenchmarkOutputLayout, + type XtreamMkdir, +} from './xtream-benchmark-output-configuration'; +import { + assertPerformanceArtifactCapacity, + type PerformanceArtifactCapacity, +} from './performance-artifact-preflight'; +import { XTREAM_MEMORY_ACCOUNTING } from './xtream-summary-contract'; + +export const XTREAM_BENCHMARK_MOCK_ORIGIN = 'http://127.0.0.1:3221'; +const CONTROL_TOKEN = /^[A-Za-z0-9_-]{43,128}$/u; + +export { + assertXtreamBenchmarkOutputPathHasNoSymlinks, + resolveXtreamBenchmarkCdpPort, + resolveXtreamBenchmarkOutputLayout, + XTREAM_BENCHMARK_FORMAL_CDP_PORT, +} from './xtream-benchmark-output-configuration'; + +export interface XtreamBenchmarkConfiguration { + readonly build: XtreamBenchmarkBuildIdentity; + readonly cdpPort: number; + /** Control-plane secret. Never serialize or log this field. */ + readonly controlToken: string; + readonly electronVersion: string; + readonly measuredRuns: 1 | 5; + readonly mockOrigin: typeof XTREAM_BENCHMARK_MOCK_ORIGIN; + readonly mode: 'formal' | 'smoke'; + readonly outputDirectory: string; + readonly outputRoot: string; + readonly runtime: XtreamBenchmarkRuntime; + readonly smoke: boolean; + readonly source: XtreamBenchmarkSource; + readonly variant: string; + readonly workspaceRoot: string; +} + +type XtreamGitSourceState = XtreamBenchmarkSource; + +interface XtreamConfigurationDependencies { + readonly assertCapacity?: ( + targetPath: string + ) => Promise; + readonly captureBuildIdentity?: ( + workspaceRoot: string + ) => Promise; + readonly mkdir?: XtreamMkdir; + readonly readElectronVersion?: (workspaceRoot: string) => Promise; + readonly readGitSourceState?: ( + workspaceRoot: string + ) => XtreamGitSourceState; +} + +export interface ResolveXtreamBenchmarkConfigurationOptions { + readonly dependencies?: XtreamConfigurationDependencies; + readonly environment?: NodeJS.ProcessEnv; + readonly workspaceRoot?: string; +} + +export async function resolveXtreamBenchmarkConfiguration( + options: ResolveXtreamBenchmarkConfigurationOptions = {} +): Promise { + const environment = options.environment ?? process.env; + const repositoryRoot = resolve( + options.workspaceRoot ?? defaultWorkspaceRoot + ); + const requestedRoot = environment['IPTVNATOR_PERF_OUTPUT_DIR']; + if (!requestedRoot) { + throw new Error('IPTVNATOR_PERF_OUTPUT_DIR is required'); + } + const smoke = resolveSmokeMode(environment['IPTVNATOR_PERF_SMOKE']); + const mode = smoke ? 'smoke' : 'formal'; + const variant = environment['IPTVNATOR_PERF_VARIANT'] ?? 'baseline'; + const layout = resolveXtreamBenchmarkOutputLayout( + repositoryRoot, + requestedRoot, + variant + ); + const cdpPort = resolveXtreamBenchmarkCdpPort( + mode, + environment['IPTVNATOR_PERF_CDP_PORT'] + ); + const controlToken = + environment['IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN'] ?? ''; + if (!CONTROL_TOKEN.test(controlToken)) { + throw new Error( + 'Xtream benchmark requires a high-entropy mock control token' + ); + } + + const dependencies = options.dependencies ?? {}; + const readSource = dependencies.readGitSourceState ?? readGitSourceState; + const source = Object.freeze(readSource(repositoryRoot)); + assertSourceState(mode, source); + const captureBuild = + dependencies.captureBuildIdentity ?? captureXtreamBuildIdentity; + const build = parseXtreamBenchmarkBuildIdentity( + await captureBuild(repositoryRoot) + ); + const electronVersion = await ( + dependencies.readElectronVersion ?? readElectronRuntimeVersion + )(repositoryRoot); + if (!electronVersion.trim()) { + throw new Error('Unable to resolve the Electron runtime version'); + } + + await assertXtreamBenchmarkOutputPathHasNoSymlinks( + repositoryRoot, + layout.outputRoot + ); + await (dependencies.assertCapacity ?? assertPerformanceArtifactCapacity)( + layout.outputDirectory + ); + await createExclusiveXtreamBenchmarkOutputLayout( + repositoryRoot, + layout, + dependencies.mkdir ?? mkdir + ); + + const runtime = Object.freeze({ + arch: process.arch, + electronVersion, + harnessNodeVersion: process.versions.node, + platform: process.platform, + }); + return Object.freeze({ + build, + cdpPort, + controlToken, + electronVersion, + measuredRuns: smoke ? 1 : 5, + mockOrigin: XTREAM_BENCHMARK_MOCK_ORIGIN, + mode, + outputDirectory: layout.outputDirectory, + outputRoot: layout.outputRoot, + runtime, + smoke, + source, + variant, + workspaceRoot: repositoryRoot, + }); +} + +export function createXtreamBenchmarkManifest( + configuration: XtreamBenchmarkConfiguration, + preparedFixture: unknown +): XtreamBenchmarkManifest { + let fixture: XtreamPerformanceManifest; + try { + fixture = parseXtreamManifest(preparedFixture); + } catch { + throw new Error('invalid Xtream performance fixture'); + } + return parseXtreamBenchmarkManifest({ + build: configuration.build, + environment: { + cdpAddress: '127.0.0.1', + cdpPort: configuration.cdpPort, + databaseWorkerBatchDelayMs: 0, + exposeGc: true, + freshUserDataPerIteration: true, + perfCapture: '1', + perfWorkerProfiling: '1', + traceRendererConsole: '0', + }, + fixture: { + bytes: fixture.bytes, + catalogSha256: fixture.catalogSha256, + categories: fixture.counts.categories.total, + items: fixture.counts.items.total, + scenario: fixture.scenario, + seed: fixture.seed, + }, + memoryAccounting: XTREAM_MEMORY_ACCOUNTING, + mode: configuration.mode, + runtime: configuration.runtime, + source: configuration.source, + suite: 'xtream-performance-100k', + variant: configuration.variant, + }); +} + +export async function assertXtreamBenchmarkBuildUnchanged( + configuration: XtreamBenchmarkConfiguration, + capture: ( + workspaceRoot: string + ) => Promise = captureXtreamBuildIdentity +): Promise { + let current: XtreamBenchmarkBuildIdentity; + try { + current = parseXtreamBenchmarkBuildIdentity( + await capture(configuration.workspaceRoot) + ); + } catch { + throw new Error('Xtream performance build changed during the suite'); + } + if (!isDeepStrictEqual(configuration.build, current)) { + throw new Error('Xtream performance build changed during the suite'); + } +} + +function resolveSmokeMode(value: string | undefined): boolean { + if (value === undefined) return false; + if (value === '1') return true; + throw new Error('IPTVNATOR_PERF_SMOKE is invalid'); +} + +function assertSourceState( + mode: 'formal' | 'smoke', + source: XtreamGitSourceState +): void { + if (mode === 'formal' && source.gitDirty) { + throw new Error( + 'Formal Xtream performance runs require a clean Git worktree' + ); + } +} + +function readGitSourceState(repositoryRoot: string): XtreamGitSourceState { + const git = (arguments_: readonly string[]): string => + execFileSync('git', [...arguments_], { + cwd: repositoryRoot, + encoding: 'utf8', + }); + const gitCommit = git(['rev-parse', 'HEAD']).trim(); + const status = git(['status', '--porcelain=v1', '--untracked-files=all']); + const gitDiffSha256 = createHash('sha256') + .update(status) + .update('\0') + .update(git(['diff', '--binary', 'HEAD'])) + .update('\0') + .update(git(['diff', '--binary', '--cached', 'HEAD'])) + .digest('hex'); + return Object.freeze({ + gitCommit, + gitDiffSha256, + gitDirty: status.trim().length > 0, + }); +} + +async function readElectronRuntimeVersion( + repositoryRoot: string +): Promise { + const value = JSON.parse( + await readFile( + join(repositoryRoot, 'node_modules', 'electron', 'package.json'), + 'utf8' + ) + ) as { version?: unknown }; + if (typeof value.version !== 'string' || !value.version.trim()) { + throw new Error('Unable to resolve the Electron runtime version'); + } + return value.version; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.spec.ts new file mode 100644 index 000000000..ee6a88c72 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.spec.ts @@ -0,0 +1,361 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + assertXtreamArtifactRedacted, + assertXtreamControlReadyAfterObservationReset, + assertXtreamControlStateForScenario, + assertXtreamManifest, + assertXtreamManifestIdentity, + assertXtreamMockOrigin, + createXtreamIterationDefinitions, + listXtreamBenchmarkScenarios, + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, + type XtreamControlState, + type XtreamPerformanceManifest, +} from './xtream-benchmark-contract'; + +const MANIFEST: XtreamPerformanceManifest = { + epoch: 1, + scenario: 'performance-100k', + seed: 91_001, + counts: { + categories: { live: 60, vod: 20, series: 20, total: 100 }, + items: { + live: 60_000, + vod: 20_000, + series: 20_000, + total: 100_000, + }, + }, + bytes: 12_345_678, + catalogSha256: + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', +}; + +describe('Xtream benchmark contract', () => { + it('defines exactly the five requested scenarios', () => { + assert.deepEqual( + listXtreamBenchmarkScenarios().map(({ id }) => id), + [ + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + XTREAM_SCENARIO_ID.REFRESH_LARGE, + XTREAM_SCENARIO_ID.DELETE_LARGE, + XTREAM_SCENARIO_ID.CANCEL_IMPORT, + XTREAM_SCENARIO_ID.BACKGROUND_UI, + ] + ); + }); + + it('schedules warm-up, five comparable measurements, and one diagnostic', () => { + const definitions = createXtreamIterationDefinitions(false); + + assert.equal(definitions.length, 35); + for (const { id } of listXtreamBenchmarkScenarios()) { + const runs = definitions.filter( + (definition) => definition.scenarioId === id + ); + assert.deepEqual( + runs.map(({ eligibleForComparison, kind, runId }) => ({ + eligibleForComparison, + kind, + runId, + })), + [ + { + eligibleForComparison: false, + kind: XTREAM_ITERATION_KIND.WARMUP, + runId: 'warmup-01', + }, + ...Array.from({ length: 5 }, (_, index) => ({ + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: `run-${String(index + 1).padStart(2, '0')}`, + })), + { + eligibleForComparison: false, + kind: XTREAM_ITERATION_KIND.DIAGNOSTIC, + runId: 'diagnostic', + }, + ] + ); + } + }); + + it('keeps smoke measurements ineligible for formal comparison', () => { + const definitions = createXtreamIterationDefinitions(true); + + assert.equal(definitions.length, 15); + assert.ok( + definitions.every((definition) => !definition.eligibleForComparison) + ); + for (const { id } of listXtreamBenchmarkScenarios()) { + assert.deepEqual( + definitions + .filter((definition) => definition.scenarioId === id) + .map(({ kind, runId }) => ({ kind, runId })), + [ + { + kind: XTREAM_ITERATION_KIND.WARMUP, + runId: 'warmup-01', + }, + { + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + }, + { + kind: XTREAM_ITERATION_KIND.DIAGNOSTIC, + runId: 'diagnostic', + }, + ] + ); + } + }); + + it('accepts only an exact credential-free HTTP loopback origin', () => { + assert.doesNotThrow(() => + assertXtreamMockOrigin('http://127.0.0.1:43123') + ); + + for (const unsafe of [ + 'https://127.0.0.1:43123', + 'http://localhost:43123', + 'http://127.0.0.1', + 'http://127.0.0.1:43123/', + 'http://user:pass@127.0.0.1:43123', + 'http://127.0.0.1:43123/player_api.php', + 'http://127.0.0.1:43123?token=secret', + 'http://127.0.0.1:43123#fragment', + ]) { + assert.throws( + () => assertXtreamMockOrigin(unsafe), + /exact Xtream HTTP loopback origin/ + ); + } + }); + + it('rejects unexpected fixture names, counts, hashes, and identity drift', () => { + assert.doesNotThrow(() => assertXtreamManifest(MANIFEST)); + + for (const invalid of [ + { ...MANIFEST, scenario: 'provider-data' }, + { ...MANIFEST, seed: 42 }, + { + ...MANIFEST, + counts: { + ...MANIFEST.counts, + items: { ...MANIFEST.counts.items, total: 99_999 }, + }, + }, + { ...MANIFEST, catalogSha256: 'not-a-hash' }, + { ...MANIFEST, username: 'performance' }, + ]) { + assert.throws( + () => assertXtreamManifest(invalid), + /invalid Xtream performance manifest/ + ); + } + + assert.doesNotThrow(() => + assertXtreamManifestIdentity(MANIFEST, { + ...MANIFEST, + epoch: 2, + }) + ); + assert.throws( + () => + assertXtreamManifestIdentity(MANIFEST, { + ...MANIFEST, + epoch: 2, + bytes: MANIFEST.bytes + 1, + }), + /fixture identity changed/ + ); + }); + + it('requires a clean, same-epoch pre-trigger state after observation reset', () => { + const ready = controlState([]); + assert.doesNotThrow(() => + assertXtreamControlReadyAfterObservationReset(ready, MANIFEST) + ); + + for (const invalid of [ + { ...ready, epoch: 2 }, + { ...ready, rules: [{ kind: 'barrier' }] }, + { ...ready, heldCount: 1, heldIds: ['leftover'] }, + controlState([['get_account_info', 'all', 1]]), + ]) { + assert.throws( + () => + assertXtreamControlReadyAfterObservationReset( + invalid, + MANIFEST + ), + /invalid Xtream mock readiness/ + ); + } + }); + + it('allows category requests to arrive in any parallel order', () => { + assert.doesNotThrow(() => + assertXtreamControlStateForScenario( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + controlState( + [ + ['get_account_info', 'all', 1], + ['get_series_categories', 'all', 2], + ['get_vod_categories', 'all', 3], + ['get_live_categories', 'all', 4], + ['get_live_streams', 'all', 5], + ['get_vod_streams', 'all', 6], + ['get_series', 'all', 7], + ], + 2 + ), + MANIFEST + ) + ); + }); + + it('fails closed on action drift, proxy traffic, residue, and ordering drift', () => { + const cases: XtreamControlState[] = [ + controlState(fullActions().slice(0, -1), 2), + controlState([...fullActions(), ['get_vod_info', 'all', 8]], 2), + { + ...controlState(fullActions(), 2), + occurrences: controlState(fullActions(), 2).occurrences.map( + (entry, index) => + index === 0 ? { ...entry, transport: 'proxy' } : entry + ), + }, + { + ...controlState(fullActions(), 2), + rules: [{ kind: 'delay' }], + }, + { + ...controlState(fullActions(), 2), + heldCount: 1, + heldIds: ['leftover'], + }, + controlState( + [ + ['get_account_info', 'all', 1], + ['get_live_streams', 'all', 2], + ['get_live_categories', 'all', 3], + ['get_vod_categories', 'all', 4], + ['get_series_categories', 'all', 5], + ['get_vod_streams', 'all', 6], + ['get_series', 'all', 7], + ], + 2 + ), + ]; + + for (const invalid of cases) { + assert.throws( + () => + assertXtreamControlStateForScenario( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + invalid, + MANIFEST + ), + /invalid Xtream mock observations/ + ); + } + }); + + it('rejects credential keys and secret values from persisted artifacts', () => { + assert.doesNotThrow(() => + assertXtreamArtifactRedacted({ + fixture: { + bytes: MANIFEST.bytes, + catalogSha256: MANIFEST.catalogSha256, + }, + }) + ); + for (const unsafe of [ + { username: 'performance' }, + { nested: { password: 'performance' } }, + { serverUrl: 'http://127.0.0.1:43123' }, + { note: 'leaked benchmark-control-token' }, + ]) { + assert.throws( + () => + assertXtreamArtifactRedacted(unsafe, [ + 'benchmark-control-token', + ]), + /credential-safe/ + ); + } + }); +}); + +type Action = readonly [string, string, number]; + +function fullActions(): readonly Action[] { + return [ + ['get_account_info', 'all', 1], + ['get_live_categories', 'all', 2], + ['get_vod_categories', 'all', 3], + ['get_series_categories', 'all', 4], + ['get_live_streams', 'all', 5], + ['get_vod_streams', 'all', 6], + ['get_series', 'all', 7], + ]; +} + +function controlState( + actions: readonly Action[], + accountCount = 1 +): XtreamControlState { + return { + epoch: MANIFEST.epoch, + prepared: MANIFEST, + rules: [], + heldIds: [], + heldCount: 0, + occurrences: actions.map(([action, categoryId]) => ({ + scenario: 'performance-100k', + transport: 'direct', + action, + categoryId, + count: action === 'get_account_info' ? accountCount : 1, + })), + ledger: actions.flatMap(([action, categoryId, order]) => + Array.from( + { + length: action === 'get_account_info' ? accountCount : 1, + }, + (_, index) => { + const shiftedOrder = + order + + index + + (action === 'get_account_info' ? 0 : accountCount - 1); + return [ + { + epoch: MANIFEST.epoch, + scenario: 'performance-100k', + transport: 'direct' as const, + action, + categoryId, + occurrence: index + 1, + status: 'arrived' as const, + atMs: shiftedOrder * 10, + }, + { + epoch: MANIFEST.epoch, + scenario: 'performance-100k', + transport: 'direct' as const, + action, + categoryId, + occurrence: index + 1, + status: 'responded' as const, + atMs: shiftedOrder * 10 + 1, + }, + ]; + } + ).flat() + ), + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.ts new file mode 100644 index 000000000..d01fa0c69 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.ts @@ -0,0 +1,399 @@ +import type { XTREAM_MEMORY_ACCOUNTING } from './xtream-summary-contract'; +import type { XtreamBenchmarkBuildIdentity } from './xtream-build-identity'; + +export const XTREAM_SCENARIO_ID = { + INITIAL_IMPORT_LARGE: 'xtream-initial-import-large', + REFRESH_LARGE: 'xtream-refresh-large', + DELETE_LARGE: 'xtream-delete-large', + CANCEL_IMPORT: 'xtream-cancel-import', + BACKGROUND_UI: 'xtream-background-ui', +} as const; + +export type XtreamScenarioId = + (typeof XTREAM_SCENARIO_ID)[keyof typeof XTREAM_SCENARIO_ID]; + +export const XTREAM_ITERATION_KIND = { + DIAGNOSTIC: 'diagnostic', + MEASURED: 'measured', + WARMUP: 'warmup', +} as const; + +export type XtreamIterationKind = + (typeof XTREAM_ITERATION_KIND)[keyof typeof XTREAM_ITERATION_KIND]; + +export interface XtreamScenario { + readonly id: XtreamScenarioId; +} + +export interface XtreamWorkerRequestInventoryEntry { + readonly count: number; + readonly operation: string; + readonly phases: readonly string[]; + readonly success: boolean; +} + +export interface XtreamIterationDefinition { + readonly eligibleForComparison: boolean; + readonly kind: XtreamIterationKind; + readonly runId: string; + readonly scenarioId: XtreamScenarioId; +} + +export interface XtreamPerformanceManifest { + readonly bytes: number; + readonly catalogSha256: string; + readonly counts: { + readonly categories: XtreamCatalogCounts; + readonly items: XtreamCatalogCounts; + }; + readonly epoch: number; + readonly scenario: 'performance-100k'; + readonly seed: 91_001; +} + +export interface XtreamCatalogCounts { + readonly live: number; + readonly series: number; + readonly total: number; + readonly vod: number; +} + +export interface XtreamOccurrence { + readonly action: string; + readonly categoryId: string; + readonly count: number; + readonly scenario: string; + readonly transport: string; +} + +export interface XtreamLedgerEntry { + readonly action: string; + readonly atMs: number; + readonly categoryId: string; + readonly epoch: number; + readonly occurrence: number; + readonly scenario: string; + readonly status: string; + readonly transport: string; +} + +export interface XtreamControlState { + readonly epoch: number; + readonly heldCount: number; + readonly heldIds: readonly string[]; + readonly ledger: readonly XtreamLedgerEntry[]; + readonly occurrences: readonly XtreamOccurrence[]; + readonly prepared: XtreamPerformanceManifest | null; + readonly rules: readonly unknown[]; +} + +export interface XtreamBenchmarkManifest { + readonly build: XtreamBenchmarkBuildIdentity; + readonly environment: XtreamBenchmarkEnvironment; + readonly fixture: XtreamBenchmarkFixture; + readonly memoryAccounting: typeof XTREAM_MEMORY_ACCOUNTING; + readonly mode: 'formal' | 'smoke'; + readonly runtime: XtreamBenchmarkRuntime; + readonly source: XtreamBenchmarkSource; + readonly suite: 'xtream-performance-100k'; + readonly variant: string; +} + +export interface XtreamBenchmarkEnvironment { + readonly cdpAddress: '127.0.0.1'; + readonly cdpPort: number; + readonly databaseWorkerBatchDelayMs: 0; + readonly exposeGc: true; + readonly freshUserDataPerIteration: true; + readonly perfCapture: '1'; + readonly perfWorkerProfiling: '1'; + readonly traceRendererConsole: '0'; +} + +export interface XtreamBenchmarkFixture { + readonly bytes: number; + readonly catalogSha256: string; + readonly categories: 100; + readonly items: 100_000; + readonly scenario: 'performance-100k'; + readonly seed: 91_001; +} + +export interface XtreamBenchmarkRuntime { + readonly arch: string; + readonly electronVersion: string; + readonly harnessNodeVersion: string; + readonly platform: string; +} + +export interface XtreamBenchmarkSource { + readonly gitCommit: string; + readonly gitDiffSha256: string; + readonly gitDirty: boolean; +} + +export interface NumericDistribution { + readonly count: number; + readonly max: number | null; + readonly mean: number | null; + readonly median: number | null; + readonly min: number | null; + readonly p95: number | null; + readonly p99: number | null; +} + +export function summarizeXtreamNumbers( + values: readonly number[] +): NumericDistribution { + const samples = [...values].sort((left, right) => left - right); + const total = samples.reduce((sum, value) => sum + value, 0); + const percentile = (rank: number): number => { + if (samples.length === 1) return samples[0] ?? 0; + const position = ((samples.length - 1) * rank) / 100; + const lowerIndex = Math.floor(position); + const upperIndex = Math.ceil(position); + const lower = samples[lowerIndex] ?? 0; + const upper = samples[upperIndex] ?? lower; + return lower + (upper - lower) * (position - lowerIndex); + }; + return { + count: samples.length, + max: samples.at(-1) ?? null, + mean: total / samples.length, + median: percentile(50), + min: samples[0] ?? null, + p95: percentile(95), + p99: percentile(99), + }; +} + +export interface XtreamScenarioMetrics { + readonly angularRenderingMs: NumericDistribution; + readonly cancelAcknowledgementLatencyMs: NumericDistribution | null; + readonly dataAcquisitionMs: NumericDistribution; + readonly deserializationMs: NumericDistribution; + readonly databaseWorkerCpuSystemMicros: NumericDistribution; + readonly databaseWorkerCpuUserMicros: NumericDistribution; + readonly databaseWorkerEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerEventLoopUtilization: NumericDistribution; + readonly databaseWorkerPeakExternalBytes: NumericDistribution; + readonly databaseWorkerPeakHeapBytes: NumericDistribution; + readonly databaseWorkerPostGcHeapBytes: NumericDistribution; + readonly databaseWorkerRequestInvalidSampleCount: NumericDistribution; + readonly databaseWorkerRequestEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerRequestEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerRequestEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerRequestEventLoopUtilization: NumericDistribution; + readonly databaseWorkerRequestTotalCount: NumericDistribution; + readonly databaseWorkerRequestValidSampleCount: NumericDistribution; + readonly jsonParsingMs: NumericDistribution; + readonly mainCpuSystemMicros: NumericDistribution; + readonly mainCpuUserMicros: NumericDistribution; + readonly mainEventLoopDelayMaxMs: NumericDistribution; + readonly mainEventLoopDelayP95Ms: NumericDistribution; + readonly mainEventLoopDelayP99Ms: NumericDistribution; + readonly mainPeakHeapBytes: NumericDistribution; + readonly mainPeakRssBytes: NumericDistribution; + readonly mainPostGcHeapBytes: NumericDistribution; + readonly normalizationMs: NumericDistribution; + readonly rendererActionLatencyMs: NumericDistribution | null; + readonly rendererFrameGapMaxMs: NumericDistribution; + readonly rendererFrameGapP95Ms: NumericDistribution; + readonly rendererHeartbeatMaxMs: NumericDistribution; + readonly rendererHeartbeatP95Ms: NumericDistribution; + readonly rendererLongTaskCount: NumericDistribution; + readonly rendererLongTaskMaxMs: NumericDistribution; + readonly rendererNavigationLatencyMs: NumericDistribution | null; + readonly rendererPeakHeapBytes: NumericDistribution; + readonly rendererPeakRssBytes: NumericDistribution; + readonly rendererPostGcHeapBytes: NumericDistribution; + readonly rendererSearchLatencyMs: NumericDistribution | null; + readonly serializationMs: NumericDistribution; + readonly sqliteReadMs: NumericDistribution; + readonly sqliteWriteMs: NumericDistribution; + readonly storeUpdateMs: NumericDistribution; + readonly structuredCloneProxyMs: NumericDistribution; + readonly totalMs: NumericDistribution; +} + +const SCENARIOS: readonly XtreamScenario[] = Object.freeze([ + Object.freeze({ id: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE }), + Object.freeze({ id: XTREAM_SCENARIO_ID.REFRESH_LARGE }), + Object.freeze({ id: XTREAM_SCENARIO_ID.DELETE_LARGE }), + Object.freeze({ id: XTREAM_SCENARIO_ID.CANCEL_IMPORT }), + Object.freeze({ id: XTREAM_SCENARIO_ID.BACKGROUND_UI }), +]); + +export function listXtreamBenchmarkScenarios(): readonly XtreamScenario[] { + return SCENARIOS; +} + +export function createXtreamIterationDefinitions( + smoke: boolean +): readonly XtreamIterationDefinition[] { + const definitions: XtreamIterationDefinition[] = []; + for (const scenario of SCENARIOS) { + definitions.push(iteration(scenario.id, 'warmup-01', 'warmup', false)); + for (let index = 1; index <= (smoke ? 1 : 5); index += 1) { + definitions.push( + iteration( + scenario.id, + `run-${String(index).padStart(2, '0')}`, + 'measured', + !smoke + ) + ); + } + definitions.push( + iteration(scenario.id, 'diagnostic', 'diagnostic', false) + ); + } + return Object.freeze(definitions); +} + +export function expectedXtreamWorkerRequestInventory( + scenarioId: XtreamScenarioId +): readonly XtreamWorkerRequestInventoryEntry[] { + return WORKER_REQUEST_INVENTORY[scenarioId]; +} + +const PHASES = Object.freeze({ + categoriesRead: ['sqlite.categories.read'], + categoriesWrite: [ + 'normalize.categories', + 'sqlite.categories.write-transactions', + ], + contentRead: ['sqlite.content.read'], + contentWrite: [ + 'sqlite.content.category-map-read', + 'normalize.content', + 'sqlite.content.write-transactions', + ], + playlistDelete: [ + 'sqlite.playlist-delete.collect-ids', + 'sqlite.playlist-delete.write-transactions', + ], + playlistGet: ['sqlite.read', 'deserialize.playlist'], + playlistUpsert: ['serialize.playlist', 'sqlite.write'], + xtreamCacheClear: ['sqlite.xtream-cache-clear.write-transactions'], + xtreamDelete: [ + 'sqlite.xtream-delete.collect-user-data', + 'sqlite.xtream-delete.write-transactions', + ], +}); + +const WORKER_REQUEST_INVENTORY: Readonly< + Record +> = Object.freeze({ + [XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE]: inventory( + request('DB_UPSERT_APP_PLAYLIST', 1, PHASES.playlistUpsert), + request('DB_GET_PLAYLIST', 1), + request('DB_GET_ALL_PLAYBACK_POSITIONS', 1), + ...fullImportRequests() + ), + [XTREAM_SCENARIO_ID.REFRESH_LARGE]: refreshRequests(), + [XTREAM_SCENARIO_ID.DELETE_LARGE]: inventory( + request('DB_DELETE_PLAYLIST', 2, PHASES.playlistDelete) + ), + [XTREAM_SCENARIO_ID.CANCEL_IMPORT]: inventory( + request('DB_UPSERT_APP_PLAYLIST', 1, PHASES.playlistUpsert), + request('DB_GET_PLAYLIST', 1), + request('DB_GET_ALL_PLAYBACK_POSITIONS', 1), + request('DB_GET_APP_STATE', 4), + request('DB_GET_CATEGORIES', 6, PHASES.categoriesRead), + request('DB_SAVE_CATEGORIES', 3, PHASES.categoriesWrite), + request('DB_GET_CONTENT', 1, PHASES.contentRead), + request('DB_SAVE_CONTENT', 1, PHASES.contentWrite, false), + request('DB_SET_APP_STATE', 3), + request('DB_CLEAR_XTREAM_IMPORT_CACHE', 3, PHASES.xtreamCacheClear) + ), + [XTREAM_SCENARIO_ID.BACKGROUND_UI]: inventory( + ...refreshRequests(), + request('DB_GET_RECENTLY_VIEWED', 1), + request('DB_GET_ALL_GLOBAL_FAVORITES', 1), + request('DB_GET_GLOBAL_RECENTLY_ADDED', 1) + ), +}); + +function fullImportRequests( + reads: Readonly<{ + appState: number; + categories: number; + content: number; + setAppState: number; + }> = { appState: 6, categories: 6, content: 6, setAppState: 3 } +): readonly XtreamWorkerRequestInventoryEntry[] { + return inventory( + request('DB_GET_APP_STATE', reads.appState), + request('DB_GET_CATEGORIES', reads.categories, PHASES.categoriesRead), + request('DB_SAVE_CATEGORIES', 3, PHASES.categoriesWrite), + request('DB_GET_CONTENT', reads.content, PHASES.contentRead), + request('DB_SAVE_CONTENT', 3, PHASES.contentWrite), + request('DB_SET_APP_STATE', reads.setAppState) + ); +} + +function refreshRequests(): readonly XtreamWorkerRequestInventoryEntry[] { + return inventory( + request('DB_DELETE_XTREAM_CONTENT', 1, PHASES.xtreamDelete), + request('DB_GET_ALL_PLAYBACK_POSITIONS', 1), + request('DB_UPDATE_PLAYLIST', 1), + request('DB_GET_APP_PLAYLIST', 1, PHASES.playlistGet), + request('DB_GET_PLAYLIST', 2), + request('DB_UPSERT_APP_PLAYLIST', 1, PHASES.playlistUpsert), + ...fullImportRequests({ + appState: 12, + categories: 9, + content: 9, + setAppState: 6, + }), + request('DB_RESTORE_XTREAM_USER_DATA', 1), + request('DB_CLEAR_ALL_PLAYBACK_POSITIONS', 1) + ); +} + +function inventory( + ...entries: readonly XtreamWorkerRequestInventoryEntry[] +): readonly XtreamWorkerRequestInventoryEntry[] { + return Object.freeze(entries); +} + +function request( + operation: string, + count: number, + phases: readonly string[] = [], + success = true +): XtreamWorkerRequestInventoryEntry { + return Object.freeze({ + count, + operation, + phases: Object.freeze([...phases]), + success, + }); +} + +function iteration( + scenarioId: XtreamScenarioId, + runId: string, + kind: XtreamIterationKind, + eligibleForComparison: boolean +): XtreamIterationDefinition { + return Object.freeze({ + eligibleForComparison, + kind, + runId, + scenarioId, + }); +} + +export { + assertXtreamArtifactRedacted, + assertXtreamControlReadyAfterObservationReset, + assertXtreamControlStateForScenario, + assertXtreamManifest, + assertXtreamManifestIdentity, + assertXtreamMockOrigin, + parseXtreamControlState, +} from './xtream-control-client'; diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-environment.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-environment.spec.ts new file mode 100644 index 000000000..c5a22b144 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-environment.spec.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +import type { XtreamBenchmarkManifest } from './xtream-benchmark-contract'; +import { createXtreamBenchmarkSummary } from './xtream-benchmark-report'; +import { + MANIFEST, + clearXtreamReportFixtureDirectories, +} from './xtream-benchmark-report.fixtures'; +import { + clearXtreamProvenanceFixtureDirectories, + smokeReportFixture, +} from './xtream-benchmark-report-provenance.fixtures'; +import { assertXtreamBenchmarkManifest } from './xtream-exact-schema'; + +afterEach(async () => { + await Promise.all([ + clearXtreamReportFixtureDirectories(), + clearXtreamProvenanceFixtureDirectories(), + ]); +}); + +describe('Xtream benchmark CDP environment', () => { + it('allows a bounded alternate smoke port but keeps formal capture on 9222', async () => { + const smokeManifest: XtreamBenchmarkManifest = { + ...MANIFEST, + environment: { ...MANIFEST.environment, cdpPort: 9322 }, + mode: 'smoke', + }; + assert.doesNotThrow(() => assertXtreamBenchmarkManifest(smokeManifest)); + assert.throws( + () => + assertXtreamBenchmarkManifest({ + ...smokeManifest, + mode: 'formal', + }), + /invalid Xtream benchmark manifest/ + ); + + const { diagnosticEvidence, iterations } = await smokeReportFixture(); + const summary = createXtreamBenchmarkSummary( + smokeManifest, + iterations, + diagnosticEvidence + ); + assert.equal(summary.validForComparison, false); + }); + + it('requires an integer CDP port from 1024 through 65535', () => { + for (const cdpPort of [1023, 65536, 9322.5, Number.NaN]) { + assert.throws( + () => + assertXtreamBenchmarkManifest({ + ...MANIFEST, + environment: { ...MANIFEST.environment, cdpPort }, + mode: 'smoke', + }), + /invalid Xtream benchmark manifest/ + ); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-integrity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-integrity.spec.ts new file mode 100644 index 000000000..e07236227 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-integrity.spec.ts @@ -0,0 +1,274 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; + +import { createXtreamIterationDefinitions } from './xtream-benchmark-contract'; +import { createXtreamBenchmarkSummary } from './xtream-benchmark-report'; +import { + MANIFEST, + clearXtreamReportFixtureDirectories, + iteration, + persist, + persistWith, + sha256, +} from './xtream-benchmark-report.fixtures'; +import { toXtreamRawIteration } from './xtream-exact-schema'; +import { + clearXtreamProvenanceFixtureDirectories, + formalReportFixture, +} from './xtream-benchmark-report-provenance.fixtures'; +import type { + XtreamBenchmarkManifest, + XtreamPersistenceReceipt, +} from './xtream-summary-contract'; + +describe('Xtream benchmark integrity boundaries', () => { + it('uses one intrinsic snapshot instead of caller-owned array methods', async () => { + const { diagnosticEvidence, iterations } = await formalReportFixture(); + const adversarial = [...iterations]; + const invoked: string[] = []; + for (const method of ['filter', 'find', 'map'] as const) { + Object.defineProperty(adversarial, method, { + value() { + invoked.push(method); + throw new Error(`caller-owned-${method}`); + }, + }); + } + Object.defineProperty(adversarial, Symbol.iterator, { + value() { + invoked.push('iterator'); + throw new Error('caller-owned-iterator'); + }, + }); + + try { + assert.doesNotThrow(() => + createXtreamBenchmarkSummary( + MANIFEST, + adversarial, + diagnosticEvidence + ) + ); + assert.deepEqual(invoked, []); + } finally { + await clearXtreamProvenanceFixtureDirectories(); + } + }); + + it('cannot hide a reused process identity behind caller-owned map', async () => { + const fixture = await formalReportFixture(); + const definitions = createXtreamIterationDefinitions(false); + const iterations = [...fixture.iterations]; + const sourceDuplicate = iteration(required(definitions, 1), 9_999); + const duplicate = { + ...sourceDuplicate, + processIdentity: { + ...sourceDuplicate.processIdentity, + electronPid: required(iterations, 0).processIdentity + .electronPid, + }, + }; + iterations[1] = await persist(duplicate, 9_999); + Object.defineProperty(iterations, 'map', { + value() { + return Array.from( + { length: iterations.length }, + (_, index) => index + ); + }, + }); + + try { + assert.throws( + () => + createXtreamBenchmarkSummary( + MANIFEST, + iterations, + fixture.diagnosticEvidence + ), + /fresh process identity/ + ); + } finally { + await Promise.all([ + clearXtreamReportFixtureDirectories(), + clearXtreamProvenanceFixtureDirectories(), + ]); + } + }); + + it('uses native Map entries after validating diagnostic evidence', async () => { + const { diagnosticEvidence, iterations } = await formalReportFixture(); + const entries = [...diagnosticEvidence.entries()]; + const [firstIteration, firstEvidence] = required(entries, 0); + const [, secondEvidence] = required(entries, 1); + const adversarial = new Map(entries); + Object.defineProperty(adversarial, 'get', { + value(key: (typeof entries)[number][0]) { + return key === firstIteration + ? secondEvidence + : Map.prototype.get.call(this, key); + }, + }); + + try { + const summary = createXtreamBenchmarkSummary( + MANIFEST, + iterations, + adversarial + ); + const scenario = summary.scenarios.find( + ({ scenarioId }) => scenarioId === firstIteration.scenarioId + ); + assert.equal( + scenario?.diagnosticProfiles.artifactProvenance.directorySha256, + firstEvidence.directorySha256 + ); + } finally { + await clearXtreamProvenanceFixtureDirectories(); + } + }); + + it('snapshots every persistence receipt accessor exactly once', async () => { + const definition = required(createXtreamIterationDefinitions(false), 1); + const raw = iteration(definition, 1); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-xtream-receipt-accessor-') + ); + const regularPath = join(directory, 'raw.json'); + const symlinkPath = join(directory, 'raw-link.json'); + try { + await assert.rejects( + () => + persistWith(raw, async (serialized) => { + await writeFile(regularPath, serialized); + await symlink(regularPath, symlinkPath); + let pathReads = 0; + return { + get absolutePath() { + pathReads += 1; + return pathReads === 1 + ? symlinkPath + : regularPath; + }, + bytes: Buffer.byteLength(serialized), + sha256: sha256(serialized), + } as XtreamPersistenceReceipt; + }), + /persistence receipt/ + ); + + let stablePathReads = 0; + await persistWith(raw, async (serialized) => ({ + get absolutePath() { + stablePathReads += 1; + return regularPath; + }, + bytes: Buffer.byteLength(serialized), + sha256: sha256(serialized), + })); + assert.equal(stablePathReads, 1); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); + + it('uses one canonical manifest snapshot for validation and output', async () => { + const { diagnosticEvidence, iterations } = await formalReportFixture(); + let variantReads = 0; + const manifest = { + ...MANIFEST, + get variant() { + variantReads += 1; + return variantReads <= 3 ? 'baseline' : 'CON.json'; + }, + } as XtreamBenchmarkManifest; + + try { + const summary = createXtreamBenchmarkSummary( + manifest, + iterations, + diagnosticEvidence + ); + assert.equal(summary.manifest.variant, 'baseline'); + assert.equal(variantReads, 1); + } finally { + await clearXtreamProvenanceFixtureDirectories(); + } + }); + + it('binds diagnostic profile durations and worker ordinal to artifact evidence', async () => { + const fixture = await formalReportFixture(); + const [diagnostic, evidence] = required( + [...fixture.diagnosticEvidence.entries()], + 0 + ); + const diagnosticIndex = fixture.iterations.indexOf(diagnostic); + assert.notEqual(diagnosticIndex, -1); + const raw = toXtreamRawIteration(diagnostic); + const mismatches = [ + { + ...raw, + renderer: { + ...raw.renderer, + cpuProfileDurationMicros: 2_001, + }, + }, + { + ...raw, + main: { + ...raw.main, + cpuProfileDurationMicros: 2_001, + }, + }, + { + ...raw, + databaseWorker: { + ...raw.databaseWorker, + cpuProfileDurationMicros: 2_001, + }, + }, + { + ...raw, + databaseWorker: { + ...raw.databaseWorker, + ordinal: raw.databaseWorker.ordinal + 1, + }, + }, + ]; + + try { + for (const [index, mismatch] of mismatches.entries()) { + const replacement = await persist(mismatch, 20_000 + index); + const iterations = [...fixture.iterations]; + iterations[diagnosticIndex] = replacement; + const diagnosticEvidence = new Map(fixture.diagnosticEvidence); + diagnosticEvidence.delete(diagnostic); + diagnosticEvidence.set(replacement, evidence); + + assert.throws( + () => + createXtreamBenchmarkSummary( + MANIFEST, + iterations, + diagnosticEvidence + ), + /diagnostic artifact evidence binding/ + ); + } + } finally { + await Promise.all([ + clearXtreamReportFixtureDirectories(), + clearXtreamProvenanceFixtureDirectories(), + ]); + } + }); +}); + +function required(values: readonly T[], index: number): T { + const value = values[index]; + assert.ok(value); + return value; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration-support.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration-support.ts new file mode 100644 index 000000000..2f43572b6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration-support.ts @@ -0,0 +1,262 @@ +import { join } from 'node:path'; + +import { + ELECTRON_RUNTIME_ENVIRONMENT_KEYS, + type LaunchedElectronApp, +} from '../electron-test-fixtures'; +import { + XTREAM_SCENARIO_ID, + type XtreamIterationDefinition, + type XtreamPerformanceManifest, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { writeXtreamSafeJson } from './xtream-benchmark-artifacts'; +import type { XtreamControlClient } from './xtream-control-client'; +import { assertXtreamControlStateForScenario } from './xtream-control-client'; +import { + validateXtreamDiagnosticArtifacts, + type XtreamDiagnosticArtifactEvidence, +} from './xtream-diagnostic-artifacts'; +import { + readXtreamMainCaptureStatus, + startMainCapture, + type MainCaptureStartOptions, + type XtreamMainCaptureResult, + type XtreamMainCaptureStatus, +} from './m3u-refresh-main-capture'; +import { isXtreamMainCaptureSettled } from './xtream-main-settlement'; +import type { XtreamRendererCaptureMetrics } from './xtream-renderer-capture'; +import { + assertCompleteXtreamRendererProbe, + beginXtreamRendererOperation, + stopXtreamRendererProbe, + waitForXtreamRendererTerminal, +} from './xtream-renderer-probe-control'; +import { installXtreamRendererProbe } from './xtream-renderer-probe'; +import { prepareXtreamScenario } from './xtream-scenario-driver'; +import type { XtreamArtifactSafetyContext } from './xtream-summary-contract'; + +export interface SeedXtreamExistingPortalOptions { + readonly app: LaunchedElectronApp; + readonly captureOptions: MainCaptureStartOptions; + readonly control: XtreamControlClient; + readonly fixture: XtreamPerformanceManifest; + readonly iterationDirectory: string; + readonly safety: XtreamArtifactSafetyContext; + readonly serverUrl: string; + readonly sourceTitle: string; +} + +export async function prearmXtreamDatabaseWorker( + app: LaunchedElectronApp +): Promise { + const playlists = await app.mainWindow.evaluate(() => + window.electron.dbGetAppPlaylists() + ); + if (!Array.isArray(playlists)) { + throw new Error('xtream-database-worker-prearm-invalid'); + } +} + +export async function seedXtreamExistingPortal( + options: SeedXtreamExistingPortalOptions +): Promise { + const seed = await prepareXtreamScenario(options.app.mainWindow, { + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + serverUrl: options.serverUrl, + sourceTitle: options.sourceTitle, + }); + let rendererProbeInstalled = false; + try { + await installXtreamRendererProbe(options.app.mainWindow, { + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + sourceTitle: options.sourceTitle, + }); + rendererProbeInstalled = true; + await startMainCapture(options.app.electronApp, { + ...options.captureOptions, + deferMeasurement: false, + diagnostic: false, + }); + const captureGeneration = ( + await readXtreamMainCaptureStatus(options.app.electronApp) + ).captureGeneration; + await beginXtreamRendererOperation(options.app.mainWindow); + await seed.trigger(); + const driverTerminal = seed.waitForTerminal(); + await waitForXtreamRendererTerminal(options.app.mainWindow); + const rendererProbe = await stopXtreamRendererProbe( + options.app.mainWindow + ); + rendererProbeInstalled = false; + assertCompleteXtreamRendererProbe(rendererProbe); + await assertXtreamSettlementAfterTerminal({ + captureGeneration, + readStatus: () => + readXtreamMainCaptureStatus(options.app.electronApp), + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + terminal: driverTerminal, + }); + const state = await options.control.state(); + await writeXtreamSafeJson( + join(options.iterationDirectory, 'seed-control-state.json'), + state, + options.safety + ); + assertXtreamControlStateForScenario( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + state, + options.fixture + ); + } finally { + await Promise.allSettled([ + rendererProbeInstalled + ? stopXtreamRendererProbe(options.app.mainWindow) + : Promise.resolve(), + seed.dispose(), + ]); + } +} + +export interface AssertXtreamSettlementAfterTerminalOptions { + readonly captureGeneration: number; + readonly readStatus: () => Promise; + readonly scenarioId: XtreamScenarioId; + readonly terminal: Promise; +} + +export async function assertXtreamSettlementAfterTerminal( + options: AssertXtreamSettlementAfterTerminalOptions +): Promise { + await options.terminal; + const status = await options.readStatus(); + if (status.captureGeneration !== options.captureGeneration) { + throw new Error('xtream-measured-capture-generation-changed'); + } + if (!isXtreamMainCaptureSettled(status, options.scenarioId)) { + throw new Error('xtream-main-not-settled-after-terminal'); + } +} + +export interface FinalizeXtreamCaptureAfterRendererTerminalOptions< + DriverTerminal, + MainCapture, + RendererCapture, +> extends Omit { + readonly driverTerminal: Promise; + readonly rendererTerminal: Promise; + readonly stopMain: () => Promise; + readonly stopRenderer: () => Promise; +} + +export interface FinalizedXtreamCapture< + DriverTerminal, + MainCapture, + RendererCapture, +> { + readonly driverTerminal: DriverTerminal; + readonly mainCapture: MainCapture; + readonly rendererCapture: RendererCapture; +} + +export async function finalizeXtreamCaptureAfterRendererTerminal< + DriverTerminal, + MainCapture, + RendererCapture, +>( + options: FinalizeXtreamCaptureAfterRendererTerminalOptions< + DriverTerminal, + MainCapture, + RendererCapture + > +): Promise< + FinalizedXtreamCapture +> { + await assertXtreamSettlementAfterTerminal({ + captureGeneration: options.captureGeneration, + readStatus: options.readStatus, + scenarioId: options.scenarioId, + terminal: options.rendererTerminal, + }); + const rendererCapture = options.stopRenderer(); + const mainCapture = options.stopMain(); + const [resolvedRenderer, resolvedMain, driverTerminal] = await Promise.all([ + rendererCapture, + mainCapture, + options.driverTerminal, + ]); + return Object.freeze({ + driverTerminal, + mainCapture: resolvedMain, + rendererCapture: resolvedRenderer, + }); +} + +export async function assertXtreamBenchmarkEnvironmentIsolated( + app: LaunchedElectronApp +): Promise { + const keys = await app.electronApp.evaluate(() => Object.keys(process.env)); + const allowed = new Set( + [ + ...ELECTRON_RUNTIME_ENVIRONMENT_KEYS, + 'ELECTRON_IS_DEV', + 'IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS', + 'IPTVNATOR_DB_WORKER_BATCH_DELAY_MS', + 'IPTVNATOR_E2E_DATA_DIR', + 'IPTVNATOR_PERF_CAPTURE', + 'IPTVNATOR_PERF_WORKER_PROFILING', + 'IPTVNATOR_TRACE_RENDERER_CONSOLE', + 'NODE_ENV', + ].map((key) => key.toUpperCase()) + ); + if ( + !Array.isArray(keys) || + keys.some( + (key) => typeof key !== 'string' || !allowed.has(key.toUpperCase()) + ) + ) { + throw new Error('xtream-benchmark-environment-reached-electron'); + } +} + +export function createXtreamSourceTitle( + definition: XtreamIterationDefinition +): string { + return `Synthetic Xtream 100k ${definition.scenarioId} ${definition.runId}`; +} + +export async function createXtreamDiagnosticEvidence( + kind: XtreamIterationDefinition['kind'], + main: XtreamMainCaptureResult, + renderer: XtreamRendererCaptureMetrics, + iterationDirectory: string +): Promise { + if (kind !== 'diagnostic') return null; + return validateXtreamDiagnosticArtifacts( + { + main: { + cpuProfilePath: main.capture.cpuProfilePath, + heapSnapshotPath: main.capture.heapSnapshotPath, + workers: main.workers.map( + ({ + kind: workerKind, + ordinal, + profilePath, + snapshotPath, + }) => ({ + kind: workerKind, + ordinal, + profilePath, + snapshotPath, + }) + ), + }, + renderer: { + cpuProfilePath: renderer.cpuProfilePath, + heapSnapshotPath: renderer.heapSnapshotPath, + tracePath: renderer.tracePath, + }, + }, + iterationDirectory + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration.ts new file mode 100644 index 000000000..45d83ea69 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-iteration.ts @@ -0,0 +1,399 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + closeElectronApp, + launchElectronApp, + type LaunchedElectronApp, +} from '../electron-test-fixtures'; +import { + type XtreamIterationDefinition, + type XtreamPerformanceManifest, + XTREAM_SCENARIO_ID, +} from './xtream-benchmark-contract'; +import type { XtreamBenchmarkConfiguration } from './xtream-benchmark-configuration'; +import type { XtreamControlClient } from './xtream-control-client'; +import { + assertXtreamControlReadyAfterObservationReset, + assertXtreamControlStateForScenario, + assertXtreamManifestIdentity, +} from './xtream-control-client'; +import { createXtreamCatalogVerification } from './xtream-catalog-database-verification'; +import { readXtreamCatalogDatabaseSnapshot } from './xtream-catalog-database-reader'; +import { assembleXtreamRawIteration } from './xtream-iteration-assembler'; +import { + beginXtreamMainMeasurement, + installMainCapture, + readXtreamMainCaptureStatus, + rolloverXtreamMainCapture, + startMainCapture, + stopXtreamMainCapture, + type MainCaptureStartOptions, +} from './m3u-refresh-main-capture'; +import { + assertRendererCdpTarget, + assertTcpPortAvailable, + resolveRendererWindowIdentity, +} from './m3u-import-benchmark-support'; +import { assertPerformanceArtifactCapacity } from './performance-artifact-preflight'; +import { + createXtreamArtifactSafetyContext, + persistXtreamIteration, + writeXtreamSafeJson, + type PersistedXtreamIteration, +} from './xtream-benchmark-artifacts'; +import { + createXtreamSingleAttemptCapture, + persistXtreamIterationFailureEvidenceAndThrow, + type XtreamBenchmarkFailureStage, +} from './xtream-benchmark-lifecycle'; +import { + assertXtreamBenchmarkEnvironmentIsolated, + createXtreamDiagnosticEvidence, + createXtreamSourceTitle, + finalizeXtreamCaptureAfterRendererTerminal, + prearmXtreamDatabaseWorker, + seedXtreamExistingPortal, +} from './xtream-benchmark-iteration-support'; +import { + deriveXtreamMeasuredPlaylistId, + disableGenericElectronFixtureProbes, + xtreamScenarioRequiresSeed, +} from './xtream-benchmark-runtime'; +import { + captureXtreamLaunchIdentity, + createXtreamIterationProcessEvidence, +} from './xtream-process-evidence'; +import { + startXtreamRendererCapture, + type RunningXtreamRendererCapture, +} from './xtream-renderer-capture'; +import { + prepareXtreamScenario, + type PreparedXtreamScenario, + type XtreamScenarioTerminalEvidence, + type XtreamScenarioTriggerEvidence, +} from './xtream-scenario-driver'; +import { runXtreamBackgroundUiActions } from './xtream-ui-action-probe'; + +export interface XtreamBenchmarkIterationContext { + readonly configuration: XtreamBenchmarkConfiguration; + readonly control: XtreamControlClient; + readonly definition: XtreamIterationDefinition; + readonly expectedFixture: XtreamPerformanceManifest; + readonly scenarioDirectory: string; + readonly seenElectronPids: Set; +} +export async function runXtreamBenchmarkIteration( + context: XtreamBenchmarkIterationContext +): Promise { + const { configuration, control, definition, scenarioDirectory } = context; + const iterationDirectory = join(scenarioDirectory, definition.runId); + await assertPerformanceArtifactCapacity(iterationDirectory); + await mkdir(iterationDirectory, { + mode: 0o700, + recursive: false, + }); + const dataDirectory = await mkdtemp( + join(tmpdir(), 'iptvnator-xtream-performance-') + ); + const safety = createXtreamArtifactSafetyContext( + configuration.controlToken + ); + let app: LaunchedElectronApp | null = null; + let renderer: RunningXtreamRendererCapture | null = null; + let prepared: PreparedXtreamScenario | null = null; + let controlState: Awaited> | null = + null; + let failureStage: XtreamBenchmarkFailureStage = 'capture-setup'; + let stopMainCaptureOnce: + (() => ReturnType) | null = null; + let stopRendererCaptureOnce: RunningXtreamRendererCapture['stop'] | null = + null; + let terminal: XtreamScenarioTerminalEvidence | null = null; + let trigger: XtreamScenarioTriggerEvidence | null = null; + try { + await control.resetAll(); + const fixture = await control.prepare(); + assertXtreamManifestIdentity(context.expectedFixture, fixture); + await assertTcpPortAvailable(configuration.cdpPort); + app = await launchElectronApp(dataDirectory, { + args: [ + '--js-flags=--expose-gc', + '--remote-debugging-address=127.0.0.1', + `--remote-debugging-port=${configuration.cdpPort}`, + `--user-data-dir=${join(dataDirectory, 'user-data')}`, + ], + environmentInheritance: 'runtime-only', + env: { + IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: '0', + IPTVNATOR_PERF_CAPTURE: '1', + IPTVNATOR_PERF_WORKER_PROFILING: '1', + IPTVNATOR_TRACE_RENDERER_CONSOLE: '0', + }, + omitEnvKeys: [ + 'IPTVNATOR_XTREAM_MOCK_CONTROL', + 'IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN', + ], + }); + app.mainWindow.setDefaultTimeout(10 * 60 * 1_000); + await assertXtreamBenchmarkEnvironmentIsolated(app); + await disableGenericElectronFixtureProbes(app.mainWindow); + await assertRendererCdpTarget(app.mainWindow, configuration.cdpPort); + await installMainCapture(app.electronApp); + await prearmXtreamDatabaseWorker(app); + const electronApp = app.electronApp; + const launch = await captureXtreamLaunchIdentity( + app.electronApp, + app.electronApp.process().pid, + context.seenElectronPids + ); + const rendererWindowIdentity = await resolveRendererWindowIdentity(app); + const captureOptions: MainCaptureStartOptions = { + deferMeasurement: true, + diagnostic: definition.kind === 'diagnostic', + outputDirectory: iterationDirectory, + rendererWindowIdentity, + }; + const sourceTitle = createXtreamSourceTitle(definition); + const seeded = xtreamScenarioRequiresSeed(definition.scenarioId); + if (seeded) { + failureStage = 'seed'; + stopMainCaptureOnce = createXtreamSingleAttemptCapture(() => + stopXtreamMainCapture(electronApp) + ); + await seedXtreamExistingPortal({ + app, + captureOptions, + control, + fixture, + iterationDirectory, + safety, + serverUrl: configuration.mockOrigin, + sourceTitle, + }); + } + failureStage = 'scenario-preparation'; + prepared = await prepareXtreamScenario(app.mainWindow, { + scenarioId: definition.scenarioId, + serverUrl: configuration.mockOrigin, + sourceTitle, + }); + await control.resetObservations(); + assertXtreamControlReadyAfterObservationReset( + await control.state(), + fixture + ); + let captureGeneration: number; + failureStage = 'measured-capture-start'; + if (seeded) { + const rolloverOnce = createXtreamSingleAttemptCapture(() => + rolloverXtreamMainCapture(electronApp, captureOptions) + ); + stopMainCaptureOnce = rolloverOnce; + const rollover = await rolloverOnce(); + stopMainCaptureOnce = createXtreamSingleAttemptCapture(() => + stopXtreamMainCapture(electronApp) + ); + if ( + !rollover.rollover?.nextCaptureStarted || + rollover.rollover.nextCaptureUnavailableReason !== null + ) { + throw new Error('xtream-measured-capture-rollover-failed'); + } + await writeXtreamSafeJson( + join(iterationDirectory, 'seed-main-capture.json'), + rollover, + safety + ); + captureGeneration = rollover.captureGeneration + 1; + if ( + (await readXtreamMainCaptureStatus(app.electronApp)) + .captureGeneration !== captureGeneration + ) { + throw new Error('xtream-measured-capture-generation-changed'); + } + } else { + stopMainCaptureOnce = createXtreamSingleAttemptCapture(() => + stopXtreamMainCapture(electronApp) + ); + await startMainCapture(app.electronApp, captureOptions); + captureGeneration = ( + await readXtreamMainCaptureStatus(app.electronApp) + ).captureGeneration; + } + failureStage = 'renderer-capture-start'; + renderer = await startXtreamRendererCapture(app.mainWindow, { + diagnostic: definition.kind === 'diagnostic', + outputDirectory: iterationDirectory, + scenarioId: definition.scenarioId, + sourceTitle, + }); + const runningRenderer = renderer; + stopRendererCaptureOnce = createXtreamSingleAttemptCapture(() => + runningRenderer.stop() + ); + await beginXtreamMainMeasurement(app.electronApp); + await renderer.beginOperation(); + failureStage = 'operation-trigger'; + trigger = await prepared.trigger(); + if (definition.scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI) { + failureStage = 'background-ui'; + await prepared.runBackgroundProbe( + ({ operationId, page, playlistId }) => + runXtreamBackgroundUiActions(page, { + operationId, + playlistId, + searchTerm: 'Performance', + }) + ); + } + failureStage = 'terminal-settlement'; + const terminalPromise = prepared.waitForTerminal().then((value) => { + terminal = value; + return value; + }); + if (!stopRendererCaptureOnce || !stopMainCaptureOnce) { + throw new Error('xtream-measured-capture-lifecycle-invalid'); + } + const finalized = await finalizeXtreamCaptureAfterRendererTerminal({ + captureGeneration, + driverTerminal: terminalPromise, + readStatus: () => readXtreamMainCaptureStatus(app.electronApp), + rendererTerminal: renderer.waitForTerminal(), + scenarioId: definition.scenarioId, + stopMain: stopMainCaptureOnce, + stopRenderer: stopRendererCaptureOnce, + }); + terminal = finalized.driverTerminal; + const { mainCapture, rendererCapture } = finalized; + if (!terminal) { + throw new Error('xtream-measured-capture-lifecycle-invalid'); + } + if (mainCapture.captureGeneration !== captureGeneration) { + throw new Error('xtream-measured-capture-generation-changed'); + } + controlState = await control.state(); + failureStage = 'evidence-validation'; + await Promise.all([ + writeXtreamSafeJson( + join(iterationDirectory, 'main-capture.json'), + mainCapture, + safety + ), + writeXtreamSafeJson( + join(iterationDirectory, 'renderer-capture.json'), + rendererCapture, + safety + ), + writeXtreamSafeJson( + join(iterationDirectory, 'terminal-evidence.json'), + terminal, + safety + ), + writeXtreamSafeJson( + join(iterationDirectory, 'trigger-evidence.json'), + trigger, + safety + ), + writeXtreamSafeJson( + join(iterationDirectory, 'control-state.json'), + controlState, + safety + ), + ]); + assertXtreamControlStateForScenario( + definition.scenarioId, + controlState, + fixture + ); + const playlistId = deriveXtreamMeasuredPlaylistId(mainCapture.requests); + const catalogSnapshot = await readXtreamCatalogDatabaseSnapshot( + app.electronApp, + { + captureStoppedEpochMs: mainCapture.captureStoppedEpochMs, + dataDirectory, + playlistId, + requireFromPath: join( + configuration.workspaceRoot, + 'dist/apps/electron-backend/main.js' + ), + } + ); + await writeXtreamSafeJson( + join(iterationDirectory, 'catalog-snapshot.json'), + catalogSnapshot, + safety + ); + const catalogVerification = createXtreamCatalogVerification( + catalogSnapshot, + fixture, + definition.scenarioId + ); + const diagnosticEvidence = await createXtreamDiagnosticEvidence( + definition.kind, + mainCapture, + rendererCapture, + iterationDirectory + ); + const processEvidence = await createXtreamIterationProcessEvidence({ + ...launch, + diagnosticDirectorySha256: + diagnosticEvidence?.directorySha256 ?? null, + iterationDirectory, + }); + const raw = assembleXtreamRawIteration({ + catalogVerification, + definition, + diagnosticArtifacts: diagnosticEvidence, + mainCapture, + process: processEvidence, + rendererCapture, + terminal, + trigger, + }); + failureStage = 'result-persistence'; + return persistXtreamIteration( + iterationDirectory, + raw, + diagnosticEvidence, + safety + ); + } catch (failure) { + const electronApp = app?.electronApp; + await persistXtreamIterationFailureEvidenceAndThrow(failure, { + evidence: { + control: async () => controlState ?? control.state(), + ...(electronApp + ? { + mainStatus: () => + readXtreamMainCaptureStatus(electronApp), + } + : {}), + ...(stopMainCaptureOnce + ? { mainCapture: stopMainCaptureOnce } + : {}), + ...(stopRendererCaptureOnce + ? { rendererCapture: stopRendererCaptureOnce } + : {}), + ...(terminal ? { terminal: async () => terminal } : {}), + ...(trigger ? { trigger: async () => trigger } : {}), + }, + persist: (filename, value) => + writeXtreamSafeJson( + join(iterationDirectory, filename), + value, + safety + ), + stage: failureStage, + }); + } finally { + await prepared?.dispose().catch(() => undefined); + await renderer?.dispose().catch(() => undefined); + if (app) await closeElectronApp(app); + await rm(dataDirectory, { force: true, recursive: true }).catch( + () => undefined + ); + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.spec.ts new file mode 100644 index 000000000..be7038c6d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.spec.ts @@ -0,0 +1,250 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; + +import { + createXtreamSingleAttemptCapture, + persistXtreamIterationFailureEvidence, + persistXtreamIterationFailureEvidenceAndThrow, +} from './xtream-benchmark-lifecycle'; + +const performanceDirectory = resolve(process.cwd(), 'src/performance'); + +describe('Xtream benchmark failure lifecycle', () => { + it('memoizes capture finalization across success and failure paths', async () => { + let attempts = 0; + const capture = createXtreamSingleAttemptCapture(async () => { + attempts += 1; + return Object.freeze({ capture: 'complete' }); + }); + + const [first, second] = await Promise.all([capture(), capture()]); + const third = await capture(); + + assert.equal(attempts, 1); + assert.strictEqual(first, second); + assert.strictEqual(second, third); + + let failedAttempts = 0; + const failedCapture = createXtreamSingleAttemptCapture(async () => { + failedAttempts += 1; + throw new Error('fixed-test-failure'); + }); + await assert.rejects(failedCapture, /fixed-test-failure/); + await assert.rejects(failedCapture, /fixed-test-failure/); + assert.equal(failedAttempts, 1); + }); + + it('persists all available evidence and only fixed failure metadata', async () => { + const persisted = new Map(); + const attempts: string[] = []; + + await persistXtreamIterationFailureEvidence({ + evidence: { + control: async () => { + attempts.push('control'); + return { epoch: 11 }; + }, + mainCapture: async () => { + attempts.push('mainCapture'); + return { captureGeneration: 3 }; + }, + mainStatus: async () => { + attempts.push('mainStatus'); + return { captureGeneration: 3, pending: 1 }; + }, + rendererCapture: async () => { + attempts.push('rendererCapture'); + throw new Error( + 'https://performance:performance@example.invalid?token=secret' + ); + }, + terminal: async () => { + attempts.push('terminal'); + return { terminalEpochMs: 42 }; + }, + }, + persist: async (filename, value) => { + persisted.set(filename, value); + }, + stage: 'terminal-settlement', + }); + + assert.deepEqual(attempts.sort(), [ + 'control', + 'mainCapture', + 'mainStatus', + 'rendererCapture', + 'terminal', + ]); + assert.deepEqual([...persisted.keys()].sort(), [ + 'failure-control-state.json', + 'failure-main-capture.json', + 'failure-main-status.json', + 'failure-terminal-evidence.json', + 'failure.json', + ]); + const metadata = persisted.get('failure.json'); + assert.deepEqual(metadata, { + evidence: { + control: 'captured', + mainCapture: 'captured', + mainStatus: 'captured', + rendererCapture: 'capture-failed', + terminal: 'captured', + trigger: 'not-available', + }, + reason: 'xtream-benchmark-iteration-failed', + stage: 'terminal-settlement', + status: 'failed', + validForComparison: false, + }); + const serialized = JSON.stringify([...persisted.values()]); + assert.doesNotMatch(serialized, /performance:performance|secret/iu); + assert.doesNotMatch(serialized, /message|stack/iu); + }); + + it('persists fixed failure metadata when capture setup never completed', async () => { + const persisted = new Map(); + + await persistXtreamIterationFailureEvidence({ + evidence: {}, + persist: async (filename, value) => { + persisted.set(filename, value); + }, + stage: 'capture-setup', + }); + + assert.deepEqual([...persisted.keys()], ['failure.json']); + assert.deepEqual(persisted.get('failure.json'), { + evidence: { + control: 'not-available', + mainCapture: 'not-available', + mainStatus: 'not-available', + rendererCapture: 'not-available', + terminal: 'not-available', + trigger: 'not-available', + }, + reason: 'xtream-benchmark-iteration-failed', + stage: 'capture-setup', + status: 'failed', + validForComparison: false, + }); + }); + + it('fails when aggregate failure evidence cannot be persisted', async () => { + await assert.rejects( + persistXtreamIterationFailureEvidence({ + evidence: {}, + persist: async (filename) => { + if (filename === 'failure.json') { + throw new Error('fixed-persistence-failure'); + } + }, + stage: 'capture-setup', + }), + /fixed-persistence-failure/ + ); + }); + + it('rethrows the original iteration failure after evidence persistence succeeds', async () => { + const originalFailure = new Error('fixed-original-failure'); + + await assert.rejects( + persistXtreamIterationFailureEvidenceAndThrow(originalFailure, { + evidence: {}, + persist: async () => undefined, + stage: 'capture-setup', + }), + (failure: unknown) => { + assert.strictEqual(failure, originalFailure); + return true; + } + ); + }); + + it('preserves both the original and evidence persistence failures', async () => { + const originalFailure = new Error('fixed-original-failure'); + const persistenceFailure = new Error('fixed-persistence-failure'); + + await assert.rejects( + persistXtreamIterationFailureEvidenceAndThrow(originalFailure, { + evidence: {}, + persist: async (filename) => { + if (filename === 'failure.json') { + throw persistenceFailure; + } + }, + stage: 'capture-setup', + }), + (failure: unknown) => { + assert.ok(failure instanceof AggregateError); + assert.deepEqual(failure.errors, [ + originalFailure, + persistenceFailure, + ]); + assert.strictEqual(failure.cause, originalFailure); + return true; + } + ); + }); + + it('collects failure evidence before teardown and writes summary before its guard', () => { + const iteration = readFileSync( + resolve(performanceDirectory, 'xtream-benchmark-iteration.ts'), + 'utf8' + ); + const failureCatch = iteration.indexOf('} catch (failure) {'); + const failurePersistence = iteration.indexOf( + 'persistXtreamIterationFailureEvidenceAndThrow(', + failureCatch + ); + const teardown = iteration.indexOf('} finally {', failureCatch); + + assert.ok(failureCatch >= 0, 'iteration failure catch is missing'); + assert.ok( + failurePersistence > failureCatch, + 'failure evidence persistence is missing' + ); + assert.ok( + teardown > failurePersistence, + 'teardown occurs before failure evidence persistence' + ); + assert.doesNotMatch( + iteration.slice(failureCatch, teardown), + /if \(captureInstalled\)/u, + 'early capture-setup failure metadata is incorrectly gated' + ); + assert.match(iteration, /createXtreamSingleAttemptCapture/); + const seededBranch = iteration.indexOf('if (seeded) {'); + const seedStop = iteration.indexOf( + 'stopMainCaptureOnce = createXtreamSingleAttemptCapture', + seededBranch + ); + const seedRun = iteration.indexOf( + 'await seedXtreamExistingPortal', + seededBranch + ); + assert.ok( + seedStop > seededBranch && seedRun > seedStop, + 'seed capture does not have failure finalization armed' + ); + + const orchestrator = readFileSync( + resolve(performanceDirectory, 'xtream-benchmark-orchestrator.ts'), + 'utf8' + ); + const summaryWrite = orchestrator.indexOf( + "join(configuration.outputDirectory, 'summary.json')" + ); + const validityGuard = orchestrator.indexOf( + 'summary.validForComparison !==' + ); + assert.ok(summaryWrite >= 0, 'summary persistence is missing'); + assert.ok( + validityGuard > summaryWrite, + 'comparison-validity guard runs before summary persistence' + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.ts new file mode 100644 index 000000000..d41d35f49 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.ts @@ -0,0 +1,93 @@ +export type XtreamBenchmarkFailureStage = + | 'background-ui' + | 'capture-setup' + | 'capture-stop' + | 'evidence-validation' + | 'measured-capture-start' + | 'operation-trigger' + | 'renderer-capture-start' + | 'result-persistence' + | 'scenario-preparation' + | 'seed' + | 'terminal-settlement'; + +type FailureEvidenceName = + | 'control' + | 'mainCapture' + | 'mainStatus' + | 'rendererCapture' + | 'terminal' + | 'trigger'; + +type FailureEvidenceStatus = 'capture-failed' | 'captured' | 'not-available'; + +interface PersistXtreamIterationFailureEvidenceOptions { + readonly evidence: Partial< + Record Promise> + >; + readonly persist: (filename: string, value: unknown) => Promise; + readonly stage: XtreamBenchmarkFailureStage; +} + +const EVIDENCE_ARTIFACTS: readonly [FailureEvidenceName, string][] = + Object.freeze([ + ['mainStatus', 'failure-main-status.json'], + ['mainCapture', 'failure-main-capture.json'], + ['rendererCapture', 'failure-renderer-capture.json'], + ['trigger', 'failure-trigger-evidence.json'], + ['terminal', 'failure-terminal-evidence.json'], + ['control', 'failure-control-state.json'], + ]); + +export function createXtreamSingleAttemptCapture( + capture: () => Promise +): () => Promise { + let attempt: Promise | null = null; + return () => { + attempt ??= Promise.resolve().then(capture); + return attempt; + }; +} + +export async function persistXtreamIterationFailureEvidence( + options: PersistXtreamIterationFailureEvidenceOptions +): Promise { + const status = {} as Record; + for (const [name, filename] of EVIDENCE_ARTIFACTS) { + const capture = options.evidence[name]; + if (!capture) { + status[name] = 'not-available'; + continue; + } + try { + const value = await capture(); + await options.persist(filename, value); + status[name] = 'captured'; + } catch { + status[name] = 'capture-failed'; + } + } + await options.persist('failure.json', { + evidence: status, + reason: 'xtream-benchmark-iteration-failed', + stage: options.stage, + status: 'failed', + validForComparison: false, + }); +} + +export async function persistXtreamIterationFailureEvidenceAndThrow( + failure: unknown, + options: PersistXtreamIterationFailureEvidenceOptions +): Promise { + try { + await persistXtreamIterationFailureEvidence(options); + } catch (persistenceFailure) { + throw new AggregateError( + [failure, persistenceFailure], + 'Xtream benchmark iteration and failure evidence persistence failed', + { cause: failure } + ); + } + throw failure; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-manifest-schema.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-manifest-schema.ts new file mode 100644 index 000000000..fc2dd445d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-manifest-schema.ts @@ -0,0 +1,186 @@ +import type { XtreamBenchmarkManifest } from './xtream-benchmark-contract'; +import { parseXtreamBenchmarkBuildIdentity } from './xtream-build-identity-schema'; +import { assertXtreamArtifactRedacted } from './xtream-diagnostic-artifacts'; +import { XTREAM_MEMORY_ACCOUNTING } from './xtream-summary-contract'; + +export const XTREAM_SHA256 = /^[a-f0-9]{64}$/u; +export const XTREAM_IDENTIFIER = + /^(?!(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$))[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9_-])?$/iu; + +const ROOT_KEYS = [ + 'build', + 'environment', + 'fixture', + 'memoryAccounting', + 'mode', + 'runtime', + 'source', + 'suite', + 'variant', +] as const; +const ENVIRONMENT_KEYS = [ + 'cdpAddress', + 'cdpPort', + 'databaseWorkerBatchDelayMs', + 'exposeGc', + 'freshUserDataPerIteration', + 'perfCapture', + 'perfWorkerProfiling', + 'traceRendererConsole', +] as const; +const FIXTURE_KEYS = [ + 'bytes', + 'catalogSha256', + 'categories', + 'items', + 'scenario', + 'seed', +] as const; +const RUNTIME_KEYS = [ + 'arch', + 'electronVersion', + 'harnessNodeVersion', + 'platform', +] as const; +const SOURCE_KEYS = ['gitCommit', 'gitDiffSha256', 'gitDirty'] as const; +const MEMORY_KEYS = Object.keys(XTREAM_MEMORY_ACCOUNTING); + +export function parseXtreamBenchmarkManifest( + value: unknown +): XtreamBenchmarkManifest { + try { + const input = snapshotExactRecord(value, ROOT_KEYS); + const build = parseXtreamBenchmarkBuildIdentity(input['build']); + const environment = snapshotExactRecord( + input['environment'], + ENVIRONMENT_KEYS + ); + const fixture = snapshotExactRecord(input['fixture'], FIXTURE_KEYS); + const memoryAccounting = snapshotExactRecord( + input['memoryAccounting'], + MEMORY_KEYS + ); + const runtime = snapshotExactRecord(input['runtime'], RUNTIME_KEYS); + const source = snapshotExactRecord(input['source'], SOURCE_KEYS); + const snapshot = { + build, + environment: { ...environment }, + fixture: { ...fixture }, + memoryAccounting: { ...memoryAccounting }, + mode: input['mode'], + runtime: { ...runtime }, + source: { ...source }, + suite: input['suite'], + variant: input['variant'], + }; + assertXtreamArtifactRedacted(snapshot); + if (!isValidManifest(snapshot)) invalid(); + return freezeDeep(snapshot) as unknown as XtreamBenchmarkManifest; + } catch { + throw new Error('invalid Xtream benchmark manifest'); + } +} + +export function assertXtreamBenchmarkManifest( + value: unknown +): asserts value is XtreamBenchmarkManifest { + parseXtreamBenchmarkManifest(value); +} + +function isValidManifest( + manifest: Record & { + environment: Record; + fixture: Record; + memoryAccounting: Record; + runtime: Record; + source: Record; + } +): boolean { + const { environment, fixture, memoryAccounting, runtime, source } = + manifest; + return ( + environment['cdpAddress'] === '127.0.0.1' && + isValidXtreamCdpPort(manifest['mode'], environment['cdpPort']) && + environment['databaseWorkerBatchDelayMs'] === 0 && + environment['exposeGc'] === true && + environment['freshUserDataPerIteration'] === true && + environment['perfCapture'] === '1' && + environment['perfWorkerProfiling'] === '1' && + environment['traceRendererConsole'] === '0' && + fixture['scenario'] === 'performance-100k' && + fixture['seed'] === 91_001 && + fixture['categories'] === 100 && + fixture['items'] === 100_000 && + isPositiveSafeInteger(fixture['bytes']) && + typeof fixture['catalogSha256'] === 'string' && + XTREAM_SHA256.test(fixture['catalogSha256']) && + Object.values(runtime).every( + (entry) => typeof entry === 'string' && entry.length > 0 + ) && + typeof source['gitCommit'] === 'string' && + /^[a-f0-9]{40}$/u.test(source['gitCommit']) && + typeof source['gitDiffSha256'] === 'string' && + XTREAM_SHA256.test(source['gitDiffSha256']) && + typeof source['gitDirty'] === 'boolean' && + (manifest['mode'] !== 'formal' || !source['gitDirty']) && + (manifest['mode'] === 'formal' || manifest['mode'] === 'smoke') && + manifest['suite'] === 'xtream-performance-100k' && + typeof manifest['variant'] === 'string' && + XTREAM_IDENTIFIER.test(manifest['variant']) && + MEMORY_KEYS.every( + (key) => + memoryAccounting[key] === + XTREAM_MEMORY_ACCOUNTING[ + key as keyof typeof XTREAM_MEMORY_ACCOUNTING + ] + ) + ); +} + +function isValidXtreamCdpPort(mode: unknown, cdpPort: unknown): boolean { + return ( + (mode === 'formal' || mode === 'smoke') && + typeof cdpPort === 'number' && + Number.isInteger(cdpPort) && + cdpPort >= 1024 && + cdpPort <= 65_535 && + (mode === 'smoke' || cdpPort === 9222) + ); +} + +function snapshotExactRecord( + value: unknown, + expectedKeys: readonly string[] +): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + invalid(); + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expectedKeys.length || + keys.some( + (key) => typeof key !== 'string' || !expectedKeys.includes(key) + ) + ) { + invalid(); + } + return Object.fromEntries( + expectedKeys.map((key) => [key, Reflect.get(value, key)]) + ); +} + +function freezeDeep(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.values(value).forEach(freezeDeep); + Object.freeze(value); + } + return value; +} + +function isPositiveSafeInteger(value: unknown): boolean { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +function invalid(): never { + throw new Error('invalid manifest'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-orchestrator.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-orchestrator.ts new file mode 100644 index 000000000..9ceab658f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-orchestrator.ts @@ -0,0 +1,119 @@ +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + createXtreamIterationDefinitions, + listXtreamBenchmarkScenarios, +} from './xtream-benchmark-contract'; +import { + assertXtreamBenchmarkBuildUnchanged, + createXtreamBenchmarkManifest, + resolveXtreamBenchmarkConfiguration, +} from './xtream-benchmark-configuration'; +import { + createXtreamArtifactSafetyContext, + writeXtreamSafeJson, +} from './xtream-benchmark-artifacts'; +import { runXtreamBenchmarkIteration } from './xtream-benchmark-iteration'; +import { createXtreamBenchmarkSummary } from './xtream-benchmark-report'; +import { + assertXtreamControlReadyAfterObservationReset, + createXtreamControlClient, +} from './xtream-control-client'; +import type { XtreamDiagnosticArtifactEvidence } from './xtream-diagnostic-artifacts'; +import type { XtreamValidatedIterationResult } from './xtream-summary-contract'; + +export async function runXtreamBenchmark(): Promise { + const configuration = await resolveXtreamBenchmarkConfiguration(); + const safety = createXtreamArtifactSafetyContext( + configuration.controlToken + ); + const control = createXtreamControlClient({ + origin: configuration.mockOrigin, + token: configuration.controlToken, + }); + const iterations: XtreamValidatedIterationResult[] = []; + const diagnosticEvidence = new Map< + XtreamValidatedIterationResult, + XtreamDiagnosticArtifactEvidence + >(); + const seenElectronPids = new Set(); + try { + await control.resetAll(); + const expectedFixture = await control.prepare(); + await control.resetObservations(); + assertXtreamControlReadyAfterObservationReset( + await control.state(), + expectedFixture + ); + const manifest = createXtreamBenchmarkManifest( + configuration, + expectedFixture + ); + await writeXtreamSafeJson( + join(configuration.outputDirectory, 'manifest.json'), + manifest, + safety + ); + const definitions = createXtreamIterationDefinitions( + configuration.smoke + ); + for (const scenario of listXtreamBenchmarkScenarios()) { + const scenarioDirectory = join( + configuration.outputDirectory, + scenario.id + ); + await mkdir(scenarioDirectory, { + mode: 0o700, + recursive: false, + }); + for (const definition of definitions.filter( + ({ scenarioId }) => scenarioId === scenario.id + )) { + console.log( + `[performance] ${configuration.variant}/${scenario.id}/${definition.runId} starting` + ); + const persisted = await runXtreamBenchmarkIteration({ + configuration, + control, + definition, + expectedFixture, + scenarioDirectory, + seenElectronPids, + }); + iterations.push(persisted.iteration); + if (persisted.diagnosticEvidence) { + diagnosticEvidence.set( + persisted.iteration, + persisted.diagnosticEvidence + ); + } + console.log( + `[performance] ${scenario.id}/${definition.runId} total=${persisted.iteration.phases.totalMs.toFixed(3)}ms` + ); + } + } + await assertXtreamBenchmarkBuildUnchanged(configuration); + const summary = createXtreamBenchmarkSummary( + manifest, + iterations, + diagnosticEvidence + ); + await writeXtreamSafeJson( + join(configuration.outputDirectory, 'summary.json'), + summary, + safety + ); + if (summary.validForComparison !== (configuration.mode === 'formal')) { + throw new Error('xtream-benchmark-comparison-validity-invalid'); + } + console.log( + `[performance] completed: ${join( + configuration.outputDirectory, + 'summary.json' + )}` + ); + } finally { + await control.resetAll().catch(() => undefined); + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-output-configuration.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-output-configuration.ts new file mode 100644 index 000000000..99b8a3e42 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-output-configuration.ts @@ -0,0 +1,180 @@ +import type { MakeDirectoryOptions } from 'node:fs'; +import { lstat, mkdir, rmdir } from 'node:fs/promises'; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path'; + +import { assertXtreamSafePathIdentifier } from './xtream-exact-schema'; + +export const XTREAM_BENCHMARK_FORMAL_CDP_PORT = 9222; + +const OUTPUT_TIMESTAMP = + /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z(?:-[a-z][a-z0-9-]{0,63})?$/u; + +export interface XtreamBenchmarkOutputLayout { + readonly outputDirectory: string; + readonly outputRoot: string; + readonly performanceRoot: string; +} + +export type XtreamMkdir = ( + path: string, + options?: MakeDirectoryOptions +) => Promise; + +export function resolveXtreamBenchmarkOutputLayout( + repositoryRoot: string, + requestedRoot: string, + variant: string +): XtreamBenchmarkOutputLayout { + const root = resolve(repositoryRoot); + const performanceRoot = join(root, 'dist', 'performance'); + const outputRoot = resolve(requestedRoot); + if ( + !isAbsolute(repositoryRoot) || + !isAbsolute(requestedRoot) || + requestedRoot !== outputRoot || + dirname(outputRoot) !== performanceRoot || + !isValidTimestampDirectory(basename(outputRoot)) + ) { + throw new Error( + 'Performance output must be an absolute fresh timestamp path below dist/performance' + ); + } + try { + assertXtreamSafePathIdentifier(variant, 'variant'); + } catch { + throw new Error('IPTVNATOR_PERF_VARIANT must be a safe Xtream variant'); + } + const outputDirectory = join(outputRoot, variant); + if (!isStrictDescendant(outputRoot, outputDirectory)) { + throw new Error('IPTVNATOR_PERF_VARIANT must be a safe Xtream variant'); + } + return Object.freeze({ outputDirectory, outputRoot, performanceRoot }); +} + +export function resolveXtreamBenchmarkCdpPort( + mode: 'formal' | 'smoke', + requestedPort: string | undefined +): number { + const port = + requestedPort === undefined + ? XTREAM_BENCHMARK_FORMAL_CDP_PORT + : Number(requestedPort); + if ( + (requestedPort !== undefined && !/^[1-9]\d*$/u.test(requestedPort)) || + !Number.isSafeInteger(port) || + port < 1024 || + port > 65_535 + ) { + throw new Error('IPTVNATOR_PERF_CDP_PORT is invalid'); + } + if (mode === 'formal' && port !== XTREAM_BENCHMARK_FORMAL_CDP_PORT) { + throw new Error( + `Formal Xtream performance runs require CDP port ${XTREAM_BENCHMARK_FORMAL_CDP_PORT}` + ); + } + return port; +} + +export async function assertXtreamBenchmarkOutputPathHasNoSymlinks( + repositoryRoot: string, + outputRoot: string +): Promise { + const root = resolve(repositoryRoot); + const output = resolve(outputRoot); + if (!isStrictDescendant(root, output)) { + throw new Error('Xtream performance output escapes the repository'); + } + const components = relative(root, output).split(sep); + let current = root; + for (const component of ['', ...components]) { + current = component ? join(current, component) : current; + try { + if ((await lstat(current)).isSymbolicLink()) { + throw new Error( + `Xtream performance output contains a symbolic link: ${current}` + ); + } + } catch (error) { + if (isMissingPath(error)) return; + throw error; + } + } +} + +export async function createExclusiveXtreamBenchmarkOutputLayout( + repositoryRoot: string, + layout: XtreamBenchmarkOutputLayout, + createDirectory: XtreamMkdir = mkdir +): Promise { + await createDirectory(layout.performanceRoot, { + mode: 0o700, + recursive: true, + }); + await assertXtreamBenchmarkOutputPathHasNoSymlinks( + repositoryRoot, + layout.outputRoot + ); + await createDirectory(layout.outputRoot, { + mode: 0o700, + recursive: false, + }); + try { + await createDirectory(layout.outputDirectory, { + mode: 0o700, + recursive: false, + }); + } catch (error) { + await rmdir(layout.outputRoot).catch(() => undefined); + throw error; + } + await assertXtreamBenchmarkOutputPathHasNoSymlinks( + repositoryRoot, + layout.outputDirectory + ); +} + +function isValidTimestampDirectory(value: string): boolean { + const match = OUTPUT_TIMESTAMP.exec(value); + if (!match) return false; + const [, year, month, day, hour, minute, second] = match; + const timestamp = Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second) + ); + return ( + Number.isFinite(timestamp) && + new Date(timestamp).toISOString().replace(/[-:]/gu, '').slice(0, 15) === + `${year}${month}${day}T${hour}${minute}${second}` + ); +} + +function isStrictDescendant(parent: string, candidate: string): boolean { + const pathFromParent = relative(resolve(parent), resolve(candidate)); + return ( + pathFromParent.length > 0 && + pathFromParent !== '..' && + !pathFromParent.startsWith(`..${sep}`) && + !isAbsolute(pathFromParent) + ); +} + +function isMissingPath(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ENOENT' + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report-provenance.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report-provenance.fixtures.ts new file mode 100644 index 000000000..ad770f04d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report-provenance.fixtures.ts @@ -0,0 +1,178 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + createXtreamIterationDefinitions, + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, + type XtreamIterationDefinition, +} from './xtream-benchmark-contract'; +import { + validateXtreamDiagnosticArtifacts, + type XtreamDiagnosticArtifactEvidence, + type XtreamDiagnosticCapture, +} from './xtream-diagnostic-artifacts'; +import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures'; +import { + iteration, + persist, + withWorkerRequestOverlap, +} from './xtream-benchmark-report.fixtures'; +import type { XtreamValidatedIterationResult } from './xtream-summary-contract'; + +const HEAP_SNAPSHOT = { + edges: [0, 0, 0], + nodes: [0, 0, 1, 0, 1, 0], + snapshot: { + meta: { + edge_fields: ['type', 'name_or_index', 'to_node'], + edge_types: [['context'], 'string_or_number', 'node'], + node_fields: [ + 'type', + 'name', + 'id', + 'self_size', + 'edge_count', + 'detachedness', + ], + node_types: [ + ['hidden'], + 'string', + 'number', + 'number', + 'number', + 'number', + ], + }, + }, + strings: ['', 'root'], +}; +const RENDERER_TRACE = { + traceEvents: [{ name: 'xtream-diagnostic', ph: 'X', ts: 1 }], +}; +const directories: string[] = []; + +export interface XtreamReportFixture { + readonly diagnosticEvidence: ReadonlyMap< + XtreamValidatedIterationResult, + XtreamDiagnosticArtifactEvidence + >; + readonly iterations: readonly XtreamValidatedIterationResult[]; +} + +export async function formalReportFixture(): Promise { + return reportFixture(createXtreamIterationDefinitions(false), 10, true); +} + +export async function smokeReportFixture(): Promise { + return reportFixture(createXtreamIterationDefinitions(true), 100, false); +} + +export async function clearXtreamProvenanceFixtureDirectories(): Promise { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +} + +async function reportFixture( + definitions: readonly XtreamIterationDefinition[], + identityOffset: number, + mixedInitialOverlap: boolean +): Promise { + const entries = await Promise.all( + definitions.map(async (definition, index) => { + const identity = index + 1; + let raw = iteration(definition, identity); + let evidence: XtreamDiagnosticArtifactEvidence | null = null; + if (definition.kind === XTREAM_ITERATION_KIND.DIAGNOSTIC) { + evidence = await diagnosticEvidence(identity + identityOffset); + raw = { + ...raw, + processIdentity: { + ...raw.processIdentity, + profileDirectorySha256: evidence.directorySha256, + }, + }; + } + if ( + mixedInitialOverlap && + definition.scenarioId === + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE && + ['run-02', 'run-04'].includes(definition.runId) + ) { + raw = withWorkerRequestOverlap(raw, 2); + } + return { + evidence, + iteration: await persist(raw, identity + identityOffset), + }; + }) + ); + return { + diagnosticEvidence: new Map( + entries.flatMap(({ evidence, iteration: result }) => + evidence ? [[result, evidence] as const] : [] + ) + ), + iterations: entries.map(({ iteration: result }) => result), + }; +} + +async function diagnosticEvidence( + identity: number +): Promise { + const directory = await mkdtemp( + join(tmpdir(), `iptvnator-xtream-report-diagnostic-${identity}-`) + ); + directories.push(directory); + await writeArtifacts(directory); + return validateXtreamDiagnosticArtifacts(capture(directory), directory); +} + +function capture(directory: string): XtreamDiagnosticCapture { + return { + main: { + cpuProfilePath: join(directory, 'main.cpuprofile'), + heapSnapshotPath: join(directory, 'main.heapsnapshot'), + workers: [ + { + kind: 'database.worker', + ordinal: 1, + profilePath: join( + directory, + 'database.worker-1.cpuprofile' + ), + snapshotPath: join( + directory, + 'database.worker-1.heapsnapshot' + ), + }, + ], + }, + renderer: { + cpuProfilePath: join(directory, 'renderer.cpuprofile'), + heapSnapshotPath: join(directory, 'renderer.heapsnapshot'), + tracePath: join(directory, 'renderer.trace.json'), + }, + }; +} + +async function writeArtifacts(directory: string): Promise { + const artifacts = [ + ['main.cpuprofile', CPU_PROFILE], + ['renderer.cpuprofile', CPU_PROFILE], + ['database.worker-1.cpuprofile', CPU_PROFILE], + ['main.heapsnapshot', HEAP_SNAPSHOT], + ['renderer.heapsnapshot', HEAP_SNAPSHOT], + ['database.worker-1.heapsnapshot', HEAP_SNAPSHOT], + ['renderer.trace.json', RENDERER_TRACE], + ] as const; + await Promise.all( + artifacts.map(([filename, value]) => + writeFile(join(directory, filename), JSON.stringify(value)) + ) + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts new file mode 100644 index 000000000..19c6c50f5 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.fixtures.ts @@ -0,0 +1,400 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, + type XtreamIterationDefinition, +} from './xtream-benchmark-contract'; +import { + computeXtreamBuildFilesAggregate, + type XtreamBenchmarkBuildIdentity, + type XtreamBuildFileIdentity, +} from './xtream-build-identity'; +import { persistThenValidateXtreamIteration } from './xtream-benchmark-report'; +import { bindFixtureCancellationEvidence } from './xtream-cancellation-evidence.fixture'; +import { bindFixtureIpcWorkerIdentities } from './xtream-ipc-worker-identity.fixture'; +import { scenarioCapture } from './xtream-phase-attribution.fixtures'; +import { attributeXtreamPhases } from './xtream-phase-attribution'; +import { + XTREAM_MEMORY_ACCOUNTING, + type XtreamArtifactSafetyContext, + type XtreamBenchmarkManifest, + type XtreamPersistenceReceipt, + type XtreamRawIterationResult, + type XtreamValidatedIterationResult, +} from './xtream-summary-contract'; +import { + invalidateWorkerRequestMetrics, + workerRequestEvidence, +} from './xtream-worker-request-evidence.fixtures'; +import { bindFixtureWorkerEvidence } from './xtream-worker-phase-evidence.fixture'; +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; + +export const TOKEN = 'random-control-token-that-must-never-persist'; +const buildFile = (path: string, marker: string): XtreamBuildFileIdentity => ({ + bytes: marker.length, + path, + sha256: sha256(marker), +}); +const rendererFiles = [ + buildFile('dist/apps/web/index.html', 'renderer-index'), + buildFile('dist/apps/web/main-FIXTURE.js', 'renderer-javascript'), + buildFile('dist/apps/web/main-FIXTURE.js.map', 'renderer-source-map'), +] as const; +export const BUILD_IDENTITY: XtreamBenchmarkBuildIdentity = { + electron: { + databaseWorker: { + javascript: buildFile( + 'dist/apps/electron-backend/workers/database.worker.js', + 'database-worker-javascript' + ), + sourceMap: buildFile( + 'dist/apps/electron-backend/workers/database.worker.js.map', + 'database-worker-source-map' + ), + }, + main: { + javascript: buildFile( + 'dist/apps/electron-backend/main.js', + 'main-javascript' + ), + sourceMap: buildFile( + 'dist/apps/electron-backend/main.js.map', + 'main-source-map' + ), + }, + playlistRefreshWorker: { + javascript: buildFile( + 'dist/apps/electron-backend/workers/playlist-refresh.worker.js', + 'playlist-worker-javascript' + ), + sourceMap: buildFile( + 'dist/apps/electron-backend/workers/playlist-refresh.worker.js.map', + 'playlist-worker-source-map' + ), + }, + preload: { + javascript: buildFile( + 'dist/apps/electron-backend/main.preload.js', + 'preload-javascript' + ), + sourceMap: buildFile( + 'dist/apps/electron-backend/main.preload.js.map', + 'preload-source-map' + ), + }, + }, + renderer: { + aggregateSha256: computeXtreamBuildFilesAggregate(rendererFiles), + bytes: rendererFiles.reduce((total, file) => total + file.bytes, 0), + executableJavaScriptCount: 1, + files: rendererFiles, + sourceMapCount: 1, + }, +}; +const SAFETY: XtreamArtifactSafetyContext = { + controlToken: TOKEN, + syntheticCredentials: { + password: 'performance', + username: 'performance', + }, +}; +export const MANIFEST: XtreamBenchmarkManifest = { + build: BUILD_IDENTITY, + environment: { + cdpAddress: '127.0.0.1', + cdpPort: 9222, + databaseWorkerBatchDelayMs: 0, + exposeGc: true, + freshUserDataPerIteration: true, + perfCapture: '1', + perfWorkerProfiling: '1', + traceRendererConsole: '0', + }, + fixture: { + bytes: 12_345, + catalogSha256: hash('1'), + categories: 100, + items: 100_000, + scenario: 'performance-100k', + seed: 91_001, + }, + memoryAccounting: XTREAM_MEMORY_ACCOUNTING, + mode: 'formal', + runtime: { + arch: 'arm64', + electronVersion: '38.0.0', + harnessNodeVersion: '22.0.0', + platform: 'darwin', + }, + source: { + gitCommit: '0123456789abcdef0123456789abcdef01234567', + gitDiffSha256: hash('2'), + gitDirty: false, + }, + suite: 'xtream-performance-100k', + variant: 'baseline', +}; +const directories: string[] = []; +export async function clearXtreamReportFixtureDirectories(): Promise { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +} + +export async function persist( + raw: XtreamRawIterationResult, + id: number, + afterWrite?: () => void +): Promise { + return persistWith(raw, async (serialized) => { + const directory = await mkdtemp( + join(tmpdir(), `iptvnator-xtream-raw-${id}-`) + ); + directories.push(directory); + const absolutePath = join(directory, 'raw.json'); + await writeFile(absolutePath, serialized, 'utf8'); + afterWrite?.(); + return { + absolutePath, + bytes: Buffer.byteLength(serialized), + sha256: sha256(serialized), + }; + }); +} + +export function persistWith( + raw: XtreamRawIterationResult, + write: (serialized: string) => Promise +): Promise { + return persistThenValidateXtreamIteration(raw, SAFETY, write); +} + +export function iteration( + definition: XtreamIterationDefinition, + identity: number +): XtreamRawIterationResult { + const diagnostic = definition.kind === XTREAM_ITERATION_KIND.DIAGNOSTIC; + const background = + definition.scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI; + const workerBinding = bindFixtureWorkerEvidence( + scenarioCapture(definition.scenarioId), + workerRequestEvidence(definition.scenarioId) + ); + const cancellationBinding = bindFixtureCancellationEvidence( + workerBinding.phaseCapture, + workerBinding.requests + ); + const ipcWorkerBinding = bindFixtureIpcWorkerIdentities( + cancellationBinding.phaseCapture, + workerBinding.requests + ); + const phaseCapture = ipcWorkerBinding.phaseCapture; + const phases = attributeXtreamPhases(phaseCapture); + const requestEvidence = ipcWorkerBinding.requests; + const capturedRequestEvidence = background + ? invalidateWorkerRequestMetrics(requestEvidence, 3) + : requestEvidence; + const requestTotalCount = requestEvidence.length; + const profile = { + cpuProfileDurationMicros: diagnostic ? 2_000 : null, + cpuProfileUnavailableReason: diagnostic + ? null + : 'not-captured-outside-diagnostic', + } as const; + return { + ...definition, + processIdentity: { + captureGeneration: 1, + electronPid: 10_000 + identity, + freshProcess: true, + generationIdentitySha256: numberedHash(identity, 3), + launchId: `launch-${identity}`, + profileDirectorySha256: numberedHash(identity, 4), + }, + phaseCapture, + phases, + renderer: { + ...profile, + actionLatencyMs: background ? 7 : null, + consoleErrors: [], + dashboardPaintVisibility: background + ? 'visible-without-overlay' + : null, + frameGapMaxMs: 45, + frameGapP95Ms: 20, + heartbeatMaxMs: 12, + heartbeatP95Ms: 5, + longTaskCount: 2, + longTaskMaxMs: 75, + navigationLatencyMs: background ? 8 : null, + peakHeapUsedBytes: 300, + peakRssBytes: 500, + postGcHeapUsedBytes: 200, + searchLatencyMs: background ? 9 : null, + storeToPaintMs: phases.angularRenderingMs, + targetId: `renderer-${identity}`, + }, + main: { + ...profile, + browserWindowId: 1, + cpuMetricScope: 'electron-main-thread', + cpuSystemMicros: 200, + cpuUserMicros: 1_000, + eventLoopDelayMaxMs: 8, + eventLoopDelayP95Ms: 2, + eventLoopDelayP99Ms: 4, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + 'electron-main-embedded-event-loop', + peakHeapUsedBytes: 400, + peakRssBytes: 1_000, + postGcHeapUsedBytes: 250, + responsiveEvents: 0, + unresponsiveEvents: 0, + webContentsId: 2, + }, + databaseWorker: { + ...profile, + cpuMetricScope: 'sum-valid-request-thread-cpu', + cpuSystemMicros: + capturedRequestEvidence.filter( + ({ invalidReason }) => invalidReason === null + ).length * 10, + cpuUserMicros: + capturedRequestEvidence.filter( + ({ invalidReason }) => invalidReason === null + ).length * 20, + currentForCapture: true, + eventLoopDelayMaxMs: 9, + eventLoopDelayMetricScope: 'valid-request-window-proxy', + eventLoopDelayP95Ms: 3, + eventLoopDelayP99Ms: 5, + eventLoopUtilization: 0.6, + kind: 'database.worker', + ordinal: 1, + peakExternalBytes: 40, + peakHeapUsedBytes: 250, + postGcHeapUsedBytes: 150, + requests: { + evidence: capturedRequestEvidence, + eventLoopDelayMaxMs: 9, + eventLoopDelayP95Ms: 3, + eventLoopDelayP99Ms: 5, + eventLoopUtilization: 0.5, + invalidReasons: background + ? [ + { + count: 3, + reason: 'overlapping-database-worker-requests', + }, + ] + : [], + invalidSampleCount: background ? 3 : 0, + metricScope: 'valid-request-window-proxy', + totalCount: requestTotalCount, + validSampleCount: requestTotalCount - (background ? 3 : 0), + }, + rssBytes: null, + rssUnavailableReason: 'unavailable-per-worker-thread', + wholeWorkerMetricScope: 'whole-worker-operation-window', + }, + playlistRefreshWorker: { + instantiated: false, + reason: 'not-instantiated-by-xtream-flow', + }, + cancellation: cancellationBinding.cancellation, + cancellationEvidence: cancellationBinding.cancellationEvidence, + catalogVerification: { + databaseStateValid: true, + fixtureIdentityValid: true, + providerCategoryCount: 100, + providerItemCount: 100_000, + verifiedAfterCapture: true, + }, + validity: { + actionContractValid: true, + catalogContractValid: true, + databaseWorkerCount: 1, + dbPhaseContractValid: true, + diagnosticArtifactsValid: diagnostic ? true : null, + lateDatabaseRequestCount: 0, + playlistWorkerCount: 0, + rendererTargetCount: 1, + }, + }; +} + +export function withWorkerRequestOverlap( + raw: XtreamRawIterationResult, + invalidSampleCount: number +): XtreamRawIterationResult { + return { + ...raw, + databaseWorker: { + ...raw.databaseWorker, + requests: { + ...raw.databaseWorker.requests, + evidence: invalidateWorkerRequestMetrics( + raw.databaseWorker.requests.evidence.map( + restoreValidRequestMetrics + ), + invalidSampleCount + ), + invalidReasons: + invalidSampleCount === 0 + ? [] + : [ + { + count: invalidSampleCount, + reason: 'overlapping-database-worker-requests', + }, + ], + invalidSampleCount, + validSampleCount: + raw.databaseWorker.requests.totalCount - invalidSampleCount, + }, + }, + }; +} + +function restoreValidRequestMetrics( + request: WorkerRequestPerformanceMetrics +): WorkerRequestPerformanceMetrics { + if ( + request.workEndedEpochMs === null || + request.responsePostedEpochMs === null + ) { + throw new Error('invalid Xtream request fixture'); + } + return { + ...request, + eventLoopDelay: { maxMs: 9, p95Ms: 3, p99Ms: 5 }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.5, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: + (request.workEndedEpochMs + request.responsePostedEpochMs) / 2, + invalidReason: null, + threadCpuSystemMicros: 10, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 20, + }; +} + +export function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function hash(character: string): string { + return character.repeat(64); +} + +function numberedHash(value: number, salt: number): string { + return sha256(`${salt}:${value}`); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.spec.ts new file mode 100644 index 000000000..9c1aeb0b8 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.spec.ts @@ -0,0 +1,351 @@ +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import { + createXtreamIterationDefinitions, + type XtreamBenchmarkManifest, +} from './xtream-benchmark-contract'; +import { createXtreamBenchmarkSummary } from './xtream-benchmark-report'; +import { + MANIFEST, + TOKEN, + clearXtreamReportFixtureDirectories, + iteration, + persist, + persistWith, + sha256, +} from './xtream-benchmark-report.fixtures'; +import { + clearXtreamProvenanceFixtureDirectories, + formalReportFixture, + smokeReportFixture, +} from './xtream-benchmark-report-provenance.fixtures'; + +afterEach(async () => { + await Promise.all([ + clearXtreamReportFixtureDirectories(), + clearXtreamProvenanceFixtureDirectories(), + ]); +}); + +describe('Xtream benchmark report contract', () => { + it('writes exact raw bytes before semantic validation and verifies a real receipt', async () => { + const raw = iteration( + itemAt(createXtreamIterationDefinitions(false), 1), + 1 + ); + const invalid = { + ...raw, + main: { ...raw.main, peakRssBytes: -1 }, + }; + const order: string[] = []; + + await assert.rejects( + () => + persist(invalid, 1, () => { + order.push('raw-written'); + }), + /invalid Xtream iteration/ + ); + order.push('validation-rejected'); + assert.deepEqual(order, ['raw-written', 'validation-rejected']); + + await assert.rejects( + () => + persistWith(raw, async (serialized) => ({ + absolutePath: join(tmpdir(), 'does-not-exist.json'), + bytes: Buffer.byteLength(serialized), + sha256: sha256(serialized), + })), + /persistence receipt/ + ); + }); + + it('brands the exact persisted bytes when the caller mutates during write', async () => { + const raw = iteration( + itemAt(createXtreamIterationDefinitions(false), 1), + 1 + ); + const persistedPeakRss = raw.main.peakRssBytes; + const validated = await persist(raw, 2, () => { + Object.assign(raw.main, { peakRssBytes: persistedPeakRss + 1 }); + }); + + assert.equal(validated.main.peakRssBytes, persistedPeakRss); + assert.notEqual(validated.main.peakRssBytes, raw.main.peakRssBytes); + assert.throws( + () => Object.assign(validated.main, { peakRssBytes: 1 }), + TypeError + ); + }); + + it('blocks exact token and credential-shaped bytes before write but permits suite labels', async () => { + const raw = iteration( + itemAt(createXtreamIterationDefinitions(false), 1), + 1 + ); + let writes = 0; + for (const leaked of [ + TOKEN, + 'performance:performance', + 'http://performance:performance@127.0.0.1:43123', + '?username=performance&password=performance', + ]) { + await assert.rejects( + () => + persistWith( + { + ...raw, + renderer: { + ...raw.renderer, + consoleErrors: [leaked], + }, + }, + async () => { + writes += 1; + throw new Error('must not write'); + } + ), + /credential-safe/ + ); + } + assert.equal(writes, 0); + await assert.doesNotReject(() => persist(raw, 2)); + }); + + it('requires exact clean formal source, runtime, CDP, and environment evidence', async () => { + const { diagnosticEvidence, iterations } = await formalReportFixture(); + assert.doesNotThrow(() => + createXtreamBenchmarkSummary( + MANIFEST, + iterations, + diagnosticEvidence + ) + ); + + for (const invalid of [ + { ...MANIFEST, source: { ...MANIFEST.source, gitDirty: true } }, + { + ...MANIFEST, + environment: { ...MANIFEST.environment, cdpPort: 9223 }, + }, + { + ...MANIFEST, + environment: { + ...MANIFEST.environment, + perfWorkerProfiling: '0', + }, + }, + { + ...MANIFEST, + runtime: { ...MANIFEST.runtime, electronVersion: '' }, + }, + { ...MANIFEST, variant: { length: 1 } }, + ]) { + assert.throws( + () => + createXtreamBenchmarkSummary( + invalid as unknown as XtreamBenchmarkManifest, + iterations, + diagnosticEvidence + ), + /invalid Xtream benchmark manifest/ + ); + } + }); + + it('requires ordered definitions and unique process/profile/generation identity', async () => { + const { diagnosticEvidence, iterations } = await formalReportFixture(); + const shuffled = [...iterations]; + [shuffled[0], shuffled[1]] = [itemAt(shuffled, 1), itemAt(shuffled, 0)]; + assert.throws( + () => + createXtreamBenchmarkSummary( + MANIFEST, + shuffled, + diagnosticEvidence + ), + /ordered Xtream benchmark schedule/ + ); + + const reused = [...iterations]; + const first = itemAt(reused, 0); + const second = itemAt(reused, 1); + reused[1] = { + ...second, + processIdentity: first.processIdentity, + }; + assert.throws( + () => + createXtreamBenchmarkSummary( + MANIFEST, + reused, + diagnosticEvidence + ), + /fresh process identity/ + ); + }); + + it('rejects forged validated objects and excludes diagnostic CPU profiles from headline metrics', async () => { + const { diagnosticEvidence, iterations } = await formalReportFixture(); + const forged = [...iterations]; + forged[0] = { ...itemAt(forged, 0) }; + assert.throws( + () => + createXtreamBenchmarkSummary( + MANIFEST, + forged, + diagnosticEvidence + ), + /validated persistence evidence/ + ); + + const summary = createXtreamBenchmarkSummary( + MANIFEST, + iterations, + diagnosticEvidence + ); + const background = summary.scenarios.find( + ({ scenarioId }) => scenarioId === 'xtream-background-ui' + ); + const initial = summary.scenarios.find( + ({ scenarioId }) => scenarioId === 'xtream-initial-import-large' + ); + assert.ok(background); + assert.ok(initial); + assert.deepEqual(background.databaseWorkerRequestInvalidReasons, [ + 'overlapping-database-worker-requests', + ]); + assert.equal( + background.metrics.databaseWorkerRequestInvalidSampleCount.min, + 3 + ); + assert.equal( + background.metrics.databaseWorkerRequestInvalidSampleCount.max, + 3 + ); + assert.deepEqual(initial.databaseWorkerRequestInvalidReasons, [ + 'overlapping-database-worker-requests', + ]); + assert.equal( + initial.metrics.databaseWorkerRequestInvalidSampleCount.min, + 0 + ); + assert.equal( + initial.metrics.databaseWorkerRequestInvalidSampleCount.max, + 2 + ); + assert.ok( + summary.scenarios.every( + ({ + databaseWorkerEventLoopDelayMetricScope, + databaseWorkerRequestMetricScope, + databaseWorkerWholeWorkerMetricScope, + metrics, + }) => + databaseWorkerEventLoopDelayMetricScope === + 'valid-request-window-proxy' && + databaseWorkerRequestMetricScope === + 'valid-request-window-proxy' && + databaseWorkerWholeWorkerMetricScope === + 'whole-worker-operation-window' && + metrics.databaseWorkerRequestEventLoopDelayP95Ms.count === 5 + ) + ); + assert.equal(summary.validForComparison, true); + assert.ok(Object.isFrozen(summary)); + assert.ok(Object.isFrozen(summary.manifest)); + assert.ok(Object.isFrozen(summary.iterations)); + assert.ok( + Object.isFrozen( + itemAt(summary.iterations, 0).databaseWorker.requests.evidence + ) + ); + assert.notEqual(summary.manifest, MANIFEST); + assert.notEqual(summary.iterations, iterations); + assert.ok( + summary.scenarios.every( + ({ + diagnosticProfiles, + mainEventLoopUtilization, + measuredRuns, + metrics, + phases, + }) => + measuredRuns === 5 && + metrics.totalMs.count === 5 && + diagnosticProfiles.mainDurationMicros !== null && + diagnosticProfiles.artifactProvenance.artifacts.length === + 7 && + Object.isFrozen( + diagnosticProfiles.artifactProvenance.artifacts + ) && + mainEventLoopUtilization.reason === + 'electron-main-embedded-event-loop' && + mainEventLoopUtilization.value === null && + phases.indexes.reason === + 'schema-indexes-pre-exist; operation-creates-none' && + phases.restoreUserData.value === null && + phases.structuredCloneAttribution === + 'selected-ipc-proxy-includes-queueing-and-scheduling' && + !Object.prototype.hasOwnProperty.call( + metrics, + 'mainCpuProfileDurationMicros' + ) + ) + ); + assert.equal( + initial.phases.restoreUserData.reason, + 'not-invoked-by-scenario' + ); + assert.equal(background.phases.restoreUserData.reason, 'no-user-data'); + }); + + it('requires branded one-to-one diagnostic evidence bound to each iteration directory', async () => { + const { diagnosticEvidence, iterations } = await formalReportFixture(); + const entries = [...diagnosticEvidence.entries()]; + const first = itemAt(entries, 0); + const second = itemAt(entries, 1); + const missing = new Map(entries.slice(1)); + const swapped = new Map(entries); + swapped.set(first[0], second[1]); + swapped.set(second[0], first[1]); + const extra = new Map(entries); + extra.set(itemAt(iterations, 0), first[1]); + const forged = new Map(entries); + forged.set(first[0], structuredClone(first[1])); + const malformed = new Map(entries); + malformed.set(first[0], null as unknown as (typeof entries)[number][1]); + + for (const evidence of [missing, swapped, extra, forged, malformed]) { + assert.throws( + () => + createXtreamBenchmarkSummary( + MANIFEST, + iterations, + evidence + ), + /diagnostic artifact evidence/ + ); + } + }); + + it('keeps one-run smoke output ineligible after all clean gates pass', async () => { + const { diagnosticEvidence, iterations } = await smokeReportFixture(); + const summary = createXtreamBenchmarkSummary( + { ...MANIFEST, mode: 'smoke' }, + iterations, + diagnosticEvidence + ); + + assert.equal(summary.validForComparison, false); + }); +}); + +function itemAt(items: readonly T[], index: number): T { + const item = items[index]; + assert.ok(item); + return item; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.ts new file mode 100644 index 000000000..359bcf0d6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-report.ts @@ -0,0 +1,387 @@ +import { + createXtreamIterationDefinitions, + listXtreamBenchmarkScenarios, + summarizeXtreamNumbers, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { + serializeXtreamArtifactForPersistence, + type XtreamDiagnosticArtifactEvidence, +} from './xtream-diagnostic-artifacts'; +import { + bindXtreamDiagnosticEvidence, + cloneXtreamSummaryValue, + copyXtreamDiagnosticProvenance, +} from './xtream-benchmark-summary-evidence'; +import { + assertFreshXtreamProcessIdentities, + assertOrderedXtreamSchedule, + freezeDeep, + isFiniteNonNegative, + parseXtreamBenchmarkManifest, + snapshotXtreamIterations, + toXtreamRawIteration, +} from './xtream-exact-schema'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; +import { assertUniqueXtreamPersistencePaths } from './xtream-persistence-schedule'; +import { verifyXtreamPersistenceReceipt } from './xtream-raw-persistence-receipt'; +import { + XTREAM_NOT_APPLICABLE, + XTREAM_RENDERER_VISIBILITY, + XTREAM_STRUCTURED_CLONE_ATTRIBUTION, + XTREAM_WORKER_REQUEST_METRIC_SCOPE, + XTREAM_WORKER_WHOLE_METRIC_SCOPE, + type NumericDistribution, + type XtreamArtifactSafetyContext, + type XtreamBenchmarkManifest, + type XtreamBenchmarkSummary, + type XtreamPersistedRawEvidence, + type XtreamPersistenceReceipt, + type XtreamRawIterationResult, + type XtreamScenarioMetrics, + type XtreamScenarioSummary, + type XtreamValidatedIterationResult, +} from './xtream-summary-contract'; + +const VALIDATED = new WeakSet(); + +export async function persistThenValidateXtreamIteration( + raw: XtreamRawIterationResult, + safety: XtreamArtifactSafetyContext, + persistRaw: (serialized: string) => Promise +): Promise { + const serialized = serializeXtreamArtifactForPersistence(raw, safety); + const receipt = await persistRaw(serialized); + const evidence = await verifyXtreamPersistenceReceipt(serialized, receipt); + const persisted: unknown = JSON.parse(serialized); + assertXtreamIterationResult(persisted); + return brandValidatedIteration(persisted, evidence); +} + +export function createXtreamBenchmarkSummary( + manifest: XtreamBenchmarkManifest, + iterations: readonly XtreamValidatedIterationResult[], + diagnosticEvidence: ReadonlyMap< + XtreamValidatedIterationResult, + XtreamDiagnosticArtifactEvidence + > +): XtreamBenchmarkSummary { + const canonicalManifest = parseXtreamBenchmarkManifest(manifest); + const expected = createXtreamIterationDefinitions( + canonicalManifest.mode === 'smoke' + ); + const canonicalIterations = snapshotXtreamIterations(iterations); + assertOrderedXtreamSchedule( + canonicalIterations, + expected, + canonicalManifest.mode + ); + assertFreshXtreamProcessIdentities(canonicalIterations); + for (const iteration of canonicalIterations) { + assertValidatedIteration(iteration); + if (iteration.validationStatus !== 'valid') invalidIteration(); + assertXtreamIterationResult(toXtreamRawIteration(iteration)); + } + assertUniqueXtreamPersistencePaths(canonicalIterations); + const boundDiagnosticEvidence = bindXtreamDiagnosticEvidence( + canonicalIterations, + diagnosticEvidence + ); + + const measuredRunCount = canonicalManifest.mode === 'formal' ? 5 : 1; + const comparable = + canonicalManifest.mode === 'formal' && + canonicalIterations + .filter(({ kind }) => kind === 'measured') + .every(({ eligibleForComparison }) => eligibleForComparison); + return freezeDeep({ + iterations: cloneXtreamSummaryValue(canonicalIterations), + manifest: cloneXtreamSummaryValue(canonicalManifest), + scenarios: listXtreamBenchmarkScenarios().map(({ id }) => + summarizeScenario( + canonicalIterations, + id, + measuredRunCount, + boundDiagnosticEvidence + ) + ), + validForComparison: comparable, + }); +} + +function summarizeScenario( + iterations: readonly XtreamValidatedIterationResult[], + scenarioId: XtreamScenarioId, + measuredRunCount: number, + diagnosticEvidence: ReadonlyMap< + XtreamValidatedIterationResult, + XtreamDiagnosticArtifactEvidence + > +): XtreamScenarioSummary { + const measured = iterations.filter( + (iteration) => + iteration.scenarioId === scenarioId && iteration.kind === 'measured' + ); + const diagnostic = iterations.find( + (iteration) => + iteration.scenarioId === scenarioId && + iteration.kind === 'diagnostic' + ); + if (measured.length !== measuredRunCount || !diagnostic) { + scheduleError(measuredRunCount === 5 ? 'formal' : 'smoke'); + } + const provenance = diagnosticEvidence.get(diagnostic); + if (!provenance) invalidIteration(); + const restores = + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui'; + return { + scenarioId, + measuredRuns: measuredRunCount, + databaseWorkerEventLoopDelayMetricScope: + XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY, + databaseWorkerRequestInvalidReasons: [ + ...new Set( + measured.flatMap((run) => + run.databaseWorker.requests.invalidReasons.map( + ({ reason }) => reason + ) + ) + ), + ], + databaseWorkerRequestMetricScope: + XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY, + databaseWorkerWholeWorkerMetricScope: + XTREAM_WORKER_WHOLE_METRIC_SCOPE.OPERATION_WINDOW, + dashboardPaintVisibility: + scenarioId === 'xtream-background-ui' + ? XTREAM_RENDERER_VISIBILITY.VISIBLE_WITHOUT_OVERLAY + : null, + diagnosticProfiles: { + artifactProvenance: copyXtreamDiagnosticProvenance(provenance), + databaseWorkerDurationMicros: + provenance.profiles.databaseWorker.durationMicros, + mainDurationMicros: provenance.profiles.main.durationMicros, + rendererDurationMicros: provenance.profiles.renderer.durationMicros, + }, + metrics: summarizeMetrics(measured, scenarioId, measuredRunCount), + memory: { + databaseWorkerRss: notApplicable( + XTREAM_NOT_APPLICABLE.DATABASE_WORKER_RSS + ), + nativeAndSqlite: notApplicable( + XTREAM_NOT_APPLICABLE.NATIVE_SQLITE_MEMORY + ), + playlistRefreshWorker: notApplicable( + XTREAM_NOT_APPLICABLE.PLAYLIST_REFRESH_WORKER + ), + }, + mainEventLoopUtilization: notApplicable( + XTREAM_NOT_APPLICABLE.MAIN_EVENT_LOOP_UTILIZATION + ), + phases: { + debugCopy: notApplicable(XTREAM_NOT_APPLICABLE.DEBUG_COPY), + indexes: notApplicable(XTREAM_NOT_APPLICABLE.INDEXES_PRE_EXIST), + transactionCommit: notApplicable( + XTREAM_NOT_APPLICABLE.COMMIT_INCLUDED_IN_SQLITE_WRITE + ), + restoreUserData: notApplicable( + restores + ? XTREAM_NOT_APPLICABLE.RESTORE_NO_USER_DATA + : XTREAM_NOT_APPLICABLE.RESTORE_NOT_INVOKED + ), + backgroundSearchMarker: + scenarioId === 'xtream-background-ui' + ? notApplicable(XTREAM_NOT_APPLICABLE.SOURCES_SEARCH_MARKER) + : null, + structuredCloneAttribution: + XTREAM_STRUCTURED_CLONE_ATTRIBUTION.SELECTED_IPC_PROXY, + }, + rendererLongTaskCountTotal: sumValues( + measured, + (run) => run.renderer.longTaskCount + ), + responsiveEventsTotal: sumValues( + measured, + (run) => run.main.responsiveEvents + ), + unresponsiveEventsTotal: sumValues( + measured, + (run) => run.main.unresponsiveEvents + ), + }; +} + +function summarizeMetrics( + runs: readonly XtreamValidatedIterationResult[], + scenarioId: XtreamScenarioId, + count: number +): XtreamScenarioMetrics { + const metric = ( + select: (run: XtreamValidatedIterationResult) => number + ): NumericDistribution => distribution(runs, select, count); + const optional = ( + select: (run: XtreamValidatedIterationResult) => number | null + ): NumericDistribution => + distribution(runs, (run) => requireNumber(select(run)), count); + return { + totalMs: metric((run) => run.phases.totalMs), + dataAcquisitionMs: metric((run) => run.phases.dataAcquisitionMs), + jsonParsingMs: metric((run) => run.phases.jsonParsingMs), + normalizationMs: metric((run) => run.phases.normalizationMs), + serializationMs: metric((run) => run.phases.serializationMs), + deserializationMs: metric((run) => run.phases.deserializationMs), + structuredCloneProxyMs: metric( + (run) => run.phases.structuredCloneProxyMs + ), + sqliteReadMs: metric((run) => run.phases.sqliteReadMs), + sqliteWriteMs: metric((run) => run.phases.sqliteWriteMs), + storeUpdateMs: metric((run) => run.phases.storeUpdateMs), + angularRenderingMs: metric((run) => run.phases.angularRenderingMs), + rendererPeakRssBytes: metric((run) => run.renderer.peakRssBytes), + rendererPeakHeapBytes: metric((run) => run.renderer.peakHeapUsedBytes), + rendererPostGcHeapBytes: metric( + (run) => run.renderer.postGcHeapUsedBytes + ), + rendererLongTaskCount: metric((run) => run.renderer.longTaskCount), + rendererLongTaskMaxMs: metric((run) => run.renderer.longTaskMaxMs), + rendererFrameGapP95Ms: metric((run) => run.renderer.frameGapP95Ms), + rendererFrameGapMaxMs: metric((run) => run.renderer.frameGapMaxMs), + rendererHeartbeatP95Ms: metric((run) => run.renderer.heartbeatP95Ms), + rendererHeartbeatMaxMs: metric((run) => run.renderer.heartbeatMaxMs), + rendererNavigationLatencyMs: + scenarioId === 'xtream-background-ui' + ? optional((run) => run.renderer.navigationLatencyMs) + : null, + rendererSearchLatencyMs: + scenarioId === 'xtream-background-ui' + ? optional((run) => run.renderer.searchLatencyMs) + : null, + rendererActionLatencyMs: + scenarioId === 'xtream-background-ui' + ? optional((run) => run.renderer.actionLatencyMs) + : null, + mainCpuUserMicros: metric((run) => run.main.cpuUserMicros), + mainCpuSystemMicros: metric((run) => run.main.cpuSystemMicros), + mainPeakRssBytes: metric((run) => run.main.peakRssBytes), + mainPeakHeapBytes: metric((run) => run.main.peakHeapUsedBytes), + mainPostGcHeapBytes: metric((run) => run.main.postGcHeapUsedBytes), + mainEventLoopDelayP95Ms: metric((run) => run.main.eventLoopDelayP95Ms), + mainEventLoopDelayP99Ms: metric((run) => run.main.eventLoopDelayP99Ms), + mainEventLoopDelayMaxMs: metric((run) => run.main.eventLoopDelayMaxMs), + databaseWorkerCpuUserMicros: metric( + (run) => run.databaseWorker.cpuUserMicros + ), + databaseWorkerCpuSystemMicros: metric( + (run) => run.databaseWorker.cpuSystemMicros + ), + databaseWorkerPeakHeapBytes: metric( + (run) => run.databaseWorker.peakHeapUsedBytes + ), + databaseWorkerPostGcHeapBytes: metric( + (run) => run.databaseWorker.postGcHeapUsedBytes + ), + databaseWorkerRequestTotalCount: metric( + (run) => run.databaseWorker.requests.totalCount + ), + databaseWorkerRequestValidSampleCount: metric( + (run) => run.databaseWorker.requests.validSampleCount + ), + databaseWorkerRequestInvalidSampleCount: metric( + (run) => run.databaseWorker.requests.invalidSampleCount + ), + databaseWorkerRequestEventLoopDelayP95Ms: metric( + (run) => run.databaseWorker.requests.eventLoopDelayP95Ms + ), + databaseWorkerRequestEventLoopDelayP99Ms: metric( + (run) => run.databaseWorker.requests.eventLoopDelayP99Ms + ), + databaseWorkerRequestEventLoopDelayMaxMs: metric( + (run) => run.databaseWorker.requests.eventLoopDelayMaxMs + ), + databaseWorkerRequestEventLoopUtilization: metric( + (run) => run.databaseWorker.requests.eventLoopUtilization + ), + databaseWorkerPeakExternalBytes: metric( + (run) => run.databaseWorker.peakExternalBytes + ), + databaseWorkerEventLoopDelayP95Ms: metric( + (run) => run.databaseWorker.eventLoopDelayP95Ms + ), + databaseWorkerEventLoopDelayP99Ms: metric( + (run) => run.databaseWorker.eventLoopDelayP99Ms + ), + databaseWorkerEventLoopDelayMaxMs: metric( + (run) => run.databaseWorker.eventLoopDelayMaxMs + ), + databaseWorkerEventLoopUtilization: metric( + (run) => run.databaseWorker.eventLoopUtilization + ), + cancelAcknowledgementLatencyMs: + scenarioId === 'xtream-cancel-import' + ? optional((run) => run.cancellation.acknowledgementLatencyMs) + : null, + }; +} + +function distribution( + runs: readonly XtreamValidatedIterationResult[], + select: (run: XtreamValidatedIterationResult) => number, + expectedCount: number +): NumericDistribution { + const values = runs.map(select); + if ( + values.length !== expectedCount || + values.some((value) => !isFiniteNonNegative(value)) + ) { + throw new Error( + 'Xtream metric requires complete finite measured values' + ); + } + return summarizeXtreamNumbers(values); +} + +function sumValues( + runs: readonly XtreamValidatedIterationResult[], + select: (run: XtreamValidatedIterationResult) => number +): number { + return runs.reduce((total, run) => total + select(run), 0); +} + +function requireNumber(value: number | null): number { + if (!isFiniteNonNegative(value)) invalidIteration(); + return value; +} + +function notApplicable(reason: string): { reason: string; value: null } { + return { reason, value: null }; +} + +function brandValidatedIteration( + raw: XtreamRawIterationResult, + persistence: XtreamPersistedRawEvidence +): XtreamValidatedIterationResult { + const result = freezeDeep({ + ...raw, + persistence, + validationStatus: 'valid' as const, + }); + VALIDATED.add(result); + return result; +} + +function assertValidatedIteration(value: XtreamValidatedIterationResult): void { + if (!VALIDATED.has(value)) { + throw new Error('Xtream validated persistence evidence is missing'); + } +} + +function scheduleError(mode: 'formal' | 'smoke'): never { + throw new Error( + `Xtream benchmark requires exactly ${mode === 'formal' ? 'five' : 'one'} measured runs per scenario` + ); +} + +function invalidIteration(): never { + throw new Error('invalid Xtream iteration'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-runtime.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-runtime.spec.ts new file mode 100644 index 000000000..89a9ac283 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-runtime.spec.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { + deriveXtreamMeasuredPlaylistId, + xtreamScenarioRequiresSeed, +} from './xtream-benchmark-runtime'; + +describe('Xtream benchmark runtime evidence', () => { + it('seeds only scenarios that measure an operation on an existing portal', () => { + const expected: Record = { + [XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE]: false, + [XTREAM_SCENARIO_ID.REFRESH_LARGE]: true, + [XTREAM_SCENARIO_ID.DELETE_LARGE]: true, + [XTREAM_SCENARIO_ID.CANCEL_IMPORT]: false, + [XTREAM_SCENARIO_ID.BACKGROUND_UI]: true, + }; + + for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) { + assert.equal( + xtreamScenarioRequiresSeed(scenarioId), + expected[scenarioId] + ); + } + }); + + it('derives exactly one non-null measured playlist identity', () => { + assert.equal( + deriveXtreamMeasuredPlaylistId([ + { playlistId: null }, + { playlistId: 'playlist-1' }, + { playlistId: 'playlist-1' }, + ]), + 'playlist-1' + ); + + for (const requests of [ + [{ playlistId: null }], + [{ playlistId: 'playlist-1' }, { playlistId: 'playlist-2' }], + [{ playlistId: '' }], + ]) { + assert.throws( + () => deriveXtreamMeasuredPlaylistId(requests), + /xtream-measured-playlist-identity-invalid/ + ); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-runtime.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-runtime.ts new file mode 100644 index 000000000..7b31d227f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-runtime.ts @@ -0,0 +1,52 @@ +import type { Page } from '@playwright/test'; + +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; + +export function xtreamScenarioRequiresSeed( + scenarioId: XtreamScenarioId +): boolean { + return ( + scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE || + scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE || + scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI + ); +} + +export function deriveXtreamMeasuredPlaylistId( + requests: readonly { readonly playlistId: string | null }[] +): string { + const identities = new Set( + requests + .map(({ playlistId }) => playlistId) + .filter( + (playlistId): playlistId is string => + typeof playlistId === 'string' && playlistId.length > 0 + ) + ); + const playlistId = identities.values().next().value as string | undefined; + if (identities.size !== 1 || !playlistId) { + throw new Error('xtream-measured-playlist-identity-invalid'); + } + return playlistId; +} + +export async function disableGenericElectronFixtureProbes( + page: Page +): Promise { + await page.evaluate(() => { + window.__portalDebugUnsubscribe?.(); + window.__portalDebugUnsubscribe = undefined; + window.__portalDebugEvents = []; + window.__dbOperationUnsubscribe?.(); + window.__dbOperationUnsubscribe = undefined; + window.__dbOperationEvents = []; + if (window.__rendererFrameRequestId) { + window.cancelAnimationFrame(window.__rendererFrameRequestId); + } + window.__rendererFrameRequestId = undefined; + window.__rendererFrameCount = 0; + }); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-benchmark-summary-evidence.ts b/apps/electron-backend-e2e/src/performance/xtream-benchmark-summary-evidence.ts new file mode 100644 index 000000000..a5525fd67 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-benchmark-summary-evidence.ts @@ -0,0 +1,112 @@ +import { + assertXtreamDiagnosticArtifactEvidence, + type XtreamDiagnosticArtifactEvidence, +} from './xtream-diagnostic-artifacts'; +import type { + XtreamDiagnosticArtifactProvenance, + XtreamValidatedIterationResult, +} from './xtream-summary-contract'; + +export function bindXtreamDiagnosticEvidence( + iterations: readonly XtreamValidatedIterationResult[], + evidenceByIteration: ReadonlyMap< + XtreamValidatedIterationResult, + XtreamDiagnosticArtifactEvidence + > +): ReadonlyMap< + XtreamValidatedIterationResult, + XtreamDiagnosticArtifactEvidence +> { + const diagnostic = iterations.filter(({ kind }) => kind === 'diagnostic'); + const evidenceEntries = snapshotEvidenceEntries(evidenceByIteration); + if (evidenceEntries.length !== diagnostic.length) { + invalidEvidence(); + } + const diagnosticSet = new Set(diagnostic); + const boundEvidence = new Map< + XtreamValidatedIterationResult, + XtreamDiagnosticArtifactEvidence + >(); + for (const [iteration, evidence] of evidenceEntries) { + if (!diagnosticSet.has(iteration)) { + invalidEvidence(); + } + try { + assertXtreamDiagnosticArtifactEvidence(evidence); + } catch { + invalidEvidence(); + } + if ( + iteration.processIdentity.profileDirectorySha256 !== + evidence.directorySha256 || + iteration.renderer.cpuProfileDurationMicros !== + evidence.profiles.renderer.durationMicros || + iteration.main.cpuProfileDurationMicros !== + evidence.profiles.main.durationMicros || + iteration.databaseWorker.cpuProfileDurationMicros !== + evidence.profiles.databaseWorker.durationMicros || + iteration.databaseWorker.ordinal !== + evidence.profiles.databaseWorker.ordinal + ) { + invalidEvidence(); + } + Map.prototype.set.call(boundEvidence, iteration, evidence); + } + if ( + diagnostic.some( + (iteration) => !Map.prototype.has.call(boundEvidence, iteration) + ) + ) { + invalidEvidence(); + } + return boundEvidence; +} + +export function copyXtreamDiagnosticProvenance( + evidence: XtreamDiagnosticArtifactEvidence +): XtreamDiagnosticArtifactProvenance { + assertXtreamDiagnosticArtifactEvidence(evidence); + return { + artifacts: evidence.artifacts.map((artifact) => ({ ...artifact })), + directorySha256: evidence.directorySha256, + }; +} + +export function cloneXtreamSummaryValue(value: T): T { + return structuredClone(value); +} + +function snapshotEvidenceEntries( + value: ReadonlyMap< + XtreamValidatedIterationResult, + XtreamDiagnosticArtifactEvidence + > +): readonly (readonly [ + XtreamValidatedIterationResult, + XtreamDiagnosticArtifactEvidence, +])[] { + try { + if (!(value instanceof Map)) invalidEvidence(); + const sizeGetter = Object.getOwnPropertyDescriptor( + Map.prototype, + 'size' + )?.get; + if (!sizeGetter) invalidEvidence(); + const size = Reflect.apply(sizeGetter, value, []) as unknown; + const entries: [ + XtreamValidatedIterationResult, + XtreamDiagnosticArtifactEvidence, + ][] = []; + Map.prototype.forEach.call(value, (evidence, iteration) => { + entries.push([iteration, evidence]); + }); + if (size !== entries.length) invalidEvidence(); + return entries; + } catch { + invalidEvidence(); + } +} + +function invalidEvidence(): never { + throw new Error('invalid Xtream diagnostic artifact evidence binding'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts new file mode 100644 index 000000000..3b1b36fa5 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity-schema.ts @@ -0,0 +1,214 @@ +import { + computeXtreamBuildFilesAggregate, + type XtreamBenchmarkBuildIdentity, + type XtreamBuildFileIdentity, + type XtreamBuildPairIdentity, +} from './xtream-build-identity'; + +const SHA256 = /^[a-f0-9]{64}$/u; +const RENDERER_FILE = + /^dist\/apps\/web\/[A-Za-z0-9][A-Za-z0-9._-]{0,254}\.js(?:\.map)?$/u; +const BUILD_KEYS = ['electron', 'renderer'] as const; +const ELECTRON_KEYS = [ + 'databaseWorker', + 'main', + 'playlistRefreshWorker', + 'preload', +] as const; +const PAIR_KEYS = ['javascript', 'sourceMap'] as const; +const FILE_KEYS = ['bytes', 'path', 'sha256'] as const; +const RENDERER_KEYS = [ + 'aggregateSha256', + 'bytes', + 'executableJavaScriptCount', + 'files', + 'sourceMapCount', +] as const; +const ELECTRON_PATHS = { + databaseWorker: 'dist/apps/electron-backend/workers/database.worker.js', + main: 'dist/apps/electron-backend/main.js', + playlistRefreshWorker: + 'dist/apps/electron-backend/workers/playlist-refresh.worker.js', + preload: 'dist/apps/electron-backend/main.preload.js', +} as const; + +export function parseXtreamBenchmarkBuildIdentity( + value: unknown +): XtreamBenchmarkBuildIdentity { + try { + const input = exactRecord(value, BUILD_KEYS); + const electronInput = exactRecord(input['electron'], ELECTRON_KEYS); + const rendererInput = exactRecord(input['renderer'], RENDERER_KEYS); + const electron = Object.freeze({ + databaseWorker: pair( + electronInput['databaseWorker'], + ELECTRON_PATHS.databaseWorker + ), + main: pair(electronInput['main'], ELECTRON_PATHS.main), + playlistRefreshWorker: pair( + electronInput['playlistRefreshWorker'], + ELECTRON_PATHS.playlistRefreshWorker + ), + preload: pair(electronInput['preload'], ELECTRON_PATHS.preload), + }); + const renderer = rendererIdentity(rendererInput); + return Object.freeze({ electron, renderer }); + } catch { + throw new Error('invalid Xtream benchmark build identity'); + } +} + +function pair(value: unknown, javascriptPath: string): XtreamBuildPairIdentity { + const input = exactRecord(value, PAIR_KEYS); + return Object.freeze({ + javascript: fileIdentity(input['javascript'], javascriptPath), + sourceMap: fileIdentity(input['sourceMap'], `${javascriptPath}.map`), + }); +} + +function rendererIdentity( + input: Record +): XtreamBenchmarkBuildIdentity['renderer'] { + const files = exactArray(input['files']).map((value) => + fileIdentity(value) + ); + if ( + files.length < 3 || + files.length > 10_000 || + !isStrictlySorted(files.map((file) => file.path)) + ) { + invalid(); + } + const indexFiles = files.filter( + (file) => file.path === 'dist/apps/web/index.html' + ); + const javascript = files.filter((file) => file.path.endsWith('.js')); + const sourceMaps = files.filter((file) => file.path.endsWith('.js.map')); + if ( + indexFiles.length !== 1 || + javascript.length === 0 || + files.length !== 1 + javascript.length + sourceMaps.length || + javascript.length !== sourceMaps.length || + javascript.some( + (file) => + !sourceMaps.some( + (sourceMap) => sourceMap.path === `${file.path}.map` + ) + ) || + sourceMaps.some( + (sourceMap) => + !javascript.some( + (file) => file.path === sourceMap.path.slice(0, -4) + ) + ) + ) { + invalid(); + } + const bytes = files.reduce((total, file) => total + file.bytes, 0); + if ( + !isPositiveSafeInteger(input['bytes']) || + input['bytes'] !== bytes || + input['executableJavaScriptCount'] !== javascript.length || + input['sourceMapCount'] !== sourceMaps.length || + typeof input['aggregateSha256'] !== 'string' || + !SHA256.test(input['aggregateSha256']) || + input['aggregateSha256'] !== computeXtreamBuildFilesAggregate(files) + ) { + invalid(); + } + return Object.freeze({ + aggregateSha256: input['aggregateSha256'], + bytes, + executableJavaScriptCount: javascript.length, + files: Object.freeze(files), + sourceMapCount: sourceMaps.length, + }) as XtreamBenchmarkBuildIdentity['renderer']; +} + +function fileIdentity( + value: unknown, + expectedPath?: string +): XtreamBuildFileIdentity { + const input = exactRecord(value, FILE_KEYS); + const path = input['path']; + const validRendererPath = + path === 'dist/apps/web/index.html' || + (typeof path === 'string' && RENDERER_FILE.test(path)); + if ( + !isPositiveSafeInteger(input['bytes']) || + typeof path !== 'string' || + (expectedPath === undefined + ? !validRendererPath + : path !== expectedPath) || + typeof input['sha256'] !== 'string' || + !SHA256.test(input['sha256']) + ) { + invalid(); + } + return Object.freeze({ + bytes: input['bytes'], + path, + sha256: input['sha256'], + }) as XtreamBuildFileIdentity; +} + +function exactArray(value: unknown): unknown[] { + if (!Array.isArray(value)) invalid(); + const length = Reflect.get(value, 'length') as unknown; + if ( + !Number.isSafeInteger(length) || + Number(length) < 0 || + Number(length) > 10_000 + ) { + invalid(); + } + const expectedKeys = new Set([ + 'length', + ...Array.from({ length: Number(length) }, (_, index) => String(index)), + ]); + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expectedKeys.size || + keys.some((key) => typeof key !== 'string' || !expectedKeys.has(key)) + ) { + invalid(); + } + return Array.from({ length: Number(length) }, (_, index) => + Reflect.get(value, String(index)) + ); +} + +function exactRecord( + value: unknown, + expectedKeys: readonly string[] +): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + invalid(); + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expectedKeys.length || + keys.some( + (key) => typeof key !== 'string' || !expectedKeys.includes(key) + ) + ) { + invalid(); + } + return Object.fromEntries( + expectedKeys.map((key) => [key, Reflect.get(value, key)]) + ); +} + +function isStrictlySorted(values: readonly string[]): boolean { + return values.every( + (value, index) => index === 0 || (values[index - 1] ?? '') < value + ); +} + +function isPositiveSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +function invalid(): never { + throw new Error('invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts new file mode 100644 index 000000000..35985e775 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity.spec.ts @@ -0,0 +1,246 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { + link, + mkdir, + mkdtemp, + rm, + symlink, + unlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import { + captureXtreamBuildIdentity, + computeXtreamBuildFilesAggregate, +} from './xtream-build-identity'; +import { assertXtreamBenchmarkManifest } from './xtream-exact-schema'; +import { BUILD_IDENTITY, MANIFEST } from './xtream-benchmark-report.fixtures'; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +describe('Xtream benchmark build identity', () => { + it('hashes exact Electron pairs and sorted top-level renderer code', async () => { + const root = await buildFixture(); + + const identity = await captureXtreamBuildIdentity(root); + + assert.deepEqual(identity.electron.main.javascript, { + bytes: 4, + path: 'dist/apps/electron-backend/main.js', + sha256: sha256('main'), + }); + assert.deepEqual(identity.electron.preload.sourceMap, { + bytes: 11, + path: 'dist/apps/electron-backend/main.preload.js.map', + sha256: sha256('preload-map'), + }); + assert.deepEqual( + identity.renderer.files.map((file) => file.path), + [ + 'dist/apps/web/chunk-A.js', + 'dist/apps/web/chunk-A.js.map', + 'dist/apps/web/index.html', + 'dist/apps/web/main-A.js', + 'dist/apps/web/main-A.js.map', + ] + ); + assert.equal(identity.renderer.executableJavaScriptCount, 2); + assert.equal(identity.renderer.sourceMapCount, 2); + assert.equal( + identity.renderer.bytes, + identity.renderer.files.reduce( + (total, file) => total + file.bytes, + 0 + ) + ); + assert.equal( + identity.renderer.aggregateSha256, + computeXtreamBuildFilesAggregate(identity.renderer.files) + ); + assert.equal( + identity.renderer.files.some((file) => + file.path.includes('app-config.js') + ), + false + ); + }); + + it('fails closed when an executable or its source map is missing', async () => { + const missingElectron = await buildFixture(); + await unlink( + join( + missingElectron, + 'dist/apps/electron-backend/workers/database.worker.js.map' + ) + ); + await assert.rejects( + captureXtreamBuildIdentity(missingElectron), + /invalid Xtream performance build/ + ); + + const missingRendererMap = await buildFixture(); + await unlink(join(missingRendererMap, 'dist/apps/web/chunk-A.js.map')); + await assert.rejects( + captureXtreamBuildIdentity(missingRendererMap), + /invalid Xtream performance build/ + ); + + const orphanRendererMap = await buildFixture(); + await writeFile( + join(orphanRendererMap, 'dist/apps/web/orphan.js.map'), + 'orphan' + ); + await assert.rejects( + captureXtreamBuildIdentity(orphanRendererMap), + /invalid Xtream performance build/ + ); + }); + + it('rejects symlinked and hard-linked build evidence', async () => { + const symlinkRoot = await buildFixture(); + const mainPath = join( + symlinkRoot, + 'dist/apps/electron-backend/main.js' + ); + const external = join(symlinkRoot, 'external-main.js'); + await writeFile(external, 'main'); + await unlink(mainPath); + await symlink(external, mainPath); + await assert.rejects( + captureXtreamBuildIdentity(symlinkRoot), + /invalid Xtream performance build/ + ); + + const hardLinkRoot = await buildFixture(); + await link( + join(hardLinkRoot, 'dist/apps/web/main-A.js'), + join(hardLinkRoot, 'main-alias.js') + ); + await assert.rejects( + captureXtreamBuildIdentity(hardLinkRoot), + /invalid Xtream performance build/ + ); + }); + + it('produces a different aggregate for path, byte, or hash drift', () => { + const files = [ + { bytes: 1, path: 'a.js', sha256: 'a'.repeat(64) }, + { bytes: 2, path: 'a.js.map', sha256: 'b'.repeat(64) }, + ]; + const baseline = computeXtreamBuildFilesAggregate(files); + + for (const changed of [ + [{ ...files[0], path: 'b.js' }, files[1]], + [{ ...files[0], bytes: 2 }, files[1]], + [{ ...files[0], sha256: 'c'.repeat(64) }, files[1]], + ]) { + assert.notEqual( + computeXtreamBuildFilesAggregate(changed), + baseline + ); + } + }); + + it('binds exact build descriptors into the persisted benchmark manifest', () => { + assert.doesNotThrow(() => assertXtreamBenchmarkManifest(MANIFEST)); + const files = BUILD_IDENTITY.renderer.files; + const invalidBuilds = [ + { + ...BUILD_IDENTITY, + renderer: { + ...BUILD_IDENTITY.renderer, + aggregateSha256: 'f'.repeat(64), + }, + }, + { + ...BUILD_IDENTITY, + renderer: { + ...BUILD_IDENTITY.renderer, + bytes: BUILD_IDENTITY.renderer.bytes + 1, + }, + }, + { + ...BUILD_IDENTITY, + renderer: { + ...BUILD_IDENTITY.renderer, + files: [...files].reverse(), + }, + }, + { + ...BUILD_IDENTITY, + renderer: { + ...BUILD_IDENTITY.renderer, + files: files.slice(0, -1), + }, + }, + { + ...BUILD_IDENTITY, + electron: { + ...BUILD_IDENTITY.electron, + main: { + ...BUILD_IDENTITY.electron.main, + javascript: { + ...BUILD_IDENTITY.electron.main.javascript, + path: 'dist/apps/electron-backend/other.js', + }, + }, + }, + }, + ]; + for (const build of invalidBuilds) { + assert.throws( + () => assertXtreamBenchmarkManifest({ ...MANIFEST, build }), + /invalid Xtream benchmark manifest/ + ); + } + }); +}); + +async function buildFixture(): Promise { + const root = await mkdtemp(join(tmpdir(), 'iptvnator-xtream-build-')); + directories.push(root); + const backend = join(root, 'dist/apps/electron-backend'); + const workers = join(backend, 'workers'); + const renderer = join(root, 'dist/apps/web'); + await Promise.all([ + mkdir(workers, { recursive: true }), + mkdir(join(renderer, 'assets'), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(backend, 'main.js'), 'main'), + writeFile(join(backend, 'main.js.map'), 'main-map'), + writeFile(join(backend, 'main.preload.js'), 'preload'), + writeFile(join(backend, 'main.preload.js.map'), 'preload-map'), + writeFile(join(workers, 'database.worker.js'), 'database'), + writeFile(join(workers, 'database.worker.js.map'), 'database-map'), + writeFile(join(workers, 'playlist-refresh.worker.js'), 'refresh'), + writeFile( + join(workers, 'playlist-refresh.worker.js.map'), + 'refresh-map' + ), + writeFile(join(renderer, 'index.html'), ''), + writeFile(join(renderer, 'main-A.js'), 'renderer-main'), + writeFile(join(renderer, 'main-A.js.map'), 'renderer-main-map'), + writeFile(join(renderer, 'chunk-A.js'), 'renderer-chunk'), + writeFile(join(renderer, 'chunk-A.js.map'), 'renderer-chunk-map'), + writeFile(join(renderer, 'styles.css'), 'ignored'), + writeFile(join(renderer, 'assets/app-config.js'), 'ignored'), + ]); + return root; +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts b/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts new file mode 100644 index 000000000..98d779c2e --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-build-identity.ts @@ -0,0 +1,202 @@ +import { createHash } from 'node:crypto'; +import { readdir } from 'node:fs/promises'; +import { isAbsolute, join } from 'node:path'; + +import { readXtreamSafeRegularFile } from './xtream-safe-file-read'; + +export interface XtreamBuildFileIdentity { + readonly bytes: number; + readonly path: string; + readonly sha256: string; +} + +export interface XtreamBuildPairIdentity { + readonly javascript: XtreamBuildFileIdentity; + readonly sourceMap: XtreamBuildFileIdentity; +} + +export interface XtreamBenchmarkBuildIdentity { + readonly electron: { + readonly databaseWorker: XtreamBuildPairIdentity; + readonly main: XtreamBuildPairIdentity; + readonly playlistRefreshWorker: XtreamBuildPairIdentity; + readonly preload: XtreamBuildPairIdentity; + }; + readonly renderer: { + readonly aggregateSha256: string; + readonly bytes: number; + readonly executableJavaScriptCount: number; + readonly files: readonly XtreamBuildFileIdentity[]; + readonly sourceMapCount: number; + }; +} + +const BACKEND_ROOT = 'dist/apps/electron-backend'; +const RENDERER_ROOT = 'dist/apps/web'; +const ELECTRON_PATHS = { + databaseWorker: `${BACKEND_ROOT}/workers/database.worker.js`, + main: `${BACKEND_ROOT}/main.js`, + playlistRefreshWorker: `${BACKEND_ROOT}/workers/playlist-refresh.worker.js`, + preload: `${BACKEND_ROOT}/main.preload.js`, +} as const; + +export async function captureXtreamBuildIdentity( + workspaceRoot: string +): Promise { + try { + if (!isAbsolute(workspaceRoot)) invalid(); + const [databaseWorker, main, playlistRefreshWorker, preload, renderer] = + await Promise.all([ + readPair(workspaceRoot, ELECTRON_PATHS.databaseWorker), + readPair(workspaceRoot, ELECTRON_PATHS.main), + readPair(workspaceRoot, ELECTRON_PATHS.playlistRefreshWorker), + readPair(workspaceRoot, ELECTRON_PATHS.preload), + readRenderer(workspaceRoot), + ]); + return Object.freeze({ + electron: Object.freeze({ + databaseWorker, + main, + playlistRefreshWorker, + preload, + }), + renderer, + }); + } catch { + throw new Error('invalid Xtream performance build'); + } +} + +export function computeXtreamBuildFilesAggregate( + files: readonly XtreamBuildFileIdentity[] +): string { + try { + const length = Reflect.get(files, 'length') as unknown; + if ( + !Number.isSafeInteger(length) || + Number(length) <= 0 || + Number(length) > 10_000 + ) { + invalid(); + } + const snapshot = Array.from({ length: Number(length) }, (_, index) => { + const value = Reflect.get(files, String(index)) as + XtreamBuildFileIdentity | undefined; + if ( + !value || + !Number.isSafeInteger(value.bytes) || + value.bytes < 0 || + typeof value.path !== 'string' || + value.path.length === 0 || + typeof value.sha256 !== 'string' || + !/^[a-f0-9]{64}$/u.test(value.sha256) + ) { + invalid(); + } + return [value.path, value.bytes, value.sha256] as const; + }); + return sha256(Buffer.from(JSON.stringify(snapshot), 'utf8')); + } catch { + throw new Error('invalid Xtream build file identity'); + } +} + +async function readPair( + workspaceRoot: string, + javascriptPath: string +): Promise { + const [javascript, sourceMap] = await Promise.all([ + readIdentity(workspaceRoot, javascriptPath), + readIdentity(workspaceRoot, `${javascriptPath}.map`), + ]); + return Object.freeze({ javascript, sourceMap }); +} + +async function readRenderer( + workspaceRoot: string +): Promise { + const rendererDirectory = join(workspaceRoot, RENDERER_ROOT); + const namesBefore = await readRendererNames(rendererDirectory); + assertRendererPairing(namesBefore); + const relativePaths = namesBefore.map((name) => `${RENDERER_ROOT}/${name}`); + const files = await Promise.all( + relativePaths.map((path) => readIdentity(workspaceRoot, path)) + ); + const namesAfter = await readRendererNames(rendererDirectory); + if ( + namesAfter.length !== namesBefore.length || + namesAfter.some((name, index) => name !== namesBefore[index]) + ) { + invalid(); + } + const executableJavaScriptCount = namesBefore.filter((name) => + name.endsWith('.js') + ).length; + const sourceMapCount = namesBefore.filter((name) => + name.endsWith('.js.map') + ).length; + const bytes = files.reduce((total, file) => total + file.bytes, 0); + if (!Number.isSafeInteger(bytes) || bytes <= 0) invalid(); + const frozenFiles = Object.freeze(files); + return Object.freeze({ + aggregateSha256: computeXtreamBuildFilesAggregate(frozenFiles), + bytes, + executableJavaScriptCount, + files: frozenFiles, + sourceMapCount, + }); +} + +async function readRendererNames(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + const relevant = entries.filter( + (entry) => + entry.name === 'index.html' || + entry.name.endsWith('.js') || + entry.name.endsWith('.js.map') + ); + if ( + relevant.some((entry) => !entry.isFile()) || + relevant.filter((entry) => entry.name === 'index.html').length !== 1 + ) { + invalid(); + } + return relevant.map((entry) => entry.name).sort(); +} + +function assertRendererPairing(names: readonly string[]): void { + const javascript = names.filter((name) => name.endsWith('.js')); + const sourceMaps = names.filter((name) => name.endsWith('.js.map')); + const javascriptSet = new Set(javascript); + const sourceMapSet = new Set(sourceMaps); + if ( + javascript.length === 0 || + javascript.length !== sourceMaps.length || + javascript.some((name) => !sourceMapSet.has(`${name}.map`)) || + sourceMaps.some((name) => !javascriptSet.has(name.slice(0, -4))) + ) { + invalid(); + } +} + +async function readIdentity( + workspaceRoot: string, + relativePath: string +): Promise { + const { content } = await readXtreamSafeRegularFile( + join(workspaceRoot, relativePath) + ); + return Object.freeze({ + bytes: content.byteLength, + path: relativePath, + sha256: sha256(content), + }); +} + +function sha256(value: Uint8Array): string { + return createHash('sha256').update(value).digest('hex'); +} + +function invalid(): never { + throw new Error('invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence-binding.ts b/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence-binding.ts new file mode 100644 index 000000000..142797fd0 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence-binding.ts @@ -0,0 +1,258 @@ +import type { DatabaseWorkerCancelTimelineRecord } from './database-worker-cancel-capture'; +import { snapshotXtreamCancellationEvidence } from './xtream-cancellation-evidence-snapshot'; +import { + isFiniteNonNegative, + isPositiveSafeInteger, +} from './xtream-exact-schema'; +import type { + XtreamAttributionPhaseEvent, + XtreamIpcAttributionSpan, +} from './xtream-phase-attribution'; +import { + hasDistinctMonotonicXtreamIpcProducerIdentities, + XTREAM_IPC_PRODUCER_NAMESPACE, + xtreamIpcProducerNamespaceForWorkerOperation, + type XtreamIpcProducerIdentity, +} from './xtream-ipc-producer-identity'; +import { XTREAM_ATTRIBUTION_PHASE as PHASE } from './xtream-phase-inventory'; +import type { + XtreamCancellationMetrics, + XtreamRawIterationResult, +} from './xtream-summary-contract'; + +type WorkerRequest = + XtreamRawIterationResult['databaseWorker']['requests']['evidence'][number]; + +const RECORD_TYPES = [ + 'db-cancel-dispatched', + 'db-cancel-received', + 'db-cancel-terminal-received', +] as const; + +export function assertXtreamCancellationEvidenceBinding( + raw: XtreamRawIterationResult, + events: readonly XtreamAttributionPhaseEvent[], + ipcSpans: readonly XtreamIpcAttributionSpan[], + requests: readonly WorkerRequest[] +): void { + const failed = requests.filter( + ({ operation, success }) => operation === 'DB_SAVE_CONTENT' && !success + ); + if (raw.scenarioId !== 'xtream-cancel-import') { + if (failed.length !== 0 || raw.cancellationEvidence !== null) invalid(); + return; + } + const request = failed[0]; + if ( + failed.length !== 1 || + !request || + typeof request.requestId !== 'string' || + typeof request.operationId !== 'string' || + raw.cancellation.failedDatabaseRequestId !== request.requestId || + raw.cancellation.failedOperationId !== request.operationId + ) { + invalid(); + } + const timeline = cancellationTimeline(raw, request); + const databasePreload = ipcPair(ipcSpans, 'dbCancelOperation'); + assertCancellationProducerIdentity(timeline, databasePreload[0], requests); + assertCancellationTimeline( + raw.cancellation, + request, + timeline, + phaseSpan( + events, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + request.requestId + ), + phaseSpan(events, PHASE.CANCEL_SESSION), + phaseSpan(events, PHASE.STORE_IMPORT_TERMINAL), + ipcPair(ipcSpans, 'xtreamCancelSession'), + databasePreload, + raw.phaseCapture.uiPaintEpochMs + ); +} + +interface Span { + readonly endEpochMs: number; + readonly startEpochMs: number; +} + +function assertCancellationTimeline( + value: XtreamCancellationMetrics, + request: WorkerRequest, + timeline: readonly DatabaseWorkerCancelTimelineRecord[], + write: Span, + networkCancel: Span, + terminal: Span, + networkPreload: readonly [ + XtreamIpcAttributionSpan, + XtreamIpcAttributionSpan, + ], + databasePreload: readonly [ + XtreamIpcAttributionSpan, + XtreamIpcAttributionSpan, + ], + uiPaintEpochMs: number +): void { + const [dispatchRecord, receiptRecord, workerTerminalRecord] = timeline; + if (!dispatchRecord || !receiptRecord || !workerTerminalRecord) invalid(); + const click = epoch(value.clickEpochMs); + const preloadStart = epoch(value.preloadStartEpochMs); + const preloadReturn = epoch(value.preloadReturnEpochMs); + const dispatch = epoch(value.databaseDispatchEpochMs); + const receipt = epoch(value.workerReceiptEpochMs); + const authoritative = epoch(value.authoritativeTerminalEpochMs); + const painted = epoch(value.paintedEpochMs); + if ( + request.requestReceivedEpochMs === null || + request.workEndedEpochMs === null || + click < write.startEpochMs || + click < request.requestReceivedEpochMs || + click > write.endEpochMs || + networkPreload[0].startEpochMs < click || + networkPreload[0].endEpochMs > networkCancel.startEpochMs || + networkCancel.endEpochMs > networkPreload[1].startEpochMs || + networkPreload[1].startEpochMs > databasePreload[0].startEpochMs || + preloadStart !== databasePreload[0].startEpochMs || + dispatchRecord.ipcCallId !== databasePreload[0].ipcCallId || + dispatchRecord.sourceEpochMs !== databasePreload[0].sourceEpochMs || + databasePreload[0].endEpochMs > dispatch || + dispatch !== dispatchRecord.epochMs || + dispatch < request.requestReceivedEpochMs || + dispatch > write.endEpochMs || + databasePreload[1].startEpochMs < dispatch || + preloadReturn !== databasePreload[1].endEpochMs || + preloadReturn < dispatch || + receipt !== receiptRecord.epochMs || + receipt < dispatch || + receipt > request.workEndedEpochMs || + receipt > authoritative || + workerTerminalRecord.epochMs < receipt || + workerTerminalRecord.epochMs > authoritative || + authoritative !== request.responseEpochMs || + terminal.startEpochMs < authoritative || + terminal.endEpochMs > painted || + painted !== uiPaintEpochMs + ) { + invalid(); + } +} + +function cancellationTimeline( + raw: XtreamRawIterationResult, + request: WorkerRequest +): readonly DatabaseWorkerCancelTimelineRecord[] { + const evidence = snapshotXtreamCancellationEvidence( + raw.cancellationEvidence + ); + if (evidence === null) invalid(); + const timeline = evidence.timeline; + if (timeline.length !== RECORD_TYPES.length) invalid(); + const first = timeline[0]; + if (!first) invalid(); + for (let index = 0; index < timeline.length; index += 1) { + const record = timeline[index]; + if ( + !record || + record.type !== RECORD_TYPES[index] || + !isFiniteNonNegative(record.epochMs) || + !isFiniteNonNegative(record.sourceEpochMs) || + !isPositiveSafeInteger(record.ipcCallId) || + typeof record.operationId !== 'string' || + record.operationId.length === 0 || + typeof record.requestId !== 'string' || + record.requestId.length === 0 || + record.ipcCallId !== first.ipcCallId || + record.operationId !== first.operationId || + record.requestId !== first.requestId || + record.sourceEpochMs !== first.sourceEpochMs + ) { + invalid(); + } + } + if ( + first.requestId !== request.requestId || + first.operationId !== request.operationId + ) { + invalid(); + } + return timeline; +} + +function assertCancellationProducerIdentity( + timeline: readonly DatabaseWorkerCancelTimelineRecord[], + databasePreload: XtreamIpcAttributionSpan, + requests: readonly WorkerRequest[] +): void { + const dispatch = timeline[0]; + if ( + !dispatch || + dispatch.ipcCallId !== databasePreload.ipcCallId || + dispatch.sourceEpochMs !== databasePreload.sourceEpochMs + ) { + invalid(); + } + const identities: XtreamIpcProducerIdentity[] = []; + for (const { ipcCallId, operation, sourceEpochMs } of requests) { + if (ipcCallId === null && sourceEpochMs === null) continue; + const namespace = xtreamIpcProducerNamespaceForWorkerOperation( + typeof operation === 'string' ? operation : '' + ); + if ( + namespace === null || + !isPositiveSafeInteger(ipcCallId) || + !isFiniteNonNegative(sourceEpochMs) + ) { + invalid(); + } + if (namespace === XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD) { + identities.push({ ipcCallId, namespace, sourceEpochMs }); + } + } + identities.push({ + ipcCallId: databasePreload.ipcCallId, + namespace: XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD, + sourceEpochMs: databasePreload.sourceEpochMs, + }); + if (!hasDistinctMonotonicXtreamIpcProducerIdentities(identities)) { + invalid(); + } +} + +function phaseSpan( + events: readonly XtreamAttributionPhaseEvent[], + phase: string, + correlationId?: string +): Span { + const selected = events.filter( + (event) => + event.phase === phase && + (correlationId === undefined || + event.correlationId === correlationId) + ); + const start = selected.find(({ boundary }) => boundary === 'start'); + const end = selected.find(({ boundary }) => boundary === 'end'); + if (selected.length !== 2 || !start || !end) invalid(); + return { endEpochMs: end.epochMs, startEpochMs: start.epochMs }; +} + +function ipcPair( + spans: readonly XtreamIpcAttributionSpan[], + method: string +): readonly [XtreamIpcAttributionSpan, XtreamIpcAttributionSpan] { + const selected = spans.filter((span) => span.method === method); + const request = selected.find(({ boundary }) => boundary === 'request'); + const response = selected.find(({ boundary }) => boundary === 'response'); + if (selected.length !== 2 || !request || !response) invalid(); + return [request, response]; +} + +function epoch(value: number | null): number { + if (!isFiniteNonNegative(value)) invalid(); + return value; +} + +function invalid(): never { + throw new Error('invalid Xtream persisted cancellation evidence'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence-snapshot.ts b/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence-snapshot.ts new file mode 100644 index 000000000..69b6b9691 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence-snapshot.ts @@ -0,0 +1,115 @@ +import type { DatabaseWorkerCancelTimelineRecord } from './database-worker-cancel-capture'; +import type { XtreamCancellationEvidence } from './xtream-summary-contract'; + +const EVIDENCE_KEYS = ['timeline'] as const; +const RECORD_KEYS = [ + 'epochMs', + 'ipcCallId', + 'operationId', + 'requestId', + 'sourceEpochMs', + 'type', +] as const; +const MAX_TIMELINE_LENGTH = 16; + +export function snapshotXtreamCancellationEvidence( + value: unknown +): XtreamCancellationEvidence | null { + try { + const evidence = snapshotExactRecord(value, EVIDENCE_KEYS); + const timeline = snapshotExactArray( + evidence['timeline'], + snapshotTimelineRecord + ); + return Object.freeze({ timeline }); + } catch { + return null; + } +} + +function snapshotTimelineRecord( + value: unknown +): DatabaseWorkerCancelTimelineRecord { + return snapshotExactRecord( + value, + RECORD_KEYS + ) as unknown as DatabaseWorkerCancelTimelineRecord; +} + +function snapshotExactArray( + value: unknown, + snapshotEntry: (entry: unknown) => T +): readonly T[] { + if (!Array.isArray(value)) invalid(); + const length = dataDescriptorValue( + Reflect.getOwnPropertyDescriptor(value, 'length') + ); + if ( + !Number.isSafeInteger(length) || + Number(length) < 0 || + Number(length) > MAX_TIMELINE_LENGTH + ) { + invalid(); + } + const expectedKeys = new Set(['length']); + const snapshot: T[] = []; + for (let index = 0; index < Number(length); index += 1) { + const key = String(index); + expectedKeys.add(key); + snapshot[index] = snapshotEntry( + dataDescriptorValue(Reflect.getOwnPropertyDescriptor(value, key)) + ); + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== expectedKeys.size || + ownKeys.some((key) => !expectedKeys.has(key)) + ) { + invalid(); + } + return Object.freeze(snapshot); +} + +function snapshotExactRecord( + value: unknown, + keys: readonly string[] +): Readonly> { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + ![Object.prototype, null].includes(Reflect.getPrototypeOf(value)) + ) { + invalid(); + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + ownKeys.some((key) => typeof key !== 'string' || !keys.includes(key)) + ) { + invalid(); + } + const snapshot: Record = {}; + for (const key of keys) { + snapshot[key] = dataDescriptorValue( + Reflect.getOwnPropertyDescriptor(value, key) + ); + } + return Object.freeze(snapshot); +} + +function dataDescriptorValue( + descriptor: PropertyDescriptor | undefined +): unknown { + if ( + !descriptor || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + invalid(); + } + return descriptor.value; +} + +function invalid(): never { + throw new Error('invalid Xtream cancellation evidence snapshot'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence.fixture.ts b/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence.fixture.ts new file mode 100644 index 000000000..af5fc6eaf --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-cancellation-evidence.fixture.ts @@ -0,0 +1,283 @@ +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import type { DatabaseWorkerCancelTimelineRecord } from './database-worker-cancel-capture'; +import type { + XtreamAttributionPhaseEvent, + XtreamIpcAttributionSpan, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import { + XTREAM_IPC_PRODUCER_NAMESPACE, + xtreamIpcProducerNamespaceForMethod, +} from './xtream-ipc-producer-identity'; +import { XTREAM_ATTRIBUTION_PHASE as PHASE } from './xtream-phase-inventory'; +import type { XtreamCancellationMetrics } from './xtream-summary-contract'; +import type { XtreamCancellationEvidence } from './xtream-summary-contract'; + +export function bindFixtureCancellationEvidence( + input: XtreamPhaseAttributionInput, + requests: readonly WorkerRequestPerformanceMetrics[] +): { + readonly cancellation: XtreamCancellationMetrics; + readonly cancellationEvidence: XtreamCancellationEvidence | null; + readonly phaseCapture: XtreamPhaseAttributionInput; +} { + if (input.scenarioId !== 'xtream-cancel-import') { + return { + cancellation: emptyCancellation(), + cancellationEvidence: null, + phaseCapture: input, + }; + } + const failed = requests.filter( + ({ operation, success }) => operation === 'DB_SAVE_CONTENT' && !success + ); + const request = failed[0]; + if ( + failed.length !== 1 || + !request || + typeof request.requestId !== 'string' || + typeof request.operationId !== 'string' || + request.workEndedEpochMs === null + ) { + fixtureError(); + } + const write = phaseSpan( + input.events, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + request.requestId + ); + const cancel = phaseSpan(input.events, PHASE.CANCEL_SESSION); + const clickEpochMs = write.startEpochMs + 0.25; + const networkPreloadStartEpochMs = clickEpochMs + 0.25; + const networkPreloadReturnEpochMs = cancel.endEpochMs + 0.25; + const preloadStartEpochMs = networkPreloadReturnEpochMs + 0.25; + const databaseDispatchEpochMs = preloadStartEpochMs + 0.5; + const preloadReturnEpochMs = databaseDispatchEpochMs + 0.5; + const workerReceiptEpochMs = preloadReturnEpochMs + 0.25; + const workerTerminalEpochMs = workerReceiptEpochMs + 0.25; + if ( + databaseDispatchEpochMs > write.endEpochMs || + workerTerminalEpochMs > request.workEndedEpochMs || + workerTerminalEpochMs > request.responseEpochMs + ) { + fixtureError(); + } + const authoritativeTerminalEpochMs = request.responseEpochMs; + const paintedEpochMs = input.uiPaintEpochMs; + const ipcSpans = resequenceFixtureIpcSpans( + input.ipcSpans.map((span) => { + if (span.method === 'xtreamCancelSession') { + return retime( + span, + networkPreloadStartEpochMs, + cancel.startEpochMs, + cancel.endEpochMs, + networkPreloadReturnEpochMs + ); + } + if (span.method === 'dbCancelOperation') { + return retime( + span, + preloadStartEpochMs, + databaseDispatchEpochMs - 0.25, + databaseDispatchEpochMs + 0.25, + preloadReturnEpochMs + ); + } + return span; + }) + ); + const databasePreload = ipcSpans.find( + ({ boundary, method }) => + boundary === 'request' && method === 'dbCancelOperation' + ); + if (!databasePreload) fixtureError(); + const cancelTimeline = cancellationTimeline( + request, + databasePreload, + databaseDispatchEpochMs, + workerReceiptEpochMs, + workerTerminalEpochMs + ); + return { + cancellation: { + acknowledgementLatencyMs: + authoritativeTerminalEpochMs - clickEpochMs, + authoritativeDatabaseAcknowledgementObserved: true, + authoritativeTerminalEpochMs, + clickEpochMs, + clickToDatabaseDispatchMs: databaseDispatchEpochMs - clickEpochMs, + clickToPaintMs: paintedEpochMs - clickEpochMs, + clickToPreloadReturnMs: preloadReturnEpochMs - clickEpochMs, + databaseDispatchEpochMs, + failedDatabaseRequestId: request.requestId, + failedOperationId: request.operationId, + mainNetworkAbortObserved: false, + mainNetworkAbortReason: 'cancel-triggered-after-network-complete', + paintedEpochMs, + preloadReturnEpochMs, + preloadStartEpochMs, + workerReceiptEpochMs, + workerReceiptIsAuthoritative: false, + workerReceiptLatencyMs: workerReceiptEpochMs - clickEpochMs, + }, + cancellationEvidence: { timeline: cancelTimeline }, + phaseCapture: { ...input, ipcSpans }, + }; +} + +function cancellationTimeline( + request: WorkerRequestPerformanceMetrics, + databasePreload: XtreamIpcAttributionSpan, + dispatchEpochMs: number, + receiptEpochMs: number, + terminalEpochMs: number +): readonly DatabaseWorkerCancelTimelineRecord[] { + if ( + typeof request.requestId !== 'string' || + typeof request.operationId !== 'string' + ) { + fixtureError(); + } + const identity = { + ipcCallId: databasePreload.ipcCallId, + operationId: request.operationId, + requestId: request.requestId, + sourceEpochMs: databasePreload.sourceEpochMs, + }; + return [ + { + ...identity, + epochMs: dispatchEpochMs, + type: 'db-cancel-dispatched', + }, + { + ...identity, + epochMs: receiptEpochMs, + type: 'db-cancel-received', + }, + { + ...identity, + epochMs: terminalEpochMs, + type: 'db-cancel-terminal-received', + }, + ]; +} + +function emptyCancellation(): XtreamCancellationMetrics { + return { + acknowledgementLatencyMs: null, + authoritativeDatabaseAcknowledgementObserved: false, + authoritativeTerminalEpochMs: null, + clickEpochMs: null, + clickToDatabaseDispatchMs: null, + clickToPaintMs: null, + clickToPreloadReturnMs: null, + databaseDispatchEpochMs: null, + failedDatabaseRequestId: null, + failedOperationId: null, + mainNetworkAbortObserved: false, + mainNetworkAbortReason: null, + paintedEpochMs: null, + preloadReturnEpochMs: null, + preloadStartEpochMs: null, + workerReceiptEpochMs: null, + workerReceiptIsAuthoritative: false, + workerReceiptLatencyMs: null, + }; +} + +function retime( + span: XtreamIpcAttributionSpan, + requestStartEpochMs: number, + requestEndEpochMs: number, + responseStartEpochMs: number, + responseEndEpochMs: number +): XtreamIpcAttributionSpan { + return { + ...span, + endEpochMs: + span.boundary === 'request' + ? requestEndEpochMs + : responseEndEpochMs, + startEpochMs: + span.boundary === 'request' + ? requestStartEpochMs + : responseStartEpochMs, + sourceEpochMs: requestStartEpochMs, + }; +} + +function resequenceFixtureIpcSpans( + spans: readonly XtreamIpcAttributionSpan[] +): readonly XtreamIpcAttributionSpan[] { + const groups = new Map(); + for (const span of spans) { + groups.set(span.correlationId, [ + ...(groups.get(span.correlationId) ?? []), + span, + ]); + } + const calls = [...groups.entries()].map(([correlationId, call]) => { + const span = call[0]; + if ( + call.length !== 2 || + !span || + call.some( + (candidate) => + candidate.ipcCallId !== span.ipcCallId || + candidate.sourceEpochMs !== span.sourceEpochMs + ) + ) { + fixtureError(); + } + const namespace = xtreamIpcProducerNamespaceForMethod(span.method); + if (namespace === null) fixtureError(); + return { correlationId, namespace, span }; + }); + const reassigned = new Map(); + for (const namespace of Object.values(XTREAM_IPC_PRODUCER_NAMESPACE)) { + const selected = calls + .filter((call) => call.namespace === namespace) + .sort( + (left, right) => + left.span.sourceEpochMs - right.span.sourceEpochMs || + left.span.ipcCallId - right.span.ipcCallId + ); + const ids = selected + .map(({ span }) => span.ipcCallId) + .sort((left, right) => left - right); + if (new Set(ids).size !== ids.length) fixtureError(); + selected.forEach(({ correlationId }, index) => { + const ipcCallId = ids[index]; + if (!ipcCallId) fixtureError(); + reassigned.set(correlationId, ipcCallId); + }); + } + return spans.map((span) => { + const ipcCallId = reassigned.get(span.correlationId); + if (!ipcCallId) fixtureError(); + return { ...span, ipcCallId }; + }); +} + +function phaseSpan( + events: readonly XtreamAttributionPhaseEvent[], + phase: string, + correlationId?: string +): { readonly endEpochMs: number; readonly startEpochMs: number } { + const selected = events.filter( + (event) => + event.phase === phase && + (correlationId === undefined || + event.correlationId === correlationId) + ); + const start = selected.find(({ boundary }) => boundary === 'start'); + const end = selected.find(({ boundary }) => boundary === 'end'); + if (selected.length !== 2 || !start || !end) fixtureError(); + return { endEpochMs: end.epochMs, startEpochMs: start.epochMs }; +} + +function fixtureError(): never { + throw new Error('invalid Xtream cancellation fixture evidence'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-catalog-database-queries.ts b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-queries.ts new file mode 100644 index 000000000..aedbc2a2a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-queries.ts @@ -0,0 +1,180 @@ +export interface XtreamCatalogDatabaseQuery { + readonly parameters: readonly (number | string)[]; + readonly sql: string; +} + +const CATEGORY_PARTITIONS = [ + { + categoryType: 'live', + expectedName: 'Performance Live %02d', + idBase: 91_100, + }, + { + categoryType: 'movies', + expectedName: 'Performance VOD %02d', + idBase: 92_100, + }, + { + categoryType: 'series', + expectedName: 'Performance Series %02d', + idBase: 93_100, + }, +] as const; + +const CONTENT_PARTITIONS = [ + { + categoryType: 'live', + contentType: 'live', + expectedTitle: 'Performance Live %06d', + idBase: 999_999, + }, + { + categoryType: 'movies', + contentType: 'movie', + expectedTitle: 'Performance Movie %06d', + idBase: 1_999_999, + }, + { + categoryType: 'series', + contentType: 'series', + expectedTitle: 'Performance Series %06d', + idBase: 2_999_999, + }, +] as const; + +const PLAYLIST_QUERY = ` + SELECT + COUNT(*) AS playlistCount, + COALESCE(SUM(CASE WHEN id = ? THEN 1 ELSE 0 END), 0) + AS matchingPlaylistCount, + MAX(CASE WHEN id = ? THEN type ELSE NULL END) + AS matchingPlaylistType + FROM playlists +`; + +const CATEGORY_COUNT_QUERY = ` + SELECT COUNT(*) AS categoryCount + FROM categories + WHERE playlist_id = ? +`; + +const CONTENT_COUNT_QUERY = ` + SELECT COUNT(*) AS contentCount + FROM content AS item + INNER JOIN categories AS category ON category.id = item.category_id + WHERE category.playlist_id = ? +`; + +const CATEGORY_PARTITION_QUERY = ` + SELECT + COUNT(*) AS count, + COUNT(DISTINCT xtream_id) AS distinctXtreamIdCount, + COALESCE( + SUM(CASE + WHEN name <> printf(?, xtream_id - ?) THEN 1 + ELSE 0 + END), + 0 + ) AS invalidNameCount, + MAX(xtream_id) AS maxXtreamId, + MIN(xtream_id) AS minXtreamId + FROM categories + WHERE playlist_id = ? AND type = ? +`; + +const CONTENT_PARTITION_QUERY = ` + SELECT + COUNT(*) AS count, + COUNT(DISTINCT item.xtream_id) AS distinctXtreamIdCount, + ( + SELECT COUNT(*) + FROM ( + SELECT expected_category.id + FROM categories AS expected_category + LEFT JOIN content AS expected_item + ON expected_item.category_id = expected_category.id + AND expected_item.type = ? + WHERE expected_category.playlist_id = ? + AND expected_category.type = ? + GROUP BY expected_category.id + HAVING COUNT(expected_item.id) <> 1000 + ) + ) AS invalidCategoryCardinalityCount, + COALESCE( + SUM(CASE + WHEN item.title <> printf(?, item.xtream_id - ?) THEN 1 + ELSE 0 + END), + 0 + ) AS invalidTitleCount, + MAX(item.xtream_id) AS maxXtreamId, + MIN(item.xtream_id) AS minXtreamId + FROM content AS item + INNER JOIN categories AS category ON category.id = item.category_id + WHERE category.playlist_id = ? + AND category.type = ? + AND item.type = ? +`; + +const IMPORT_STATUS_QUERY = ` + SELECT + (SELECT value FROM app_state WHERE key = ?) AS live, + (SELECT value FROM app_state WHERE key = ?) AS movie, + (SELECT value FROM app_state WHERE key = ?) AS series +`; + +export function buildXtreamCatalogDatabaseQueries( + playlistId: string +): readonly XtreamCatalogDatabaseQuery[] { + const queries: XtreamCatalogDatabaseQuery[] = [ + descriptor(PLAYLIST_QUERY, [playlistId, playlistId]), + descriptor(CATEGORY_COUNT_QUERY, [playlistId]), + descriptor(CONTENT_COUNT_QUERY, [playlistId]), + ]; + for (const partition of CATEGORY_PARTITIONS) { + queries.push( + descriptor(CATEGORY_PARTITION_QUERY, [ + partition.expectedName, + partition.idBase, + playlistId, + partition.categoryType, + ]) + ); + } + for (const partition of CONTENT_PARTITIONS) { + queries.push( + descriptor(CONTENT_PARTITION_QUERY, [ + partition.contentType, + playlistId, + partition.categoryType, + partition.expectedTitle, + partition.idBase, + playlistId, + partition.categoryType, + partition.contentType, + ]) + ); + } + queries.push( + descriptor(IMPORT_STATUS_QUERY, [ + importStatusKey(playlistId, 'live'), + importStatusKey(playlistId, 'movie'), + importStatusKey(playlistId, 'series'), + ]) + ); + return Object.freeze(queries); +} + +function descriptor( + sql: string, + parameters: readonly (number | string)[] +): XtreamCatalogDatabaseQuery { + return Object.freeze({ + parameters: Object.freeze([...parameters]), + sql, + }); +} + +function importStatusKey(playlistId: string, type: string): string { + return `xtream-import-status:${playlistId}:${type}`; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-catalog-database-reader.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-reader.spec.ts new file mode 100644 index 000000000..518663049 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-reader.spec.ts @@ -0,0 +1,365 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; + +import { + readXtreamCatalogDatabaseSnapshot, + type XtreamCatalogDatabaseEvaluator, +} from './xtream-catalog-database-reader'; + +interface TestStatement { + get(...parameters: readonly (number | string)[]): unknown; + run(...parameters: readonly (number | string)[]): unknown; +} + +interface TestDatabase { + close(): void; + exec(source: string): void; + prepare(source: string): TestStatement; +} + +interface TestDatabaseConstructor { + new (path: string): TestDatabase; +} + +interface RuntimeProcess { + getBuiltinModule(id: string): unknown; +} + +const { DatabaseSync } = ( + process as unknown as RuntimeProcess +).getBuiltinModule('node:sqlite') as { + readonly DatabaseSync: TestDatabaseConstructor; +}; + +describe('Xtream catalog database reader', () => { + it('reads the scoped catalog and import state after capture stops', async () => { + const database = createDatabase(); + seedPlaylist(database, 'playlist-1'); + seedPartition(database, { + categoryId: 91_101, + categoryName: 'Performance Live 01', + categoryType: 'live', + contentBase: 1_000_000, + contentType: 'live', + playlistId: 'playlist-1', + titleLabel: 'Live', + }); + seedPartition(database, { + categoryId: 92_101, + categoryName: 'Performance VOD 01', + categoryType: 'movies', + contentBase: 2_000_000, + contentType: 'movie', + playlistId: 'playlist-1', + titleLabel: 'Movie', + }); + seedPartition(database, { + categoryId: 93_101, + categoryName: 'Performance Series 01', + categoryType: 'series', + contentBase: 3_000_000, + contentType: 'series', + playlistId: 'playlist-1', + titleLabel: 'Series', + }); + seedStatuses(database, 'playlist-1', 'completed'); + const evaluator = evaluatorFor(database, 2_001); + + const snapshot = await readXtreamCatalogDatabaseSnapshot(evaluator, { + captureStoppedEpochMs: 2_000, + dataDirectory: '/tmp/iptvnator-performance-fixture', + playlistId: 'playlist-1', + requireFromPath: '/workspace/dist/apps/electron-backend/main.js', + }); + + assert.deepEqual(snapshot, { + captureStoppedEpochMs: 2_000, + categories: { + live: categoryPartition(1, 91_101), + movies: categoryPartition(1, 92_101), + series: categoryPartition(1, 93_101), + }, + categoryCount: 3, + content: { + live: contentPartition(1_000, 1_000_000), + movie: contentPartition(1_000, 2_000_000), + series: contentPartition(1_000, 3_000_000), + }, + contentCount: 3_000, + databaseReadStartedEpochMs: 2_001, + importStatuses: { + live: 'completed', + movie: 'completed', + series: 'completed', + }, + matchingPlaylistCount: 1, + matchingPlaylistType: 'xtream', + playlistCount: 1, + }); + database.close(); + }); + + it('reports malformed fixture rows and incomplete category cardinality', async () => { + const database = createDatabase(); + seedPlaylist(database, 'playlist-1'); + database.exec(` + INSERT INTO categories (playlist_id, name, type, xtream_id) + VALUES ('playlist-1', 'wrong category', 'live', 91101); + INSERT INTO content (category_id, title, xtream_id, type) + VALUES (last_insert_rowid(), 'wrong title', 1000000, 'live'); + `); + seedStatuses(database, 'playlist-1', 'cancelled'); + + const snapshot = await readXtreamCatalogDatabaseSnapshot( + evaluatorFor(database, 3_001), + { + captureStoppedEpochMs: 3_000, + dataDirectory: '/tmp/iptvnator-performance-fixture', + playlistId: 'playlist-1', + requireFromPath: + '/workspace/dist/apps/electron-backend/main.js', + } + ); + + assert.equal(snapshot.categories.live.invalidNameCount, 1); + assert.equal(snapshot.content.live.invalidTitleCount, 1); + assert.equal(snapshot.content.live.invalidCategoryCardinalityCount, 1); + assert.deepEqual(snapshot.importStatuses, { + live: 'cancelled', + movie: 'cancelled', + series: 'cancelled', + }); + database.close(); + }); + + it('returns an empty deleted state without inventing a playlist type', async () => { + const database = createDatabase(); + seedStatuses(database, 'playlist-1', 'completed'); + + const snapshot = await readXtreamCatalogDatabaseSnapshot( + evaluatorFor(database, 4_001), + { + captureStoppedEpochMs: 4_000, + dataDirectory: '/tmp/iptvnator-performance-fixture', + playlistId: 'playlist-1', + requireFromPath: + '/workspace/dist/apps/electron-backend/main.js', + } + ); + + assert.equal(snapshot.categoryCount, 0); + assert.equal(snapshot.contentCount, 0); + assert.equal(snapshot.matchingPlaylistCount, 0); + assert.equal(snapshot.matchingPlaylistType, null); + assert.equal(snapshot.playlistCount, 0); + database.close(); + }); + + it('fails closed for pre-capture reads and malformed main-process results', async () => { + const database = createDatabase(); + const base = { + captureStoppedEpochMs: 5_000, + dataDirectory: '/tmp/iptvnator-performance-fixture', + playlistId: 'playlist-1', + requireFromPath: '/workspace/dist/apps/electron-backend/main.js', + }; + + await assert.rejects( + readXtreamCatalogDatabaseSnapshot( + evaluatorFor(database, 4_999), + base + ), + /invalid Xtream catalog database read/ + ); + await assert.rejects( + readXtreamCatalogDatabaseSnapshot( + { + evaluate: async () => ({ + databaseReadStartedEpochMs: 5_001, + rows: [], + }), + }, + base + ), + /invalid Xtream catalog database read/ + ); + await assert.rejects( + readXtreamCatalogDatabaseSnapshot(evaluatorFor(database, 5_001), { + ...base, + dataDirectory: 'relative', + }), + /invalid Xtream catalog database read/ + ); + database.close(); + }); + + it('accepts high-resolution fractional capture and database epochs', async () => { + const database = createDatabase(); + + const snapshot = await readXtreamCatalogDatabaseSnapshot( + evaluatorFor(database, 6_000.75), + { + captureStoppedEpochMs: 6_000.5, + dataDirectory: '/tmp/iptvnator-performance-fixture', + playlistId: 'playlist-1', + requireFromPath: + '/workspace/dist/apps/electron-backend/main.js', + } + ); + + assert.equal(snapshot.captureStoppedEpochMs, 6_000.5); + assert.equal(snapshot.databaseReadStartedEpochMs, 6_000.75); + database.close(); + }); + + it('uses the high-resolution epoch clock inside the main-process read boundary', () => { + const source = readFileSync( + resolve( + process.cwd(), + 'src/performance/xtream-catalog-database-reader.ts' + ), + 'utf8' + ); + + assert.match(source, /getBuiltinModule\(\s*'node:perf_hooks'\s*\)/u); + assert.match( + source, + /perfHooks\.performance\.timeOrigin\s*\+\s*perfHooks\.performance\.now\(\)/u + ); + assert.doesNotMatch(source, /Date\.now\(\)/u); + }); +}); + +interface QueryDescriptor { + readonly parameters: readonly (number | string)[]; + readonly sql: string; +} + +interface EvaluationInput { + readonly queries: readonly QueryDescriptor[]; +} + +function evaluatorFor( + database: TestDatabase, + databaseReadStartedEpochMs: number +): XtreamCatalogDatabaseEvaluator { + return { + evaluate: async (_pageFunction: unknown, value: unknown) => { + const input = value as unknown as EvaluationInput; + return { + databaseReadStartedEpochMs, + rows: input.queries.map((query) => + database.prepare(query.sql).get(...query.parameters) + ), + }; + }, + } as unknown as XtreamCatalogDatabaseEvaluator; +} + +function createDatabase(): TestDatabase { + const database = new DatabaseSync(':memory:'); + database.exec(` + CREATE TABLE playlists (id TEXT PRIMARY KEY, type TEXT NOT NULL); + CREATE TABLE app_state (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + playlist_id TEXT NOT NULL, + name TEXT NOT NULL, + type TEXT NOT NULL, + xtream_id INTEGER NOT NULL + ); + CREATE TABLE content ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL, + title TEXT NOT NULL, + xtream_id INTEGER NOT NULL, + type TEXT NOT NULL + ); + `); + return database; +} + +function seedPlaylist(database: TestDatabase, playlistId: string): void { + database + .prepare('INSERT INTO playlists (id, type) VALUES (?, ?)') + .run(playlistId, 'xtream'); +} + +function seedStatuses( + database: TestDatabase, + playlistId: string, + status: string +): void { + const insert = database.prepare( + 'INSERT INTO app_state (key, value) VALUES (?, ?)' + ); + for (const type of ['live', 'movie', 'series']) { + insert.run(`xtream-import-status:${playlistId}:${type}`, status); + } +} + +function seedPartition( + database: TestDatabase, + options: { + readonly categoryId: number; + readonly categoryName: string; + readonly categoryType: string; + readonly contentBase: number; + readonly contentType: string; + readonly playlistId: string; + readonly titleLabel: string; + } +): void { + database + .prepare( + `INSERT INTO categories (playlist_id, name, type, xtream_id) + VALUES (?, ?, ?, ?)` + ) + .run( + options.playlistId, + options.categoryName, + options.categoryType, + options.categoryId + ); + const categoryRow = database + .prepare('SELECT id FROM categories WHERE playlist_id = ? AND type = ?') + .get(options.playlistId, options.categoryType) as { id: number }; + const insert = database.prepare( + `INSERT INTO content (category_id, title, xtream_id, type) + VALUES (?, ?, ?, ?)` + ); + for (let index = 0; index < 1_000; index += 1) { + insert.run( + categoryRow.id, + `Performance ${options.titleLabel} ${String(index + 1).padStart( + 6, + '0' + )}`, + options.contentBase + index, + options.contentType + ); + } +} + +function categoryPartition(count: number, id: number) { + return { + count, + distinctXtreamIdCount: count, + invalidNameCount: 0, + maxXtreamId: id, + minXtreamId: id, + }; +} + +function contentPartition(count: number, id: number) { + return { + count, + distinctXtreamIdCount: count, + invalidCategoryCardinalityCount: 0, + invalidTitleCount: 0, + maxXtreamId: id + count - 1, + minXtreamId: id, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-catalog-database-reader.ts b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-reader.ts new file mode 100644 index 000000000..b50658b75 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-reader.ts @@ -0,0 +1,302 @@ +import type { ElectronApplication } from '@playwright/test'; +import { isAbsolute, join } from 'node:path'; + +import type { XtreamCatalogDatabaseSnapshot } from './xtream-catalog-database-verification'; +import { + buildXtreamCatalogDatabaseQueries, + type XtreamCatalogDatabaseQuery, +} from './xtream-catalog-database-queries'; + +export type XtreamCatalogDatabaseEvaluator = Pick< + ElectronApplication, + 'evaluate' +>; + +export interface XtreamCatalogDatabaseReadOptions { + readonly captureStoppedEpochMs: number; + readonly dataDirectory: string; + readonly playlistId: string; + readonly requireFromPath: string; +} + +interface DatabaseReadInput { + readonly databasePath: string; + readonly queries: readonly XtreamCatalogDatabaseQuery[]; + readonly requireFromPath: string; +} + +interface DatabaseReadTransport { + readonly databaseReadStartedEpochMs: number; + readonly rows: readonly unknown[]; +} + +export async function readXtreamCatalogDatabaseSnapshot( + evaluator: XtreamCatalogDatabaseEvaluator, + options: XtreamCatalogDatabaseReadOptions +): Promise { + try { + assertOptions(options); + const queries = buildXtreamCatalogDatabaseQueries(options.playlistId); + const transport = (await evaluator.evaluate( + async (_electron, input: DatabaseReadInput) => { + interface SqliteStatement { + get(...parameters: readonly (number | string)[]): unknown; + } + interface SqliteDatabase { + close(): void; + pragma(source: string): unknown; + prepare(source: string): SqliteStatement; + } + interface SqliteConstructor { + new ( + path: string, + options: { + readonly: true; + fileMustExist: true; + } + ): SqliteDatabase; + } + interface NodeModuleBuiltin { + createRequire(path: string): (id: string) => unknown; + } + interface NodePerfHooksBuiltin { + readonly performance: { + now(): number; + readonly timeOrigin: number; + }; + } + + const perfHooks = process.getBuiltinModule( + 'node:perf_hooks' + ) as NodePerfHooksBuiltin; + const databaseReadStartedEpochMs = + perfHooks.performance.timeOrigin + + perfHooks.performance.now(); + const nodeModule = process.getBuiltinModule( + 'node:module' + ) as NodeModuleBuiltin; + const requireFromBuild = nodeModule.createRequire( + input.requireFromPath + ); + const Sqlite = requireFromBuild( + 'better-sqlite3' + ) as SqliteConstructor; + const database = new Sqlite(input.databasePath, { + readonly: true, + fileMustExist: true, + }); + try { + database.pragma('query_only = ON'); + return { + databaseReadStartedEpochMs, + rows: input.queries.map((query) => + database.prepare(query.sql).get(...query.parameters) + ), + }; + } finally { + database.close(); + } + }, + { + databasePath: join( + options.dataDirectory, + 'databases', + 'iptvnator.db' + ), + queries, + requireFromPath: options.requireFromPath, + } + )) as DatabaseReadTransport; + return snapshotTransport( + transport, + options.captureStoppedEpochMs, + queries.length + ); + } catch { + throw new Error('invalid Xtream catalog database read'); + } +} + +function snapshotTransport( + value: unknown, + captureStoppedEpochMs: number, + expectedRowCount: number +): XtreamCatalogDatabaseSnapshot { + const transport = exactRecord(value, [ + 'databaseReadStartedEpochMs', + 'rows', + ]); + const databaseReadStartedEpochMs = transport[ + 'databaseReadStartedEpochMs' + ] as unknown; + const rows = transport['rows']; + if ( + !isPositiveFinite(databaseReadStartedEpochMs) || + databaseReadStartedEpochMs < captureStoppedEpochMs || + !Array.isArray(rows) || + rows.length !== expectedRowCount + ) { + invalid(); + } + + const playlist = numericRecord(rows[0], [ + 'playlistCount', + 'matchingPlaylistCount', + 'matchingPlaylistType', + ]); + const categoryCount = singleCount(rows[1], 'categoryCount'); + const contentCount = singleCount(rows[2], 'contentCount'); + const categories = [rows[3], rows[4], rows[5]].map(categoryPartition); + const content = [rows[6], rows[7], rows[8]].map(contentPartition); + const importStatuses = statusRecord(rows[9]); + const matchingPlaylistType = playlist['matchingPlaylistType']; + if ( + matchingPlaylistType !== null && + typeof matchingPlaylistType !== 'string' + ) { + invalid(); + } + + return Object.freeze({ + captureStoppedEpochMs, + categories: Object.freeze({ + live: categories[0], + movies: categories[1], + series: categories[2], + }), + categoryCount, + content: Object.freeze({ + live: content[0], + movie: content[1], + series: content[2], + }), + contentCount, + databaseReadStartedEpochMs, + importStatuses, + matchingPlaylistCount: nonNegativeInteger( + playlist['matchingPlaylistCount'] + ), + matchingPlaylistType, + playlistCount: nonNegativeInteger(playlist['playlistCount']), + }); +} + +function categoryPartition(value: unknown) { + const row = numericRecord(value, [ + 'count', + 'distinctXtreamIdCount', + 'invalidNameCount', + 'maxXtreamId', + 'minXtreamId', + ]); + return Object.freeze({ + count: nonNegativeInteger(row['count']), + distinctXtreamIdCount: nonNegativeInteger(row['distinctXtreamIdCount']), + invalidNameCount: nonNegativeInteger(row['invalidNameCount']), + maxXtreamId: nullableInteger(row['maxXtreamId']), + minXtreamId: nullableInteger(row['minXtreamId']), + }); +} + +function contentPartition(value: unknown) { + const row = numericRecord(value, [ + 'count', + 'distinctXtreamIdCount', + 'invalidCategoryCardinalityCount', + 'invalidTitleCount', + 'maxXtreamId', + 'minXtreamId', + ]); + return Object.freeze({ + count: nonNegativeInteger(row['count']), + distinctXtreamIdCount: nonNegativeInteger(row['distinctXtreamIdCount']), + invalidCategoryCardinalityCount: nonNegativeInteger( + row['invalidCategoryCardinalityCount'] + ), + invalidTitleCount: nonNegativeInteger(row['invalidTitleCount']), + maxXtreamId: nullableInteger(row['maxXtreamId']), + minXtreamId: nullableInteger(row['minXtreamId']), + }); +} + +function singleCount(value: unknown, key: string): number { + return nonNegativeInteger(numericRecord(value, [key])[key]); +} + +function statusRecord(value: unknown) { + const row = exactRecord(value, ['live', 'movie', 'series']); + for (const key of ['live', 'movie', 'series']) { + if (row[key] !== null && typeof row[key] !== 'string') { + invalid(); + } + } + return Object.freeze({ + live: row['live'] as string | null, + movie: row['movie'] as string | null, + series: row['series'] as string | null, + }); +} + +function numericRecord( + value: unknown, + keys: readonly string[] +): Record { + return exactRecord(value, keys); +} + +function exactRecord( + value: unknown, + expectedKeys: readonly string[] +): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + invalid(); + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expectedKeys.length || + keys.some( + (key) => typeof key !== 'string' || !expectedKeys.includes(key) + ) + ) { + invalid(); + } + return Object.fromEntries( + expectedKeys.map((key) => [key, Reflect.get(value, key)]) + ); +} + +function nonNegativeInteger(value: unknown): number { + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < 0 + ) { + invalid(); + } + return value; +} + +function nullableInteger(value: unknown): number | null { + if (value === null) return null; + return nonNegativeInteger(value); +} + +function assertOptions(options: XtreamCatalogDatabaseReadOptions): void { + if ( + !isPositiveFinite(options.captureStoppedEpochMs) || + !isAbsolute(options.dataDirectory) || + !isAbsolute(options.requireFromPath) || + options.playlistId.length === 0 || + options.playlistId.length > 256 + ) { + invalid(); + } +} + +function isPositiveFinite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function invalid(): never { + throw new Error('invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-catalog-database-verification.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-verification.spec.ts new file mode 100644 index 000000000..dee01dcc3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-verification.spec.ts @@ -0,0 +1,361 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_SCENARIO_ID, + type XtreamPerformanceManifest, +} from './xtream-benchmark-contract'; +import { + assertXtreamCatalogDatabaseSnapshot, + createXtreamCatalogVerification, + type XtreamCatalogDatabaseSnapshot, +} from './xtream-catalog-database-verification'; + +describe('Xtream catalog database verification', () => { + it('accepts the exact synthetic catalog after import and refresh scenarios', () => { + for (const scenarioId of [ + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + XTREAM_SCENARIO_ID.REFRESH_LARGE, + XTREAM_SCENARIO_ID.BACKGROUND_UI, + ] as const) { + const snapshot = importedSnapshot(); + + assert.doesNotThrow(() => + assertXtreamCatalogDatabaseSnapshot( + snapshot, + manifest(), + scenarioId + ) + ); + assert.deepEqual( + createXtreamCatalogVerification( + snapshot, + manifest(), + scenarioId + ), + { + databaseStateValid: true, + fixtureIdentityValid: true, + providerCategoryCount: 100, + providerItemCount: 100_000, + verifiedAfterCapture: true, + } + ); + } + }); + + it('pins the current cancellation cleanup state and complete source deletion', () => { + assert.doesNotThrow(() => + assertXtreamCatalogDatabaseSnapshot( + emptySnapshot({ + matchingPlaylistCount: 1, + matchingPlaylistType: 'xtream', + playlistCount: 1, + statuses: { + live: 'failed', + movie: 'failed', + series: 'failed', + }, + }), + manifest(), + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ) + ); + assert.throws( + () => + assertXtreamCatalogDatabaseSnapshot( + emptySnapshot({ + matchingPlaylistCount: 1, + matchingPlaylistType: 'xtream', + playlistCount: 1, + statuses: { + live: 'cancelled', + movie: 'cancelled', + series: 'cancelled', + }, + }), + manifest(), + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ), + /invalid Xtream database catalog state/ + ); + assert.doesNotThrow(() => + assertXtreamCatalogDatabaseSnapshot( + emptySnapshot(), + manifest(), + XTREAM_SCENARIO_ID.DELETE_LARGE + ) + ); + }); + + it('rejects partial, foreign, mis-associated, and non-terminal database state', () => { + const baseline = importedSnapshot(); + const invalid: XtreamCatalogDatabaseSnapshot[] = [ + { ...baseline, playlistCount: 2 }, + { + ...baseline, + categories: { + ...baseline.categories, + live: { + ...baseline.categories.live, + distinctXtreamIdCount: 59, + }, + }, + }, + { + ...baseline, + content: { + ...baseline.content, + movie: { + ...baseline.content.movie, + maxXtreamId: 2_019_998, + }, + }, + }, + { + ...baseline, + content: { + ...baseline.content, + series: { + ...baseline.content.series, + invalidCategoryCardinalityCount: 1, + }, + }, + }, + { + ...baseline, + content: { + ...baseline.content, + live: { + ...baseline.content.live, + invalidTitleCount: 1, + }, + }, + }, + { + ...baseline, + importStatuses: { + ...baseline.importStatuses, + movie: 'importing', + }, + }, + { + ...baseline, + databaseReadStartedEpochMs: baseline.captureStoppedEpochMs - 1, + }, + ]; + + for (const snapshot of invalid) { + assert.throws( + () => + assertXtreamCatalogDatabaseSnapshot( + snapshot, + manifest(), + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ), + /^Error: invalid Xtream database catalog state$/u + ); + } + }); + + it('rejects cancellation residue and a delete that left the source row behind', () => { + const cancelled = emptySnapshot({ + matchingPlaylistCount: 1, + matchingPlaylistType: 'xtream', + playlistCount: 1, + statuses: { + live: 'failed', + movie: 'failed', + series: 'failed', + }, + }); + const cancelWithResidue: XtreamCatalogDatabaseSnapshot = { + ...cancelled, + content: { + ...cancelled.content, + live: { + ...cancelled.content.live, + count: 1, + distinctXtreamIdCount: 1, + maxXtreamId: 1_000_000, + minXtreamId: 1_000_000, + }, + }, + contentCount: 1, + }; + + assert.throws( + () => + assertXtreamCatalogDatabaseSnapshot( + cancelWithResidue, + manifest(), + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ), + /invalid Xtream database catalog state/ + ); + assert.throws( + () => + assertXtreamCatalogDatabaseSnapshot( + emptySnapshot({ + matchingPlaylistCount: 1, + matchingPlaylistType: 'xtream', + playlistCount: 1, + }), + manifest(), + XTREAM_SCENARIO_ID.DELETE_LARGE + ), + /invalid Xtream database catalog state/ + ); + }); + + it('uses one canonical fixture manifest snapshot', () => { + const expectedManifest = manifest(); + const reducedCounts = { + categories: { live: 1, series: 1, total: 3, vod: 1 }, + items: { live: 1, series: 1, total: 3, vod: 1 }, + }; + let countReads = 0; + const changingManifest = { + ...expectedManifest, + get counts() { + countReads += 1; + return countReads === 1 + ? expectedManifest.counts + : reducedCounts; + }, + }; + const reducedSnapshot: XtreamCatalogDatabaseSnapshot = { + ...importedSnapshot(), + categories: { + live: partition(1, 91_101), + movies: partition(1, 92_101), + series: partition(1, 93_101), + }, + categoryCount: 3, + content: { + live: contentPartition(1, 1_000_000), + movie: contentPartition(1, 2_000_000), + series: contentPartition(1, 3_000_000), + }, + contentCount: 3, + }; + + assert.throws( + () => + createXtreamCatalogVerification( + reducedSnapshot, + changingManifest, + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ), + /invalid Xtream database catalog state/ + ); + assert.equal(countReads, 1); + }); +}); + +function manifest(): XtreamPerformanceManifest { + return { + bytes: 12_345_678, + catalogSha256: 'a'.repeat(64), + counts: { + categories: { live: 60, series: 20, total: 100, vod: 20 }, + items: { + live: 60_000, + series: 20_000, + total: 100_000, + vod: 20_000, + }, + }, + epoch: 1, + scenario: 'performance-100k', + seed: 91_001, + }; +} + +function importedSnapshot(): XtreamCatalogDatabaseSnapshot { + return { + captureStoppedEpochMs: 1_000, + categories: { + live: partition(60, 91_101), + movies: partition(20, 92_101), + series: partition(20, 93_101), + }, + categoryCount: 100, + content: { + live: contentPartition(60_000, 1_000_000), + movie: contentPartition(20_000, 2_000_000), + series: contentPartition(20_000, 3_000_000), + }, + contentCount: 100_000, + databaseReadStartedEpochMs: 1_001, + importStatuses: { + live: 'completed', + movie: 'completed', + series: 'completed', + }, + matchingPlaylistCount: 1, + matchingPlaylistType: 'xtream', + playlistCount: 1, + }; +} + +function emptySnapshot( + options: { + matchingPlaylistCount?: number; + matchingPlaylistType?: string | null; + playlistCount?: number; + statuses?: XtreamCatalogDatabaseSnapshot['importStatuses']; + } = {} +): XtreamCatalogDatabaseSnapshot { + return { + captureStoppedEpochMs: 1_000, + categories: { + live: partition(0, null), + movies: partition(0, null), + series: partition(0, null), + }, + categoryCount: 0, + content: { + live: contentPartition(0, null), + movie: contentPartition(0, null), + series: contentPartition(0, null), + }, + contentCount: 0, + databaseReadStartedEpochMs: 1_001, + importStatuses: options.statuses ?? { + live: 'completed', + movie: 'completed', + series: 'completed', + }, + matchingPlaylistCount: options.matchingPlaylistCount ?? 0, + matchingPlaylistType: options.matchingPlaylistType ?? null, + playlistCount: options.playlistCount ?? 0, + }; +} + +function partition( + count: number, + minXtreamId: number | null +): XtreamCatalogDatabaseSnapshot['categories']['live'] { + return { + count, + distinctXtreamIdCount: count, + invalidNameCount: 0, + maxXtreamId: minXtreamId === null ? null : minXtreamId + count - 1, + minXtreamId, + }; +} + +function contentPartition( + count: number, + minXtreamId: number | null +): XtreamCatalogDatabaseSnapshot['content']['live'] { + return { + count, + distinctXtreamIdCount: count, + invalidCategoryCardinalityCount: 0, + invalidTitleCount: 0, + maxXtreamId: minXtreamId === null ? null : minXtreamId + count - 1, + minXtreamId, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-catalog-database-verification.ts b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-verification.ts new file mode 100644 index 000000000..0467446e0 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-catalog-database-verification.ts @@ -0,0 +1,237 @@ +import { isDeepStrictEqual } from 'node:util'; + +import { + XTREAM_SCENARIO_ID, + type XtreamPerformanceManifest, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { parseXtreamManifest } from './xtream-control-state-schema'; + +interface XtreamCatalogPartition { + readonly count: number; + readonly distinctXtreamIdCount: number; + readonly invalidNameCount: number; + readonly maxXtreamId: number | null; + readonly minXtreamId: number | null; +} + +interface XtreamContentPartition { + readonly count: number; + readonly distinctXtreamIdCount: number; + readonly invalidCategoryCardinalityCount: number; + readonly invalidTitleCount: number; + readonly maxXtreamId: number | null; + readonly minXtreamId: number | null; +} + +export interface XtreamCatalogDatabaseSnapshot { + readonly captureStoppedEpochMs: number; + readonly categories: { + readonly live: XtreamCatalogPartition; + readonly movies: XtreamCatalogPartition; + readonly series: XtreamCatalogPartition; + }; + readonly categoryCount: number; + readonly content: { + readonly live: XtreamContentPartition; + readonly movie: XtreamContentPartition; + readonly series: XtreamContentPartition; + }; + readonly contentCount: number; + readonly databaseReadStartedEpochMs: number; + readonly importStatuses: { + readonly live: string | null; + readonly movie: string | null; + readonly series: string | null; + }; + readonly matchingPlaylistCount: number; + readonly matchingPlaylistType: string | null; + readonly playlistCount: number; +} + +export interface XtreamCatalogVerification { + readonly databaseStateValid: true; + readonly fixtureIdentityValid: true; + readonly providerCategoryCount: number; + readonly providerItemCount: number; + readonly verifiedAfterCapture: true; +} + +export function assertXtreamCatalogDatabaseSnapshot( + snapshot: unknown, + manifest: XtreamPerformanceManifest, + scenarioId: XtreamScenarioId +): asserts snapshot is XtreamCatalogDatabaseSnapshot { + try { + const canonicalManifest = parseXtreamManifest(manifest); + assertCatalogSnapshot(snapshot, canonicalManifest, scenarioId); + } catch { + throw new Error('invalid Xtream database catalog state'); + } +} + +export function createXtreamCatalogVerification( + snapshot: unknown, + manifest: XtreamPerformanceManifest, + scenarioId: XtreamScenarioId +): XtreamCatalogVerification { + try { + const canonicalManifest = parseXtreamManifest(manifest); + assertCatalogSnapshot(snapshot, canonicalManifest, scenarioId); + return Object.freeze({ + databaseStateValid: true, + fixtureIdentityValid: true, + providerCategoryCount: canonicalManifest.counts.categories.total, + providerItemCount: canonicalManifest.counts.items.total, + verifiedAfterCapture: true, + }); + } catch { + throw new Error('invalid Xtream database catalog state'); + } +} + +function assertCatalogSnapshot( + snapshot: unknown, + manifest: XtreamPerformanceManifest, + scenarioId: XtreamScenarioId +): void { + const expected = expectedSnapshot(manifest, scenarioId); + const candidate = snapshotTopLevel(snapshot); + if ( + !isSnapshotTimingValid(candidate) || + !isDeepStrictEqual(withoutSnapshotTiming(candidate), expected) + ) { + invalid(); + } +} + +function expectedSnapshot( + manifest: XtreamPerformanceManifest, + scenarioId: XtreamScenarioId +): XtreamCatalogDatabaseSnapshot { + if (!Object.values(XTREAM_SCENARIO_ID).includes(scenarioId)) { + invalid(); + } + const empty = isEmptyScenario(scenarioId); + const deleted = scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE; + const cancelled = scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT; + const expected = { + captureStoppedEpochMs: 1, + categories: { + live: categoryPartition( + empty ? 0 : manifest.counts.categories.live, + 91_101 + ), + movies: categoryPartition( + empty ? 0 : manifest.counts.categories.vod, + 92_101 + ), + series: categoryPartition( + empty ? 0 : manifest.counts.categories.series, + 93_101 + ), + }, + categoryCount: empty ? 0 : manifest.counts.categories.total, + content: { + live: contentPartition( + empty ? 0 : manifest.counts.items.live, + 1_000_000 + ), + movie: contentPartition( + empty ? 0 : manifest.counts.items.vod, + 2_000_000 + ), + series: contentPartition( + empty ? 0 : manifest.counts.items.series, + 3_000_000 + ), + }, + contentCount: empty ? 0 : manifest.counts.items.total, + databaseReadStartedEpochMs: 1, + importStatuses: { + live: cancelled ? 'failed' : 'completed', + movie: cancelled ? 'failed' : 'completed', + series: cancelled ? 'failed' : 'completed', + }, + matchingPlaylistCount: deleted ? 0 : 1, + matchingPlaylistType: deleted ? null : 'xtream', + playlistCount: deleted ? 0 : 1, + } satisfies XtreamCatalogDatabaseSnapshot; + return { + ...expected, + captureStoppedEpochMs: Number.NaN, + databaseReadStartedEpochMs: Number.NaN, + }; +} + +function snapshotTopLevel(value: unknown): XtreamCatalogDatabaseSnapshot { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + invalid(); + } + return { ...value } as XtreamCatalogDatabaseSnapshot; +} + +function isSnapshotTimingValid( + snapshot: XtreamCatalogDatabaseSnapshot +): boolean { + if ( + !isPositiveFinite(snapshot.captureStoppedEpochMs) || + !isPositiveFinite(snapshot.databaseReadStartedEpochMs) || + snapshot.databaseReadStartedEpochMs < snapshot.captureStoppedEpochMs + ) { + return false; + } + return true; +} + +function withoutSnapshotTiming( + snapshot: XtreamCatalogDatabaseSnapshot +): XtreamCatalogDatabaseSnapshot { + return { + ...snapshot, + captureStoppedEpochMs: Number.NaN, + databaseReadStartedEpochMs: Number.NaN, + }; +} + +function isEmptyScenario(scenarioId: XtreamScenarioId): boolean { + return ( + scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT || + scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE + ); +} + +function categoryPartition( + count: number, + firstXtreamId: number +): XtreamCatalogPartition { + return { + count, + distinctXtreamIdCount: count, + invalidNameCount: 0, + maxXtreamId: count === 0 ? null : firstXtreamId + count - 1, + minXtreamId: count === 0 ? null : firstXtreamId, + }; +} + +function contentPartition( + count: number, + firstXtreamId: number +): XtreamContentPartition { + return { + count, + distinctXtreamIdCount: count, + invalidCategoryCardinalityCount: 0, + invalidTitleCount: 0, + maxXtreamId: count === 0 ? null : firstXtreamId + count - 1, + minXtreamId: count === 0 ? null : firstXtreamId, + }; +} + +function isPositiveFinite(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function invalid(): never { + throw new Error('invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-control-client.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-control-client.spec.ts new file mode 100644 index 000000000..e3d60142f --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-control-client.spec.ts @@ -0,0 +1,377 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { + XtreamControlState, + XtreamPerformanceManifest, +} from './xtream-benchmark-contract'; +import { createXtreamControlClient } from './xtream-control-client'; + +const TOKEN = 'never-persist-this-control-token'; +const MANIFEST: XtreamPerformanceManifest = { + epoch: 1, + scenario: 'performance-100k', + seed: 91_001, + counts: { + categories: { live: 60, vod: 20, series: 20, total: 100 }, + items: { + live: 60_000, + vod: 20_000, + series: 20_000, + total: 100_000, + }, + }, + bytes: 100, + catalogSha256: + 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789', +}; +const STATE: XtreamControlState = { + epoch: 1, + prepared: MANIFEST, + rules: [], + heldIds: [], + heldCount: 0, + occurrences: [], + ledger: [], +}; + +describe('Xtream performance control client', () => { + it('exposes only fixed prepare, reset, and state operations', async () => { + const calls: RecordedCall[] = []; + const client = createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token: TOKEN, + fetchImpl: fakeFetch(calls), + }); + + assert.deepEqual(Object.keys(client).sort(), [ + 'prepare', + 'resetAll', + 'resetObservations', + 'state', + ]); + assert.deepEqual(await client.prepare(), MANIFEST); + await client.resetObservations(); + await client.resetAll(); + assert.deepEqual(await client.state(), STATE); + + assert.deepEqual( + calls.map(({ body, method, path }) => ({ body, method, path })), + [ + { + body: { scenario: 'performance-100k' }, + method: 'POST', + path: '/__control/prepare', + }, + { + body: { mode: 'observations' }, + method: 'POST', + path: '/__control/reset', + }, + { + body: { mode: 'all' }, + method: 'POST', + path: '/__control/reset', + }, + { + body: null, + method: 'GET', + path: '/__control/state', + }, + ] + ); + assert.ok( + calls.every( + ({ headers, redirect }) => + redirect === 'error' && + headers.get('x-iptvnator-performance-token') === TOKEN + ) + ); + assert.equal( + calls.at(-1)?.headers.get('content-type'), + null, + 'GET must not carry a synthetic JSON body' + ); + }); + + it('keeps the token closure-only and redacts transport failures', async () => { + const client = createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token: TOKEN, + fetchImpl: async () => + new Response( + JSON.stringify({ + error: TOKEN, + password: 'performance', + }), + { status: 500 } + ), + }); + + assert.doesNotMatch(JSON.stringify(client), new RegExp(TOKEN)); + await assert.rejects( + () => client.prepare(), + (error: unknown) => + error instanceof Error && + /prepare failed with HTTP 500/.test(error.message) && + !error.message.includes(TOKEN) && + !error.message.includes('performance') + ); + }); + + it('rejects redirects and a response finalized at any second origin', async () => { + let forwardedToken = false; + const client = createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token: TOKEN, + fetchImpl: async (_input, init) => { + if (init?.redirect !== 'error') { + forwardedToken = new Headers(init?.headers).has( + 'x-iptvnator-performance-token' + ); + } + return responseAt( + MANIFEST, + 200, + 'http://127.0.0.1:43124/__control/prepare' + ); + }, + }); + + await assert.rejects( + () => client.prepare(), + /redirect|origin|transport failed/ + ); + assert.equal(forwardedToken, false); + }); + + it('validates control responses without echoing their unsafe payloads', async () => { + const client = createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token: TOKEN, + fetchImpl: async () => + responseAt( + { + username: 'performance', + secret: TOKEN, + }, + 200, + 'http://127.0.0.1:43123/__control/state' + ), + }); + + await assert.rejects( + () => client.state(), + (error: unknown) => + error instanceof Error && + /invalid control response/.test(error.message) && + !error.message.includes(TOKEN) && + !error.message.includes('performance') + ); + }); + + it('sanitizes adversarial response metadata accessors', async () => { + for (const property of ['ok', 'status', 'redirected', 'url'] as const) { + const client = createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token: TOKEN, + fetchImpl: async (input) => { + const response = { + json: async () => MANIFEST, + ok: property === 'status' ? false : true, + redirected: false, + status: 200, + url: String(input), + }; + Object.defineProperty(response, property, { + get() { + throw new Error(TOKEN); + }, + }); + return response as Response; + }, + }); + + await assert.rejects( + () => client.prepare(), + (error: unknown) => + error instanceof Error && + /invalid response metadata/.test(error.message) && + !error.message.includes(TOKEN) + ); + } + }); + + it('sanitizes reset response Proxy failures', async () => { + const resetBody = new Proxy( + {}, + { + ownKeys() { + throw new Error(TOKEN); + }, + } + ); + const client = createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token: TOKEN, + fetchImpl: async (input) => + ({ + json: async () => resetBody, + ok: true, + redirected: false, + status: 200, + url: String(input), + }) as Response, + }); + + await assert.rejects( + () => client.resetAll(), + (error: unknown) => + error instanceof Error && + error.message === 'Xtream all reset invalid control response' && + !error.message.includes(TOKEN) + ); + }); + + it('rejects non-loopback origins and empty tokens before issuing I/O', () => { + let calls = 0; + const fetchImpl: typeof fetch = async () => { + calls += 1; + return new Response(); + }; + + assert.throws( + () => + createXtreamControlClient({ + origin: 'https://provider.example', + token: TOKEN, + fetchImpl, + }), + /exact Xtream HTTP loopback origin/ + ); + assert.throws( + () => + createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token: '', + fetchImpl, + }), + /non-empty control token/ + ); + assert.equal(calls, 0); + }); + + it('snapshots the validated origin and token before mutable getters can change them', async () => { + const calls: RecordedCall[] = []; + let currentOrigin = 'http://127.0.0.1:43123'; + let currentToken = TOKEN; + let originReads = 0; + let tokenReads = 0; + const client = createXtreamControlClient({ + fetchImpl: fakeFetch(calls), + get origin() { + originReads += 1; + return currentOrigin; + }, + get token() { + tokenReads += 1; + return currentToken; + }, + }); + + currentOrigin = 'https://provider.example'; + currentToken = 'attacker-controlled-token'; + await client.prepare(); + + assert.equal(originReads, 1); + assert.equal(tokenReads, 1); + assert.equal(calls[0]?.path, '/__control/prepare'); + assert.equal( + calls[0]?.headers.get('x-iptvnator-performance-token'), + TOKEN + ); + }); + + it('rejects invalid runtime token values before issuing I/O', () => { + let calls = 0; + const fetchImpl: typeof fetch = async () => { + calls += 1; + return new Response(); + }; + + for (const token of [ + null, + undefined, + 42, + {}, + ' ', + 'line\r\nbreak', + 'x'.repeat(257), + ]) { + assert.throws( + () => + createXtreamControlClient({ + origin: 'http://127.0.0.1:43123', + token, + fetchImpl, + } as unknown as Parameters< + typeof createXtreamControlClient + >[0]), + /non-empty control token/ + ); + } + assert.equal(calls, 0); + }); +}); + +interface RecordedCall { + readonly body: Record | null; + readonly headers: Headers; + readonly method: string; + readonly path: string; + readonly redirect: RequestRedirect | undefined; +} + +function fakeFetch(calls: RecordedCall[]): typeof fetch { + return async (input, init) => { + const url = new URL( + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url + ); + const method = init?.method ?? 'GET'; + const body = + typeof init?.body === 'string' + ? (JSON.parse(init.body) as Record) + : null; + calls.push({ + body, + headers: new Headers(init?.headers), + method, + path: url.pathname, + redirect: init?.redirect, + }); + if (url.pathname.endsWith('/prepare')) { + return responseAt(MANIFEST, 200, url.href); + } + if (url.pathname.endsWith('/reset')) { + return responseAt( + { + epoch: MANIFEST.epoch, + mode: body?.['mode'], + }, + 200, + url.href + ); + } + return responseAt(STATE, 200, url.href); + }; +} + +function responseAt(value: unknown, status: number, url: string): Response { + const response = Response.json(value, { status }); + Object.defineProperty(response, 'url', { value: url }); + return response; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-control-client.ts b/apps/electron-backend-e2e/src/performance/xtream-control-client.ts new file mode 100644 index 000000000..72184cdaa --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-control-client.ts @@ -0,0 +1,273 @@ +import type { + XtreamControlState, + XtreamPerformanceManifest, + XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { + assertXtreamArtifactRedacted, + assertXtreamManifest, + assertXtreamManifestIdentity, +} from './xtream-diagnostic-artifacts'; +import { + parseXtreamControlState, + parseXtreamManifest, +} from './xtream-control-state-schema'; +import { assertXtreamScenarioObservations } from './xtream-control-observation-contract'; + +export { + assertXtreamArtifactRedacted, + assertXtreamManifest, + assertXtreamManifestIdentity, + parseXtreamControlState, + parseXtreamManifest, +}; + +export interface XtreamControlClientOptions { + readonly fetchImpl?: typeof fetch; + readonly origin: string; + readonly token: string; +} + +export interface XtreamControlClient { + prepare(): Promise; + resetAll(): Promise; + resetObservations(): Promise; + state(): Promise; +} + +export function createXtreamControlClient( + options: XtreamControlClientOptions +): XtreamControlClient { + let origin: string; + let token: string; + let configuredFetch: typeof fetch | undefined; + try { + origin = options.origin; + token = options.token; + configuredFetch = options.fetchImpl; + } catch { + throw new Error('Xtream benchmark control options are invalid'); + } + assertXtreamMockOrigin(origin); + if (!isValidControlToken(token)) { + throw new Error('Xtream benchmark requires a non-empty control token'); + } + const fetchImpl = configuredFetch ?? globalThis.fetch; + const request = async ( + path: '/__control/prepare' | '/__control/reset' | '/__control/state', + label: string, + body?: Readonly> + ): Promise => { + let response: Response; + const requestUrl = `${origin}${path}`; + try { + response = await fetchImpl(requestUrl, { + method: body ? 'POST' : 'GET', + redirect: 'error', + headers: { + 'x-iptvnator-performance-token': token, + ...(body ? { 'content-type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + } catch { + throw new Error(`Xtream control ${label} transport failed`); + } + const metadata = snapshotControlResponse(response, label); + if (!metadata.ok) { + throw new Error( + `Xtream control ${label} failed with HTTP ${metadata.status}` + ); + } + if (metadata.redirected || metadata.url !== requestUrl) { + throw new Error( + `Xtream control ${label} redirect or origin rejected` + ); + } + try { + return (await Reflect.apply( + metadata.json, + response, + [] + )) as unknown; + } catch { + throw new Error(`Xtream ${label} invalid control response`); + } + }; + const reset = async (mode: 'all' | 'observations'): Promise => { + const value = await request('/__control/reset', `${mode} reset`, { + mode, + }); + try { + if (!isRecordWithKeys(value, ['epoch', 'mode'])) { + throw new Error('invalid reset response'); + } + const epoch = Reflect.get(value, 'epoch'); + const responseMode = Reflect.get(value, 'mode'); + if (!isPositiveSafeInteger(epoch) || responseMode !== mode) { + throw new Error('invalid reset response'); + } + } catch { + throw new Error(`Xtream ${mode} reset invalid control response`); + } + }; + return Object.freeze({ + async prepare() { + const value = await request('/__control/prepare', 'prepare', { + scenario: 'performance-100k', + }); + try { + return parseXtreamManifest(value); + } catch { + throw new Error('Xtream prepare invalid control response'); + } + }, + resetAll: () => reset('all'), + resetObservations: () => reset('observations'), + async state() { + const value = await request('/__control/state', 'state'); + try { + return parseXtreamControlState(value); + } catch { + throw new Error('Xtream state invalid control response'); + } + }, + }); +} + +export function assertXtreamMockOrigin(value: unknown): void { + if (typeof value !== 'string') throw invalidOrigin(); + let url: URL; + try { + url = new URL(value); + } catch { + throw invalidOrigin(); + } + if ( + url.protocol !== 'http:' || + url.hostname !== '127.0.0.1' || + url.port.length === 0 || + url.username.length > 0 || + url.password.length > 0 || + url.pathname !== '/' || + url.search.length > 0 || + url.hash.length > 0 || + value !== url.origin + ) { + throw invalidOrigin(); + } +} + +function snapshotControlResponse( + response: Response, + label: string +): { + readonly json: () => Promise; + readonly ok: boolean; + readonly redirected: boolean; + readonly status: number; + readonly url: string; +} { + try { + const json = Reflect.get(response, 'json') as unknown; + const ok = Reflect.get(response, 'ok') as unknown; + const redirected = Reflect.get(response, 'redirected') as unknown; + const status = Reflect.get(response, 'status') as unknown; + const url = Reflect.get(response, 'url') as unknown; + if ( + typeof json !== 'function' || + typeof ok !== 'boolean' || + typeof redirected !== 'boolean' || + !Number.isSafeInteger(status) || + Number(status) < 100 || + Number(status) > 599 || + typeof url !== 'string' + ) { + throw new Error('invalid response metadata'); + } + return { + json: json as () => Promise, + ok, + redirected, + status: Number(status), + url, + }; + } catch { + throw new Error(`Xtream control ${label} invalid response metadata`); + } +} + +export function assertXtreamControlReadyAfterObservationReset( + input: unknown, + expectedManifest: XtreamPerformanceManifest +): asserts input is XtreamControlState { + try { + const state = parseXtreamControlState(input); + assertPreparedState(state, expectedManifest); + if (state.occurrences.length !== 0 || state.ledger.length !== 0) { + throw new Error('observations remain'); + } + } catch { + throw new Error( + 'invalid Xtream mock readiness after observation reset' + ); + } +} + +export function assertXtreamControlStateForScenario( + scenarioId: XtreamScenarioId, + input: unknown, + expectedManifest: XtreamPerformanceManifest +): asserts input is XtreamControlState { + try { + const state = parseXtreamControlState(input); + assertPreparedState(state, expectedManifest); + assertXtreamScenarioObservations(scenarioId, state); + } catch { + throw new Error('invalid Xtream mock observations'); + } +} + +function assertPreparedState( + state: XtreamControlState, + manifest: XtreamPerformanceManifest +): void { + if ( + state.prepared === null || + state.epoch !== manifest.epoch || + state.prepared.epoch !== manifest.epoch || + state.rules.length !== 0 || + state.heldCount !== 0 || + state.heldIds.length !== 0 + ) { + throw new Error('state residue'); + } + assertXtreamManifestIdentity(manifest, state.prepared); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isRecordWithKeys( + value: unknown, + keys: readonly string[] +): value is Record { + return ( + isRecord(value) && + Object.keys(value).length === keys.length && + keys.every((key) => Object.prototype.hasOwnProperty.call(value, key)) + ); +} + +function isPositiveSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +function isValidControlToken(value: unknown): value is string { + return typeof value === 'string' && /^[\x21-\x7e]{1,256}$/u.test(value); +} + +function invalidOrigin(): Error { + return new Error('mock must use the exact Xtream HTTP loopback origin'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-control-observation-contract.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-control-observation-contract.spec.ts new file mode 100644 index 000000000..5953c0e87 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-control-observation-contract.spec.ts @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_SCENARIO_ID, + type XtreamControlState, + type XtreamPerformanceManifest, +} from './xtream-benchmark-contract'; +import { assertXtreamControlStateForScenario } from './xtream-control-client'; + +const MANIFEST: XtreamPerformanceManifest = { + epoch: 1, + scenario: 'performance-100k', + seed: 91_001, + counts: { + categories: { live: 60, vod: 20, series: 20, total: 100 }, + items: { + live: 60_000, + vod: 20_000, + series: 20_000, + total: 100_000, + }, + }, + bytes: 12_345_678, + catalogSha256: + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', +}; + +const FULL_ACTIONS = [ + 'get_account_info', + 'get_live_categories', + 'get_vod_categories', + 'get_series_categories', + 'get_live_streams', + 'get_vod_streams', + 'get_series', +] as const; + +describe('Xtream mock observation contract', () => { + it('models exact account-info multiplicity for every measured window', () => { + assert.doesNotThrow(() => + validate( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + controlState(FULL_ACTIONS, 2) + ) + ); + assert.doesNotThrow(() => + validate( + XTREAM_SCENARIO_ID.CANCEL_IMPORT, + controlState(FULL_ACTIONS.slice(0, 5), 2) + ) + ); + for (const scenarioId of [ + XTREAM_SCENARIO_ID.REFRESH_LARGE, + XTREAM_SCENARIO_ID.BACKGROUND_UI, + ] as const) { + assert.doesNotThrow(() => + validate(scenarioId, controlState(FULL_ACTIONS, 2, true)) + ); + } + assert.doesNotThrow(() => + validate(XTREAM_SCENARIO_ID.DELETE_LARGE, controlState([], 0)) + ); + }); + + it('rejects account-info multiplicity from a different scenario window', () => { + for (const [scenarioId, state] of [ + [ + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + controlState(FULL_ACTIONS, 1), + ], + [ + XTREAM_SCENARIO_ID.CANCEL_IMPORT, + controlState(FULL_ACTIONS.slice(0, 5), 1), + ], + [XTREAM_SCENARIO_ID.REFRESH_LARGE, controlState(FULL_ACTIONS, 1)], + [XTREAM_SCENARIO_ID.BACKGROUND_UI, controlState(FULL_ACTIONS, 1)], + ] as const) { + assert.throws( + () => validate(scenarioId, state), + /invalid Xtream mock observations/ + ); + } + + for (const scenarioId of [ + XTREAM_SCENARIO_ID.REFRESH_LARGE, + XTREAM_SCENARIO_ID.BACKGROUND_UI, + ] as const) { + assert.throws( + () => validate(scenarioId, controlState(FULL_ACTIONS, 2)), + /invalid Xtream mock observations/ + ); + } + }); + + it('allows account overlap but requires complete route-account causality', () => { + const exact = controlState(FULL_ACTIONS, 2); + const duplicateArrival = { + ...exact, + ledger: exact.ledger.map((entry) => + entry.action === 'get_account_info' && + entry.occurrence === 2 && + entry.status === 'responded' + ? { ...entry, status: 'arrived' } + : entry + ), + }; + const overlappingAccounts = { + ...exact, + ledger: exact.ledger.map((entry) => + entry.action === 'get_account_info' && + entry.occurrence === 1 && + entry.status === 'responded' + ? { ...entry, atMs: 25 } + : entry + ), + }; + const routeAccountAfterCategories = { + ...exact, + ledger: exact.ledger.map((entry) => + entry.action === 'get_account_info' && + entry.occurrence === 2 && + entry.status === 'responded' + ? { ...entry, atMs: 35 } + : entry + ), + }; + const reversedAccountStarts = { + ...exact, + ledger: exact.ledger.map((entry) => + entry.action === 'get_account_info' && + entry.occurrence === 2 && + entry.status === 'arrived' + ? { ...entry, atMs: 9 } + : entry + ), + }; + + assert.doesNotThrow(() => + validate( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + overlappingAccounts + ) + ); + for (const invalid of [ + duplicateArrival, + reversedAccountStarts, + routeAccountAfterCategories, + ]) { + assert.throws( + () => + validate(XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, invalid), + /invalid Xtream mock observations/ + ); + } + }); +}); + +function validate( + scenarioId: Parameters[0], + state: XtreamControlState +): void { + assertXtreamControlStateForScenario(scenarioId, state, MANIFEST); +} + +function controlState( + actions: readonly string[], + accountCount: number, + routeAccountAfterContent = false +): XtreamControlState { + const counts = new Map( + actions.map((action) => [ + action, + action === 'get_account_info' ? accountCount : 1, + ]) + ); + let order = 1; + return { + epoch: MANIFEST.epoch, + prepared: MANIFEST, + rules: [], + heldIds: [], + heldCount: 0, + occurrences: [...counts].map(([action, count]) => ({ + scenario: 'performance-100k', + transport: 'direct', + action, + categoryId: 'all', + count, + })), + ledger: [...counts].flatMap(([action, count]) => + Array.from({ length: count }, (_, index) => { + const routeAccount = + routeAccountAfterContent && + action === 'get_account_info' && + index === 1; + const arrived = routeAccount ? 1_000 : order++ * 10; + return [ + { + scenario: 'performance-100k', + transport: 'direct', + action, + categoryId: 'all', + epoch: MANIFEST.epoch, + occurrence: index + 1, + status: 'arrived', + atMs: arrived, + }, + { + scenario: 'performance-100k', + transport: 'direct', + action, + categoryId: 'all', + epoch: MANIFEST.epoch, + occurrence: index + 1, + status: 'responded', + atMs: arrived + 1, + }, + ]; + }).flat() + ), + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-control-observation-contract.ts b/apps/electron-backend-e2e/src/performance/xtream-control-observation-contract.ts new file mode 100644 index 000000000..5d43a04c3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-control-observation-contract.ts @@ -0,0 +1,204 @@ +import { + XTREAM_SCENARIO_ID, + type XtreamControlState, + type XtreamLedgerEntry, + type XtreamOccurrence, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; + +const FULL_ACTIONS = [ + 'get_account_info', + 'get_live_categories', + 'get_vod_categories', + 'get_series_categories', + 'get_live_streams', + 'get_vod_streams', + 'get_series', +] as const; +const CATEGORY_ACTIONS = FULL_ACTIONS.slice(1, 4); + +interface ExpectedAction { + readonly action: string; + readonly count: number; +} + +interface RequestLifecycle { + readonly arrived: number; + readonly responded: number; +} + +export function assertXtreamScenarioObservations( + scenarioId: XtreamScenarioId, + state: XtreamControlState +): void { + const expected = expectedActions(scenarioId); + if ( + state.occurrences.length !== expected.length || + state.ledger.length !== + expected.reduce((total, entry) => total + entry.count * 2, 0) + ) { + throw new Error('action cardinality'); + } + + const lifecycle = new Map(); + for (const expectedAction of expected) { + const occurrences = state.occurrences.filter( + (entry) => entry.action === expectedAction.action + ); + const entries = state.ledger.filter( + (entry) => entry.action === expectedAction.action + ); + if ( + occurrences.length !== 1 || + !isExpectedOccurrence(occurrences[0], expectedAction.count) || + entries.length !== expectedAction.count * 2 + ) { + throw new Error('action occurrence'); + } + + const requests = Array.from( + { length: expectedAction.count }, + (_, index) => requestLifecycle(entries, state.epoch, index + 1) + ); + assertOccurrenceStartOrder(requests); + lifecycle.set(expectedAction.action, requests); + } + if ( + state.occurrences.some( + (entry) => !expected.some(({ action }) => action === entry.action) + ) || + state.ledger.some( + (entry) => !expected.some(({ action }) => action === entry.action) + ) + ) { + throw new Error('unexpected action'); + } + assertActionOrder(scenarioId, expected, lifecycle); +} + +function expectedActions( + scenarioId: XtreamScenarioId +): readonly ExpectedAction[] { + if (scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) return []; + const actions = + scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT + ? FULL_ACTIONS.slice(0, 5) + : FULL_ACTIONS; + return actions.map((action) => ({ + action, + count: action === 'get_account_info' ? 2 : 1, + })); +} + +function requestLifecycle( + entries: readonly XtreamLedgerEntry[], + epoch: number, + occurrence: number +): RequestLifecycle { + const requestEntries = entries.filter( + (entry) => entry.occurrence === occurrence + ); + const arrived = requestEntries.find((entry) => entry.status === 'arrived'); + const responded = requestEntries.find( + (entry) => entry.status === 'responded' + ); + if ( + requestEntries.length !== 2 || + !isExpectedLedger(arrived, epoch, occurrence) || + !isExpectedLedger(responded, epoch, occurrence) || + arrived.status !== 'arrived' || + responded.status !== 'responded' || + arrived.atMs > responded.atMs + ) { + throw new Error('action lifecycle'); + } + return { arrived: arrived.atMs, responded: responded.atMs }; +} + +function assertOccurrenceStartOrder( + requests: readonly RequestLifecycle[] +): void { + for (let index = 1; index < requests.length; index += 1) { + if ( + (requests[index - 1]?.arrived ?? Number.POSITIVE_INFINITY) > + (requests[index]?.arrived ?? Number.NEGATIVE_INFINITY) + ) { + throw new Error('occurrence start order'); + } + } +} + +function assertActionOrder( + scenarioId: XtreamScenarioId, + expected: readonly ExpectedAction[], + lifecycle: ReadonlyMap +): void { + if (expected.length === 0) return; + const accounts = lifecycle.get('get_account_info'); + const routeHydratesAfterImport = + scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE || + scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI; + const account = routeHydratesAfterImport ? accounts?.[0] : accounts?.at(-1); + const liveCategory = lifecycle.get(CATEGORY_ACTIONS[0])?.[0]; + const vodCategory = lifecycle.get(CATEGORY_ACTIONS[1])?.[0]; + const seriesCategory = lifecycle.get(CATEGORY_ACTIONS[2])?.[0]; + const live = lifecycle.get('get_live_streams')?.[0]; + if ( + !account || + !liveCategory || + !vodCategory || + !seriesCategory || + account.responded > + Math.min( + liveCategory.arrived, + vodCategory.arrived, + seriesCategory.arrived + ) || + !live || + Math.max( + liveCategory.responded, + vodCategory.responded, + seriesCategory.responded + ) > live.arrived + ) { + throw new Error('action order'); + } + const vod = lifecycle.get('get_vod_streams')?.[0]; + const series = lifecycle.get('get_series')?.[0]; + if ( + (vod && live.responded > vod.arrived) || + (series && (!vod || vod.responded > series.arrived)) || + (routeHydratesAfterImport && + (!series || + !accounts?.[1] || + series.responded > accounts[1].arrived)) + ) { + throw new Error('content order'); + } +} + +function isExpectedOccurrence( + value: XtreamOccurrence | undefined, + count: number +): boolean { + return ( + value?.scenario === 'performance-100k' && + value.transport === 'direct' && + value.categoryId === 'all' && + value.count === count + ); +} + +function isExpectedLedger( + value: XtreamLedgerEntry | undefined, + epoch: number, + occurrence: number +): value is XtreamLedgerEntry { + return ( + value?.scenario === 'performance-100k' && + value.transport === 'direct' && + value.categoryId === 'all' && + value.epoch === epoch && + value.occurrence === occurrence + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-control-state-schema.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-control-state-schema.spec.ts new file mode 100644 index 000000000..8c2a1ecc9 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-control-state-schema.spec.ts @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import type { + XtreamControlState, + XtreamPerformanceManifest, +} from './xtream-benchmark-contract'; +import { parseXtreamControlState } from './xtream-control-state-schema'; + +const MANIFEST: XtreamPerformanceManifest = { + epoch: 1, + scenario: 'performance-100k', + seed: 91_001, + counts: { + categories: { live: 60, vod: 20, series: 20, total: 100 }, + items: { + live: 60_000, + vod: 20_000, + series: 20_000, + total: 100_000, + }, + }, + bytes: 100, + catalogSha256: + 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789', +}; +const OCCURRENCE = { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_account_info', + categoryId: 'all', + count: 1, +}; +const LEDGER = { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_account_info', + categoryId: 'all', + epoch: 1, + occurrence: 1, + status: 'arrived', + atMs: 1, +}; + +describe('Xtream control-state exact schema', () => { + it('requires exact occurrence and ledger records with known lifecycle states', () => { + const populated = state(); + assert.doesNotThrow(() => parseXtreamControlState(populated)); + + for (const invalid of [ + { + ...populated, + occurrences: [{ ...OCCURRENCE, injected: true }], + }, + { + ...populated, + ledger: [{ ...LEDGER, injected: true }], + }, + { + ...populated, + ledger: [{ ...LEDGER, status: 'completed' }], + }, + ]) { + assert.throws( + () => parseXtreamControlState(invalid), + /^Error: invalid control response$/ + ); + } + }); + + it('returns a stable deep-frozen snapshot of nested accessor values', () => { + let epoch = 1; + let manifestBytes = MANIFEST.bytes; + let occurrenceAction = OCCURRENCE.action; + let ledgerStatus = LEDGER.status; + const input = withAccessor( + { + ...state(), + prepared: withAccessor( + { ...MANIFEST }, + 'bytes', + () => manifestBytes + ), + occurrences: [ + withAccessor( + { ...OCCURRENCE }, + 'action', + () => occurrenceAction + ), + ], + ledger: [ + withAccessor({ ...LEDGER }, 'status', () => ledgerStatus), + ], + }, + 'epoch', + () => epoch + ); + + const parsed = parseXtreamControlState(input); + epoch = 2; + manifestBytes = 0; + occurrenceAction = 'unknown'; + ledgerStatus = 'completed'; + + assert.equal(parsed.epoch, 1); + assert.equal(parsed.prepared?.bytes, MANIFEST.bytes); + assert.equal(parsed.occurrences[0]?.action, 'get_account_info'); + assert.equal(parsed.ledger[0]?.status, 'arrived'); + assert.ok(Object.isFrozen(parsed)); + assert.ok(Object.isFrozen(parsed.prepared)); + assert.ok(Object.isFrozen(parsed.prepared?.counts)); + assert.ok(Object.isFrozen(parsed.occurrences)); + assert.ok(Object.isFrozen(parsed.occurrences[0])); + assert.ok(Object.isFrozen(parsed.ledger)); + assert.ok(Object.isFrozen(parsed.ledger[0])); + }); + + it('sanitizes proxy and nested getter failures', () => { + const secret = 'must-not-escape-control-state'; + const proxy = new Proxy( + {}, + { + ownKeys() { + throw new Error(secret); + }, + } + ); + const nested = state(); + Object.defineProperty(nested.prepared, 'bytes', { + enumerable: true, + get() { + throw new Error(secret); + }, + }); + + for (const invalid of [proxy, nested]) { + assert.throws( + () => parseXtreamControlState(invalid), + (error: unknown) => + error instanceof Error && + error.message === 'invalid control response' && + !error.message.includes(secret) + ); + } + }); +}); + +function state(): XtreamControlState { + return { + epoch: 1, + prepared: structuredClone(MANIFEST), + rules: [], + heldIds: [], + heldCount: 0, + occurrences: [{ ...OCCURRENCE }], + ledger: [{ ...LEDGER }], + }; +} + +function withAccessor( + value: T, + key: K, + get: () => T[K] +): T { + Object.defineProperty(value, key, { enumerable: true, get }); + return value; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-control-state-schema.ts b/apps/electron-backend-e2e/src/performance/xtream-control-state-schema.ts new file mode 100644 index 000000000..4756eb6e6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-control-state-schema.ts @@ -0,0 +1,314 @@ +import type { + XtreamCatalogCounts, + XtreamControlState, + XtreamLedgerEntry, + XtreamOccurrence, + XtreamPerformanceManifest, +} from './xtream-benchmark-contract'; + +const LIFECYCLE_STATUSES = [ + 'aborted', + 'arrived', + 'blocked', + 'delayed', + 'responded', +] as const; +const STATE_KEYS = [ + 'epoch', + 'prepared', + 'rules', + 'heldIds', + 'heldCount', + 'occurrences', + 'ledger', +] as const; +const MANIFEST_KEYS = [ + 'epoch', + 'scenario', + 'seed', + 'counts', + 'bytes', + 'catalogSha256', +] as const; +const MAX_CONTROL_ARRAY_LENGTH = 4_096; + +export function parseXtreamControlState(value: unknown): XtreamControlState { + try { + const input = snapshotExactRecord(value, STATE_KEYS); + const heldIds = snapshotArray(input['heldIds']); + const occurrences = snapshotArray(input['occurrences']).map( + snapshotOccurrence + ); + const ledger = snapshotArray(input['ledger']).map(snapshotLedger); + const rules = snapshotArray(input['rules']).map((entry) => + snapshotJsonValue(entry, new WeakSet()) + ); + if ( + !isPositiveSafeInteger(input['epoch']) || + !heldIds.every((entry) => typeof entry === 'string') || + !isNonNegativeSafeInteger(input['heldCount']) || + input['heldCount'] !== heldIds.length + ) { + invalid(); + } + const prepared = + input['prepared'] === null + ? null + : snapshotManifest(input['prepared']); + return freezeDeep({ + epoch: input['epoch'], + prepared, + rules, + heldIds: heldIds as string[], + heldCount: input['heldCount'], + occurrences, + ledger, + }) as XtreamControlState; + } catch { + invalid(); + } +} + +export function parseXtreamManifest(value: unknown): XtreamPerformanceManifest { + try { + return freezeDeep(snapshotManifest(value)); + } catch { + throw invalidManifest(); + } +} + +export function assertXtreamManifest( + value: unknown +): asserts value is XtreamPerformanceManifest { + parseXtreamManifest(value); +} + +export function assertXtreamManifestIdentity( + expected: XtreamPerformanceManifest, + actual: unknown +): asserts actual is XtreamPerformanceManifest { + const expectedSnapshot = parseXtreamManifest(expected); + const actualSnapshot = parseXtreamManifest(actual); + const identity = (manifest: XtreamPerformanceManifest) => [ + manifest.scenario, + manifest.seed, + manifest.counts, + manifest.bytes, + manifest.catalogSha256, + ]; + if ( + JSON.stringify(identity(expectedSnapshot)) !== + JSON.stringify(identity(actualSnapshot)) + ) { + throw new Error('Xtream performance fixture identity changed'); + } +} + +function snapshotManifest(value: unknown): XtreamPerformanceManifest { + const input = snapshotExactRecord(value, MANIFEST_KEYS); + const counts = snapshotExactRecord(input['counts'], [ + 'categories', + 'items', + ]); + const categories = snapshotCounts(counts['categories'], [60, 20, 20, 100]); + const items = snapshotCounts( + counts['items'], + [60_000, 20_000, 20_000, 100_000] + ); + if ( + !isPositiveSafeInteger(input['epoch']) || + input['scenario'] !== 'performance-100k' || + input['seed'] !== 91_001 || + !isPositiveSafeInteger(input['bytes']) || + typeof input['catalogSha256'] !== 'string' || + !/^[a-f0-9]{64}$/u.test(input['catalogSha256']) + ) { + throw invalidManifest(); + } + return { + epoch: input['epoch'], + scenario: input['scenario'], + seed: input['seed'], + counts: { categories, items }, + bytes: input['bytes'], + catalogSha256: input['catalogSha256'], + }; +} + +function snapshotCounts( + value: unknown, + expected: readonly number[] +): XtreamCatalogCounts { + const input = snapshotExactRecord(value, [ + 'live', + 'vod', + 'series', + 'total', + ]); + const actual = [ + input['live'], + input['vod'], + input['series'], + input['total'], + ]; + if (actual.some((entry, index) => entry !== expected[index])) { + throw invalidManifest(); + } + return { + live: input['live'] as number, + vod: input['vod'] as number, + series: input['series'] as number, + total: input['total'] as number, + }; +} + +function snapshotOccurrence(value: unknown): XtreamOccurrence { + const input = snapshotExactRecord(value, [ + 'scenario', + 'transport', + 'action', + 'categoryId', + 'count', + ]); + if ( + !['scenario', 'transport', 'action', 'categoryId'].every( + (key) => typeof input[key] === 'string' + ) || + !isPositiveSafeInteger(input['count']) + ) { + invalid(); + } + return input as unknown as XtreamOccurrence; +} + +function snapshotLedger(value: unknown): XtreamLedgerEntry { + const input = snapshotExactRecord(value, [ + 'scenario', + 'transport', + 'action', + 'categoryId', + 'epoch', + 'occurrence', + 'status', + 'atMs', + ]); + if ( + !['scenario', 'transport', 'action', 'categoryId'].every( + (key) => typeof input[key] === 'string' + ) || + typeof input['status'] !== 'string' || + !LIFECYCLE_STATUSES.includes(input['status'] as never) || + !isPositiveSafeInteger(input['epoch']) || + !isPositiveSafeInteger(input['occurrence']) || + !isFiniteNonNegative(input['atMs']) + ) { + invalid(); + } + return input as unknown as XtreamLedgerEntry; +} + +function snapshotExactRecord( + value: unknown, + expectedKeys: readonly string[] +): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + invalid(); + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expectedKeys.length || + keys.some( + (key) => typeof key !== 'string' || !expectedKeys.includes(key) + ) + ) { + invalid(); + } + return Object.fromEntries( + expectedKeys.map((key) => [key, Reflect.get(value, key)]) + ); +} + +function snapshotArray(value: unknown): unknown[] { + if (!Array.isArray(value)) invalid(); + const length = Reflect.get(value, 'length') as unknown; + if ( + !isNonNegativeSafeInteger(length) || + length > MAX_CONTROL_ARRAY_LENGTH + ) { + invalid(); + } + const expected = new Set([ + 'length', + ...Array.from({ length }, (_, index) => String(index)), + ]); + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expected.size || + keys.some((key) => typeof key !== 'string' || !expected.has(key)) + ) { + invalid(); + } + return Array.from({ length }, (_, index) => + Reflect.get(value, String(index)) + ); +} + +function snapshotJsonValue(value: unknown, stack: WeakSet): unknown { + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) { + return value; + } + if (typeof value !== 'object') invalid(); + if (stack.has(value)) invalid(); + stack.add(value); + try { + if (Array.isArray(value)) { + return snapshotArray(value).map((entry) => + snapshotJsonValue(entry, stack) + ); + } + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string')) invalid(); + const input = snapshotExactRecord(value, keys as string[]); + return Object.fromEntries( + (keys as string[]).map((key) => [ + key, + snapshotJsonValue(input[key], stack), + ]) + ); + } finally { + stack.delete(value); + } +} + +function freezeDeep(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.values(value).forEach(freezeDeep); + Object.freeze(value); + } + return value; +} + +function isPositiveSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +function isNonNegativeSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +function isFiniteNonNegative(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function invalid(): never { + throw new Error('invalid control response'); +} + +function invalidManifest(): Error { + return new Error('invalid Xtream performance manifest'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-delete-correlation.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-delete-correlation.spec.ts new file mode 100644 index 000000000..1f0a3051c --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-delete-correlation.spec.ts @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + XTREAM_MAIN_PERFORMANCE_PHASE, + XTREAM_PRELOAD_PERFORMANCE_METHOD, + XtreamCodeActions, +} from '@iptvnator/shared/interfaces'; + +import { + createXtreamIpcMarkerCapture, + type XtreamIpcMarkerCaptureOptions, +} from './xtream-ipc-marker-events'; + +const options: XtreamIpcMarkerCaptureOptions = { + actions: Object.values(XtreamCodeActions), + mainPhases: Object.values(XTREAM_MAIN_PERFORMANCE_PHASE), + methods: Object.values(XTREAM_PRELOAD_PERFORMANCE_METHOD), + schemaVersion: 1, +}; + +describe('Xtream playlist delete correlation', () => { + it('correlates the follow-up playlist delete without fabricating an operation ID', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + + assert.equal(capture.acceptPreload(42, 100, deleteMarker()), true); + assert.deepEqual(capture.matchDatabaseRequest(deleteRequest()), { + ipcCallId: 1, + operationId: 'operation-1', + operationIdUnavailableReason: null, + sourceEpochMs: 100, + }); + assert.equal( + capture.acceptPreload( + 42, + 102, + deleteMarker({ + boundary: 'end', + outcome: 'success', + sourceEpochMs: 101, + }) + ), + true + ); + + const followUp = deleteMarker({ + ipcCallId: 2, + operationId: null, + sourceEpochMs: 103, + }); + assert.equal(capture.acceptPreload(42, 104, followUp), true); + assert.deepEqual( + capture.matchDatabaseRequest( + deleteRequest({ operationId: undefined }) + ), + { + ipcCallId: 2, + operationId: null, + operationIdUnavailableReason: null, + sourceEpochMs: 103, + } + ); + assert.equal( + capture.acceptPreload( + 42, + 106, + deleteMarker({ + ...followUp, + boundary: 'end', + outcome: 'success', + sourceEpochMs: 105, + }) + ), + true + ); + assert.deepEqual(capture.stop().invalidReasons, []); + }); + + it('quarantines overlapping deletes that share the explicit no-operation-ID key', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + const first = deleteMarker({ operationId: null }); + + assert.equal(capture.acceptPreload(42, 100, first), true); + assert.equal( + capture.acceptPreload( + 42, + 101, + deleteMarker({ + ipcCallId: 2, + operationId: null, + sourceEpochMs: 101, + }) + ), + false + ); + assert.deepEqual( + capture.matchDatabaseRequest(deleteRequest({ operationId: null })), + { + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: + 'xtream-preload-performance-marker-ambiguous', + sourceEpochMs: null, + } + ); + assert.equal(capture.stop().quarantinedKeyCount, 1); + }); +}); + +function deleteMarker(overrides: Record = {}) { + return { + action: null, + boundary: 'start', + categoryType: null, + contentType: null, + ipcCallId: 1, + itemCount: null, + method: 'dbDeletePlaylist', + operationId: 'operation-1', + outcome: null, + playlistId: 'playlist-1', + schemaVersion: 1, + sessionId: null, + sourceEpochMs: 100, + ...overrides, + }; +} + +function deleteRequest( + payloadOverrides: Record = {} +): Record { + return { + operation: 'DB_DELETE_PLAYLIST', + payload: { + operationId: 'operation-1', + playlistId: 'playlist-1', + ...payloadOverrides, + }, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-descriptor-integrity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-descriptor-integrity.spec.ts new file mode 100644 index 000000000..788cec191 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-descriptor-integrity.spec.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, + type XtreamIterationDefinition, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { iteration } from './xtream-benchmark-report.fixtures'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; +import type { + XtreamCancellationEvidence, + XtreamRawIterationResult, +} from './xtream-summary-contract'; + +describe('Xtream raw descriptor integrity', () => { + it('rejects symbol and non-enumerable top-level extras', () => { + for (const key of [Symbol('extra'), 'hiddenExtra'] as const) { + const raw = createIteration(); + Object.defineProperty(raw, key, { value: true }); + + assert.throws( + () => assertXtreamIterationResult(raw), + /invalid Xtream iteration/ + ); + } + }); + + it('rejects symbol extras on cancellation records', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const evidence = cancellationEvidence(raw); + Object.defineProperty(evidence.timeline[0], Symbol('extra'), { + value: true, + }); + + assert.throws( + () => assertXtreamIterationResult(raw), + /invalid Xtream iteration/ + ); + }); + + it('rejects a cancellation timeline accessor without invoking it', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const evidence = cancellationEvidence(raw); + let invocations = 0; + const accessorEvidence = {}; + Object.defineProperty(accessorEvidence, 'timeline', { + enumerable: true, + get() { + invocations += 1; + return evidence.timeline; + }, + }); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellationEvidence: + accessorEvidence as XtreamCancellationEvidence, + }), + /invalid Xtream iteration/ + ); + assert.equal(invocations, 0); + }); + + it('rejects cancellation record accessors without invoking them', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const evidence = cancellationEvidence(raw); + const original = evidence.timeline[0]; + let invocations = 0; + const accessorRecord = { ...original }; + Object.defineProperty(accessorRecord, 'epochMs', { + enumerable: true, + get() { + invocations += 1; + return original.epochMs; + }, + }); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellationEvidence: { + timeline: [ + accessorRecord, + ...evidence.timeline.slice(1), + ], + }, + }), + /invalid Xtream iteration/ + ); + assert.equal(invocations, 0); + }); + + it('rejects every own cancellation timeline key beyond dense indices and length', () => { + for (const extraKey of [ + Symbol('extra'), + 'hiddenExtra', + 'map', + ] as const) { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const evidence = cancellationEvidence(raw); + const timeline = [...evidence.timeline]; + Object.defineProperty(timeline, extraKey, { + value: + extraKey === 'map' + ? Array.prototype.map.bind(timeline) + : true, + }); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellationEvidence: { timeline }, + }), + /invalid Xtream iteration/ + ); + } + }); +}); + +function cancellationEvidence( + raw: XtreamRawIterationResult +): XtreamCancellationEvidence { + assert.ok(raw.cancellationEvidence); + return raw.cancellationEvidence; +} + +function createIteration( + scenarioId: XtreamScenarioId = XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE +): XtreamRawIterationResult { + const definition: XtreamIterationDefinition = { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId, + }; + return iteration(definition, 1); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.spec.ts new file mode 100644 index 000000000..669185815 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.spec.ts @@ -0,0 +1,392 @@ +import assert from 'node:assert/strict'; +import { + mkdir, + mkdtemp, + rm, + symlink, + unlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures'; +import { + assertXtreamDiagnosticArtifactEvidence, + validateXtreamDiagnosticArtifacts, + type XtreamDiagnosticCapture, +} from './xtream-diagnostic-artifacts'; + +const HEAP_SNAPSHOT = { + edges: [0, 0, 0], + nodes: [0, 0, 1, 0, 1, 0], + snapshot: { + meta: { + edge_fields: ['type', 'name_or_index', 'to_node'], + edge_types: [['context'], 'string_or_number', 'node'], + node_fields: [ + 'type', + 'name', + 'id', + 'self_size', + 'edge_count', + 'detachedness', + ], + node_types: [ + ['hidden'], + 'string', + 'number', + 'number', + 'number', + 'number', + ], + }, + }, + strings: ['', 'root'], +}; +const RENDERER_TRACE = { + traceEvents: [{ name: 'xtream-diagnostic', ph: 'X', ts: 1 }], +}; +const directories: string[] = []; + +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +describe('Xtream diagnostic artifacts', () => { + it('accepts one complete main, renderer, and database-worker artifact set', async () => { + const directory = await createDirectory('valid'); + await writeValidArtifacts(directory); + + const evidence = await validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ); + + assertXtreamDiagnosticArtifactEvidence(evidence); + assert.equal(evidence.artifacts.length, 7); + assert.match(evidence.directorySha256, /^[a-f0-9]{64}$/u); + assert.ok( + evidence.artifacts.every( + ({ bytes, filename, label, sha256 }) => + bytes > 0 && + filename.length > 0 && + label.length > 0 && + /^[a-f0-9]{64}$/u.test(sha256) + ) + ); + assert.ok(Object.isFrozen(evidence)); + assert.ok(Object.isFrozen(evidence.artifacts)); + assert.ok(evidence.artifacts.every(Object.isFrozen)); + assert.throws( + () => + assertXtreamDiagnosticArtifactEvidence( + structuredClone(evidence) + ), + /validated Xtream diagnostic artifact evidence/ + ); + }); + + it('fails closed for malformed or missing process artifacts', async () => { + const directory = await createDirectory('invalid'); + await writeValidArtifacts(directory); + await writeFile(join(directory, 'renderer.trace.json'), '{'); + + await assert.rejects( + () => + validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ), + /xtream-diagnostic-artifact-invalid:renderer-trace/ + ); + + await writeFile( + join(directory, 'renderer.trace.json'), + JSON.stringify(RENDERER_TRACE) + ); + const missing: XtreamDiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + cpuProfilePath: null, + }, + }; + await assert.rejects( + () => validateXtreamDiagnosticArtifacts(missing, directory), + /xtream-diagnostic-artifact-missing:main-cpu-profile/ + ); + }); + + it('rejects escaped paths and symlinks', async () => { + const directory = await createDirectory('contained'); + const outside = await createDirectory('outside'); + await writeValidArtifacts(directory); + await writeFile( + join(outside, 'main.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ); + + const escaped: XtreamDiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + cpuProfilePath: join(outside, 'main.cpuprofile'), + }, + }; + await assert.rejects( + () => validateXtreamDiagnosticArtifacts(escaped, directory), + /xtream-diagnostic-artifact-path-invalid:main-cpu-profile/ + ); + + const rendererProfile = join(directory, 'renderer.cpuprofile'); + await unlink(rendererProfile); + await symlink(join(outside, 'main.cpuprofile'), rendererProfile); + await assert.rejects( + () => + validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ), + /xtream-diagnostic-artifact-path-invalid:renderer-cpu-profile/ + ); + + const symlinkRoot = await createDirectory('directory-symlink'); + const realDirectory = join(symlinkRoot, 'real'); + const symlinkDirectory = join(symlinkRoot, 'alias'); + await mkdir(realDirectory); + await writeValidArtifacts(realDirectory); + await symlink(realDirectory, symlinkDirectory, 'dir'); + await assert.rejects( + () => + validateXtreamDiagnosticArtifacts( + capture(symlinkDirectory), + symlinkDirectory + ), + /xtream-diagnostic-artifact-path-invalid:iteration-directory/ + ); + }); + + it('requires exactly one database worker and no playlist-refresh worker', async () => { + const directory = await createDirectory('workers'); + await writeValidArtifacts(directory); + await writeFile( + join(directory, 'database.worker-9.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ); + + await assert.rejects( + () => + validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ), + /xtream-diagnostic-database-profile-cardinality-invalid/ + ); + + await unlink(join(directory, 'database.worker-9.cpuprofile')); + await writeFile( + join(directory, 'playlist-refresh.worker-2.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ); + await assert.rejects( + () => + validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ), + /xtream-diagnostic-playlist-profile-unexpected/ + ); + }); + + it('rejects every rogue top-level profile, snapshot, and trace artifact', async () => { + const filenames = [ + 'main-extra.cpuprofile', + 'main-extra.heapsnapshot', + 'renderer-extra.cpuprofile', + 'renderer-extra.heapsnapshot', + 'renderer-extra.trace.json', + 'rogue.cpuprofile', + 'rogue.heapsnapshot', + 'rogue.trace.json', + 'database.worker-evil.cpuprofile', + 'database.worker-4.trace.json', + 'playlist-refresh.worker-2.trace.json', + 'rogue.CPUPROFILE', + 'rogue.HeapSnapshot', + 'rogue.TRACE.JSON', + ] as const; + + for (const filename of filenames) { + const directory = await createDirectory(filename); + await writeValidArtifacts(directory); + await writeFile( + join(directory, filename), + JSON.stringify( + filename.endsWith('.cpuprofile') + ? CPU_PROFILE + : filename.endsWith('.heapsnapshot') + ? HEAP_SNAPSHOT + : RENDERER_TRACE + ) + ); + + await assert.rejects( + () => + validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ), + /xtream-diagnostic-artifact-cardinality-invalid/ + ); + } + }); + + it('rejects mixed-case rogue artifacts nested anywhere in the iteration tree', async () => { + const directory = await createDirectory('nested-rogue'); + const nested = join(directory, 'nested'); + await writeValidArtifacts(directory); + await mkdir(nested); + await writeFile( + join(nested, 'rogue.CpuProfile'), + JSON.stringify(CPU_PROFILE) + ); + + await assert.rejects( + () => + validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ), + /^Error: xtream-diagnostic-artifact-cardinality-invalid$/ + ); + }); + + it('snapshots the validated database worker ordinal before constructing paths', async () => { + const directory = await createDirectory('ordinal-getter'); + await writeValidArtifacts(directory); + let reads = 0; + const worker = { + kind: 'database.worker', + get ordinal(): number { + reads += 1; + return reads === 1 ? 4 : ('../../outside' as unknown as number); + }, + profilePath: join(directory, 'database.worker-4.cpuprofile'), + snapshotPath: join(directory, 'database.worker-4.heapsnapshot'), + }; + const getterCapture: XtreamDiagnosticCapture = { + ...capture(directory), + main: { + ...capture(directory).main, + workers: [worker], + }, + }; + + await assert.doesNotReject(() => + validateXtreamDiagnosticArtifacts(getterCapture, directory) + ); + assert.equal(reads, 1); + }); + + it('sanitizes capture Proxy failures before filesystem access', async () => { + const secret = 'must-not-escape-diagnostic-capture'; + const directory = await createDirectory('proxy-capture'); + const captureProxy = new Proxy( + {}, + { + ownKeys() { + throw new Error(secret); + }, + } + ) as XtreamDiagnosticCapture; + + await assert.rejects( + () => validateXtreamDiagnosticArtifacts(captureProxy, directory), + (error: unknown) => + error instanceof Error && + error.message === 'xtream-diagnostic-capture-invalid' && + !error.message.includes(secret) + ); + }); +}); + +function capture( + directory: string, + databaseOrdinal = 4 +): XtreamDiagnosticCapture { + return { + main: { + cpuProfilePath: join(directory, 'main.cpuprofile'), + heapSnapshotPath: join(directory, 'main.heapsnapshot'), + workers: [ + { + kind: 'database.worker', + ordinal: databaseOrdinal, + profilePath: join( + directory, + `database.worker-${databaseOrdinal}.cpuprofile` + ), + snapshotPath: join( + directory, + `database.worker-${databaseOrdinal}.heapsnapshot` + ), + }, + ], + }, + renderer: { + cpuProfilePath: join(directory, 'renderer.cpuprofile'), + heapSnapshotPath: join(directory, 'renderer.heapsnapshot'), + tracePath: join(directory, 'renderer.trace.json'), + }, + }; +} + +async function createDirectory(label: string): Promise { + const directory = await mkdtemp( + join(tmpdir(), `iptvnator-xtream-diagnostic-${label}-`) + ); + directories.push(directory); + return directory; +} + +async function writeValidArtifacts( + directory: string, + databaseOrdinal = 4 +): Promise { + await Promise.all([ + writeFile( + join(directory, 'main.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ), + writeFile( + join(directory, 'renderer.cpuprofile'), + JSON.stringify(CPU_PROFILE) + ), + writeFile( + join(directory, `database.worker-${databaseOrdinal}.cpuprofile`), + JSON.stringify(CPU_PROFILE) + ), + writeFile( + join(directory, `database.worker-${databaseOrdinal}.heapsnapshot`), + JSON.stringify(HEAP_SNAPSHOT) + ), + writeFile( + join(directory, 'main.heapsnapshot'), + JSON.stringify(HEAP_SNAPSHOT) + ), + writeFile( + join(directory, 'renderer.heapsnapshot'), + JSON.stringify(HEAP_SNAPSHOT) + ), + writeFile( + join(directory, 'renderer.trace.json'), + JSON.stringify(RENDERER_TRACE) + ), + ]); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.ts b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.ts new file mode 100644 index 000000000..a55755bf1 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.ts @@ -0,0 +1,324 @@ +import { lstat, readdir, realpath } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { + isCpuProfile, + isHeapSnapshot, + isRendererTrace, + readCpuProfileDurationMicros, +} from './initial-import-diagnostic-artifact-content-validation'; +import { + assertXtreamManifest, + assertXtreamManifestIdentity, +} from './xtream-control-state-schema'; +import { + snapshotXtreamDiagnosticCapture, + type XtreamDiagnosticCapture, + type XtreamDiagnosticWorkerArtifact, +} from './xtream-diagnostic-capture-schema'; +import { + assertXtreamArtifactRedacted, + createXtreamDiagnosticEvidenceAuthority, + serializeXtreamArtifactForPersistence, + type XtreamDiagnosticArtifactEvidence, + type XtreamValidatedArtifactContent, +} from './xtream-diagnostic-evidence'; +import { + isXtreamUnsafeFileError, + readXtreamSafeRegularFile, +} from './xtream-safe-file-read'; + +export { + assertXtreamArtifactRedacted, + assertXtreamManifest, + assertXtreamManifestIdentity, + serializeXtreamArtifactForPersistence, +}; +export type { + XtreamDiagnosticArtifactEvidence, + XtreamDiagnosticArtifactReceipt, +} from './xtream-diagnostic-evidence'; +export type { XtreamDiagnosticCapture } from './xtream-diagnostic-capture-schema'; + +interface ArtifactDescription { + readonly capturesCpuProfileDuration: boolean; + readonly filename: string; + readonly label: string; + readonly path: string | null; + readonly validate: (value: unknown) => boolean; +} + +const EVIDENCE_AUTHORITY = createXtreamDiagnosticEvidenceAuthority(); + +export async function validateXtreamDiagnosticArtifacts( + capture: XtreamDiagnosticCapture, + iterationDirectory: string +): Promise { + const snapshot = snapshotXtreamDiagnosticCapture(capture); + if (typeof iterationDirectory !== 'string') { + throw artifactPathError('iteration-directory'); + } + const directory = resolve(iterationDirectory); + let realDirectory: string; + try { + const stats = await lstat(directory); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error('unsafe-iteration-directory'); + } + realDirectory = await realpath(directory); + } catch { + throw artifactPathError('iteration-directory'); + } + + const databaseWorker = requireExactDatabaseWorker(snapshot.main.workers); + const artifacts: readonly ArtifactDescription[] = [ + artifact( + 'main.cpuprofile', + 'main-cpu-profile', + snapshot.main.cpuProfilePath, + isCpuProfile, + true + ), + artifact( + 'renderer.cpuprofile', + 'renderer-cpu-profile', + snapshot.renderer.cpuProfilePath, + isCpuProfile, + true + ), + artifact( + 'renderer.trace.json', + 'renderer-trace', + snapshot.renderer.tracePath, + isRendererTrace + ), + artifact( + 'main.heapsnapshot', + 'main-heap-snapshot', + snapshot.main.heapSnapshotPath, + isHeapSnapshot + ), + artifact( + 'renderer.heapsnapshot', + 'renderer-heap-snapshot', + snapshot.renderer.heapSnapshotPath, + isHeapSnapshot + ), + artifact( + `database.worker-${databaseWorker.ordinal}.cpuprofile`, + 'database-cpu-profile', + databaseWorker.profilePath, + isCpuProfile, + true + ), + artifact( + `database.worker-${databaseWorker.ordinal}.heapsnapshot`, + 'database-heap-snapshot', + databaseWorker.snapshotPath, + isHeapSnapshot + ), + ]; + await assertWorkerArtifactCardinality( + directory, + artifacts.map(({ filename }) => filename) + ); + const validated: XtreamValidatedArtifactContent[] = []; + for (const description of artifacts) { + validated.push( + await validateJsonArtifact(directory, realDirectory, description) + ); + } + await assertWorkerArtifactCardinality( + directory, + artifacts.map(({ filename }) => filename) + ); + return EVIDENCE_AUTHORITY.issue( + realDirectory, + validated, + databaseWorker.ordinal + ); +} + +export function assertXtreamDiagnosticArtifactEvidence( + value: unknown +): asserts value is XtreamDiagnosticArtifactEvidence { + if (!EVIDENCE_AUTHORITY.is(value)) { + throw new Error( + 'expected validated Xtream diagnostic artifact evidence' + ); + } +} + +function requireExactDatabaseWorker( + workers: readonly XtreamDiagnosticWorkerArtifact[] +): XtreamDiagnosticWorkerArtifact { + if (workers.some((worker) => worker.kind === 'playlist-refresh.worker')) { + throw new Error('xtream-diagnostic-playlist-profile-unexpected'); + } + const databaseWorkers = workers.filter( + (worker) => worker.kind === 'database.worker' + ); + const worker = databaseWorkers[0]; + if ( + databaseWorkers.length !== 1 || + workers.length !== 1 || + !worker || + !Number.isSafeInteger(worker.ordinal) || + worker.ordinal <= 0 + ) { + throw new Error( + 'xtream-diagnostic-database-profile-cardinality-invalid' + ); + } + return worker; +} + +async function assertWorkerArtifactCardinality( + directory: string, + expectedArtifacts: readonly string[] +): Promise { + const entries = await listDiagnosticArtifactEntries(directory); + if ( + entries.some((entry) => + /^playlist-refresh\.worker-\d+\.(?:cpuprofile|heapsnapshot)$/iu.test( + entry + ) + ) + ) { + throw new Error('xtream-diagnostic-playlist-profile-unexpected'); + } + for (const extension of ['cpuprofile', 'heapsnapshot']) { + if ( + entries.filter((entry) => + new RegExp( + `^database\\.worker-\\d+\\.${extension}$`, + 'iu' + ).test(entry) + ).length !== 1 + ) { + throw new Error( + `xtream-diagnostic-database-${extension === 'cpuprofile' ? 'profile' : 'snapshot'}-cardinality-invalid` + ); + } + } + if ( + entries.length !== expectedArtifacts.length || + entries.some((entry) => !expectedArtifacts.includes(entry)) || + expectedArtifacts.some((entry) => !entries.includes(entry)) + ) { + throw new Error('xtream-diagnostic-artifact-cardinality-invalid'); + } +} + +async function listDiagnosticArtifactEntries( + directory: string +): Promise { + const artifacts: string[] = []; + const walk = async (current: string): Promise => { + const entries = await readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const path = join(current, entry.name); + if ( + /(?:\.cpuprofile|\.heapsnapshot|\.trace\.json)$/iu.test( + entry.name + ) + ) { + artifacts.push(relative(directory, path)); + } + if (!entry.isDirectory() || entry.isSymbolicLink()) continue; + const stats = await lstat(path); + if (stats.isSymbolicLink()) continue; + if (!stats.isDirectory()) { + throw new Error('unsafe-artifact-tree-entry'); + } + await walk(path); + } + }; + try { + await walk(directory); + return artifacts; + } catch { + throw artifactPathError('iteration-directory'); + } +} + +async function validateJsonArtifact( + directory: string, + realDirectory: string, + description: ArtifactDescription +): Promise { + if (description.path === null) { + throw new Error( + `xtream-diagnostic-artifact-missing:${description.label}` + ); + } + const expectedPath = join(directory, description.filename); + if (resolve(description.path) !== expectedPath) { + throw artifactPathError(description.label); + } + + let content: Buffer; + let value: unknown; + try { + const snapshot = await readXtreamSafeRegularFile(expectedPath); + const realArtifactPath = snapshot.realPath; + if (!isContained(realDirectory, realArtifactPath)) { + throw new Error('escaped-artifact'); + } + content = snapshot.content; + value = JSON.parse(content.toString('utf8')) as unknown; + } catch (error) { + if ( + isXtreamUnsafeFileError(error) || + (error instanceof Error && error.message === 'escaped-artifact') + ) { + throw artifactPathError(description.label); + } + throw new Error( + `xtream-diagnostic-artifact-invalid:${description.label}` + ); + } + if (!description.validate(value)) { + throw new Error( + `xtream-diagnostic-artifact-invalid:${description.label}` + ); + } + return { + content, + cpuProfileDurationMicros: description.capturesCpuProfileDuration + ? readCpuProfileDurationMicros(value) + : null, + filename: description.filename, + label: description.label, + }; +} + +function artifact( + filename: string, + label: string, + path: string | null, + validate: (value: unknown) => boolean, + capturesCpuProfileDuration = false +): ArtifactDescription { + return { + capturesCpuProfileDuration, + filename, + label, + path, + validate, + }; +} + +function isContained(directory: string, artifactPath: string): boolean { + const relativePath = relative(directory, artifactPath); + return ( + relativePath.length > 0 && + !isAbsolute(relativePath) && + relativePath !== '..' && + !relativePath.startsWith(`..${sep}`) + ); +} + +function artifactPathError(label: string): Error { + return new Error(`xtream-diagnostic-artifact-path-invalid:${label}`); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-diagnostic-capture-schema.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-capture-schema.spec.ts new file mode 100644 index 000000000..366208c80 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-capture-schema.spec.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + snapshotXtreamDiagnosticCapture, + type XtreamDiagnosticWorkerArtifact, +} from './xtream-diagnostic-capture-schema'; + +const WORKER: XtreamDiagnosticWorkerArtifact = { + kind: 'database.worker', + ordinal: 4, + profilePath: '/tmp/database.worker-4.cpuprofile', + snapshotPath: '/tmp/database.worker-4.heapsnapshot', +}; + +describe('Xtream diagnostic capture schema', () => { + it('reads a workers accessor once before validating and freezing it', () => { + let reads = 0; + const capture = { + main: { + cpuProfilePath: '/tmp/main.cpuprofile', + heapSnapshotPath: '/tmp/main.heapsnapshot', + get workers() { + reads += 1; + return [{ ...WORKER }]; + }, + }, + renderer: { + cpuProfilePath: '/tmp/renderer.cpuprofile', + heapSnapshotPath: '/tmp/renderer.heapsnapshot', + tracePath: '/tmp/renderer.trace.json', + }, + }; + + const snapshot = snapshotXtreamDiagnosticCapture(capture); + + assert.equal(reads, 1); + assert.deepEqual(snapshot.main.workers, [WORKER]); + assert.ok(Object.isFrozen(snapshot.main.workers)); + assert.ok(Object.isFrozen(snapshot.main.workers[0])); + }); + + it('rejects the producer-impossible zero worker ordinal', () => { + assert.throws( + () => + snapshotXtreamDiagnosticCapture({ + main: { + cpuProfilePath: '/tmp/main.cpuprofile', + heapSnapshotPath: '/tmp/main.heapsnapshot', + workers: [{ ...WORKER, ordinal: 0 }], + }, + renderer: { + cpuProfilePath: '/tmp/renderer.cpuprofile', + heapSnapshotPath: '/tmp/renderer.heapsnapshot', + tracePath: '/tmp/renderer.trace.json', + }, + }), + /xtream-diagnostic-capture-invalid/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-diagnostic-capture-schema.ts b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-capture-schema.ts new file mode 100644 index 000000000..79b9ad589 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-capture-schema.ts @@ -0,0 +1,149 @@ +export interface XtreamDiagnosticWorkerArtifact { + readonly kind: string; + readonly ordinal: number; + readonly profilePath: string | null; + readonly snapshotPath: string | null; +} + +export interface XtreamDiagnosticCapture { + readonly main: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly workers: readonly XtreamDiagnosticWorkerArtifact[]; + }; + readonly renderer: { + readonly cpuProfilePath: string | null; + readonly heapSnapshotPath: string | null; + readonly tracePath: string | null; + }; +} + +export function snapshotXtreamDiagnosticCapture( + value: unknown +): XtreamDiagnosticCapture { + try { + return snapshotCapture(value); + } catch { + throw new Error('xtream-diagnostic-capture-invalid'); + } +} + +function snapshotCapture(value: unknown): XtreamDiagnosticCapture { + if (!isExactRecord(value, ['main', 'renderer'])) invalid(); + const main = value['main']; + const renderer = value['renderer']; + const workerValues = + typeof main === 'object' && main !== null + ? Reflect.get(main, 'workers') + : null; + if ( + !isExactRecord(main, [ + 'cpuProfilePath', + 'heapSnapshotPath', + 'workers', + ]) || + !isExactRecord(renderer, [ + 'cpuProfilePath', + 'heapSnapshotPath', + 'tracePath', + ]) || + !Array.isArray(workerValues) + ) { + invalid(); + } + const workers = snapshotWorkers(workerValues); + const snapshot = { + main: { + cpuProfilePath: snapshotPath(main['cpuProfilePath']), + heapSnapshotPath: snapshotPath(main['heapSnapshotPath']), + workers: Object.freeze(workers), + }, + renderer: { + cpuProfilePath: snapshotPath(renderer['cpuProfilePath']), + heapSnapshotPath: snapshotPath(renderer['heapSnapshotPath']), + tracePath: snapshotPath(renderer['tracePath']), + }, + }; + Object.freeze(snapshot.main); + Object.freeze(snapshot.renderer); + return Object.freeze(snapshot); +} + +function snapshotWorkers(value: unknown): XtreamDiagnosticWorkerArtifact[] { + if (!Array.isArray(value)) invalid(); + const length = Reflect.get(value, 'length') as unknown; + if ( + !Number.isSafeInteger(length) || + Number(length) < 0 || + Number(length) > 64 + ) { + invalid(); + } + const expected = new Set([ + 'length', + ...Array.from({ length: Number(length) }, (_, index) => String(index)), + ]); + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expected.size || + keys.some((key) => typeof key !== 'string' || !expected.has(key)) + ) { + invalid(); + } + return Array.from({ length: Number(length) }, (_, index) => + snapshotWorker(Reflect.get(value, String(index))) + ); +} + +function snapshotWorker(value: unknown): XtreamDiagnosticWorkerArtifact { + if ( + !isExactRecord(value, [ + 'kind', + 'ordinal', + 'profilePath', + 'snapshotPath', + ]) + ) { + invalid(); + } + const kind = value['kind']; + const ordinal = value['ordinal']; + const profilePath = snapshotPath(value['profilePath']); + const snapshotPathValue = snapshotPath(value['snapshotPath']); + if ( + typeof kind !== 'string' || + !Number.isSafeInteger(ordinal) || + Number(ordinal) <= 0 + ) { + invalid(); + } + return Object.freeze({ + kind, + ordinal: ordinal as number, + profilePath, + snapshotPath: snapshotPathValue, + }); +} + +function snapshotPath(value: unknown): string | null { + if (value !== null && typeof value !== 'string') invalid(); + return value as string | null; +} + +function isExactRecord( + value: unknown, + keys: readonly string[] +): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const actual = Reflect.ownKeys(value); + return ( + actual.length === keys.length && + actual.every((key) => typeof key === 'string' && keys.includes(key)) + ); +} + +function invalid(): never { + throw new Error('invalid diagnostic capture'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-diagnostic-evidence.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-evidence.spec.ts new file mode 100644 index 000000000..226102858 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-evidence.spec.ts @@ -0,0 +1,198 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + assertXtreamArtifactRedacted, + serializeXtreamArtifactForPersistence, +} from './xtream-diagnostic-artifacts'; +import type { XtreamArtifactSafetyContext } from './xtream-summary-contract'; + +const TOKEN = 'diagnostic-control-token'; +const SAFETY: XtreamArtifactSafetyContext = { + controlToken: TOKEN, + syntheticCredentials: { + password: 'performance', + username: 'performance', + }, +}; +const SAFE_ERROR = 'Xtream artifact must remain credential-safe'; + +describe('Xtream diagnostic persistence safety', () => { + it('rejects single- and double-percent-encoded synthetic credentials', () => { + for (const endpoint of [ + 'http://127.0.0.1/player_api.php?%75sername%3D%70erformance%26%70assword%3D%70erformance', + 'http://127.0.0.1/player_api.php?%2575sername%253D%2570erformance%2526%2570assword%253D%2570erformance', + 'http://%70erformance%3A%70erformance%40127.0.0.1/player_api.php', + 'http://%2570erformance%253A%2570erformance%2540127.0.0.1/player_api.php', + ]) { + assert.throws( + () => + serializeXtreamArtifactForPersistence({ endpoint }, SAFETY), + exactSafeError + ); + } + }); + + it('rejects triple encoding and fails closed beyond the decode cap', () => { + const userInfo = + 'http://performance:performance@127.0.0.1/player_api.php'; + for (const endpoint of [ + encodeTimes(userInfo, 3), + encodeTimes(userInfo, 9), + ]) { + assert.throws( + () => + serializeXtreamArtifactForPersistence({ endpoint }, SAFETY), + exactSafeError + ); + } + }); + + it('preserves benign malformed percent text', () => { + assert.equal( + serializeXtreamArtifactForPersistence( + { label: 'progress is 100% complete' }, + SAFETY + ), + '{"label":"progress is 100% complete"}' + ); + }); + + it('does not let malformed percent text mask encoded credentials', () => { + assert.throws( + () => + serializeXtreamArtifactForPersistence( + { + endpoint: + 'http://%ZZ%70erformance%3A%70erformance%40127.0.0.1/player_api.php', + }, + SAFETY + ), + exactSafeError + ); + }); + + it('does not trust an array own forEach during standalone redaction', () => { + const unsafe = [ + 'http://mock.invalid/player_api.php?username=performance', + ]; + Object.defineProperty(unsafe, 'forEach', { + value: () => undefined, + }); + + assert.throws( + () => assertXtreamArtifactRedacted(unsafe), + exactSafeError + ); + }); + + it('revalidates the serialized JSON emitted by toJSON hooks', () => { + assert.equal( + serializeXtreamArtifactForPersistence( + { + visible: true, + toJSON() { + return { visible: true }; + }, + }, + SAFETY + ), + '{"visible":true}' + ); + + for (const unsafe of [ + { + toJSON() { + return { username: 'injected-after-first-validation' }; + }, + }, + { + toJSON() { + return { + endpoint: + 'http://mock.invalid/player_api.php?token=late', + }; + }, + }, + ]) { + assert.throws( + () => serializeXtreamArtifactForPersistence(unsafe, SAFETY), + exactSafeError + ); + } + }); + + it('sanitizes ownKeys, getter, and revoked Proxy failures', () => { + const ownKeysProxy = new Proxy( + {}, + { + ownKeys() { + throw new Error(TOKEN); + }, + } + ); + const safetyProxy = new Proxy(SAFETY, { + get() { + throw new Error(TOKEN); + }, + }); + const getterValue = Object.defineProperty({}, 'visible', { + enumerable: true, + get() { + throw new Error(TOKEN); + }, + }); + const throwingJson = { + toJSON() { + throw new Error(TOKEN); + }, + }; + const revocable = Proxy.revocable({}, {}); + revocable.revoke(); + + assert.throws( + () => assertXtreamArtifactRedacted(ownKeysProxy, [TOKEN]), + exactSafeError + ); + assert.throws( + () => assertXtreamArtifactRedacted(revocable.proxy, [TOKEN]), + exactSafeError + ); + assert.throws( + () => + serializeXtreamArtifactForPersistence( + { visible: true }, + safetyProxy + ), + exactSafeError + ); + assert.throws( + () => serializeXtreamArtifactForPersistence(ownKeysProxy, SAFETY), + exactSafeError + ); + assert.throws( + () => serializeXtreamArtifactForPersistence(getterValue, SAFETY), + exactSafeError + ); + assert.throws( + () => serializeXtreamArtifactForPersistence(throwingJson, SAFETY), + exactSafeError + ); + }); +}); + +function exactSafeError(error: unknown): boolean { + return ( + error instanceof Error && + error.message === SAFE_ERROR && + !error.message.includes(TOKEN) + ); +} + +function encodeTimes(value: string, count: number): string { + let encoded = value; + for (let pass = 0; pass < count; pass += 1) { + encoded = encodeURIComponent(encoded); + } + return encoded; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-diagnostic-evidence.ts b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-evidence.ts new file mode 100644 index 000000000..d55bb2952 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-diagnostic-evidence.ts @@ -0,0 +1,266 @@ +import { createHash } from 'node:crypto'; + +import type { XtreamArtifactSafetyContext } from './xtream-summary-contract'; + +export interface XtreamDiagnosticArtifactReceipt { + readonly bytes: number; + readonly filename: string; + readonly label: string; + readonly sha256: string; +} + +declare const EVIDENCE_BRAND: unique symbol; + +export interface XtreamDiagnosticArtifactEvidence { + readonly [EVIDENCE_BRAND]: true; + readonly artifacts: readonly XtreamDiagnosticArtifactReceipt[]; + readonly directorySha256: string; + readonly profiles: XtreamDiagnosticProfileEvidence; +} + +export interface XtreamDiagnosticProfileEvidence { + readonly databaseWorker: { + readonly durationMicros: number; + readonly ordinal: number; + }; + readonly main: { + readonly durationMicros: number; + }; + readonly renderer: { + readonly durationMicros: number; + }; +} + +export interface XtreamValidatedArtifactContent { + readonly content: Uint8Array; + readonly cpuProfileDurationMicros: number | null; + readonly filename: string; + readonly label: string; +} + +interface XtreamDiagnosticEvidenceAuthority { + is(value: unknown): value is XtreamDiagnosticArtifactEvidence; + issue( + realDirectory: string, + artifacts: readonly XtreamValidatedArtifactContent[], + databaseWorkerOrdinal: number + ): XtreamDiagnosticArtifactEvidence; +} + +const SENSITIVE_KEYS = new Set([ + 'authorization', + 'credential', + 'credentials', + 'password', + 'playlisturl', + 'serverurl', + 'token', + 'username', +]); +const MAX_PERCENT_DECODE_PASSES = 8; + +export function createXtreamDiagnosticEvidenceAuthority(): XtreamDiagnosticEvidenceAuthority { + const validated = new WeakSet(); + return { + is(value): value is XtreamDiagnosticArtifactEvidence { + return ( + typeof value === 'object' && + value !== null && + validated.has(value) + ); + }, + issue(realDirectory, artifacts, databaseWorkerOrdinal) { + const receipts = Object.freeze( + artifacts.map(({ content, filename, label }) => + Object.freeze({ + bytes: content.byteLength, + filename, + label, + sha256: sha256(content), + }) + ) + ); + const duration = (label: string): number => { + const value = artifacts.find( + (artifact) => artifact.label === label + )?.cpuProfileDurationMicros; + if (!Number.isSafeInteger(value) || Number(value) <= 0) { + throw new Error('invalid diagnostic profile evidence'); + } + return Number(value); + }; + if ( + !Number.isSafeInteger(databaseWorkerOrdinal) || + databaseWorkerOrdinal <= 0 + ) { + throw new Error('invalid diagnostic worker identity'); + } + const profiles = Object.freeze({ + databaseWorker: Object.freeze({ + durationMicros: duration('database-cpu-profile'), + ordinal: databaseWorkerOrdinal, + }), + main: Object.freeze({ + durationMicros: duration('main-cpu-profile'), + }), + renderer: Object.freeze({ + durationMicros: duration('renderer-cpu-profile'), + }), + }); + const directorySha256 = sha256( + Buffer.from( + `${realDirectory}\0${JSON.stringify(receipts)}`, + 'utf8' + ) + ); + const evidence = Object.freeze({ + artifacts: receipts, + directorySha256, + profiles, + }) as XtreamDiagnosticArtifactEvidence; + validated.add(evidence); + return evidence; + }, + }; +} + +export function assertXtreamArtifactRedacted( + value: unknown, + forbiddenValues: readonly string[] = [] +): void { + try { + assertArtifactRedacted(value, forbiddenValues); + } catch { + throw unsafeArtifact(); + } +} + +function assertArtifactRedacted( + value: unknown, + forbiddenValues: readonly string[] +): void { + const secrets: string[] = []; + for (let index = 0; index < forbiddenValues.length; index += 1) { + const entry = Reflect.get(forbiddenValues, String(index)) as unknown; + if (typeof entry === 'string' && entry.length > 0) { + secrets.push(entry); + } + } + const visit = (input: unknown): void => { + if (typeof input === 'string') { + for (const candidate of decodedVariants(input)) { + if ( + secrets.some((secret) => candidate.includes(secret)) || + /[?&](?:username|password|token)=/iu.test(candidate) || + /:\/\/[^/\s]+@/u.test(candidate) + ) { + throw unsafeArtifact(); + } + } + return; + } + if (Array.isArray(input)) { + const length = input.length; + for (let index = 0; index < length; index += 1) { + visit(Reflect.get(input, String(index))); + } + return; + } + if (!input || typeof input !== 'object') return; + for (const [key, entry] of Object.entries(input)) { + if (SENSITIVE_KEYS.has(key.replace(/[-_]/gu, '').toLowerCase())) { + throw unsafeArtifact(); + } + visit(entry); + } + }; + visit(value); +} + +function decodedVariants(value: string): readonly string[] { + const variants = [value]; + let current = value; + for (let pass = 0; pass < MAX_PERCENT_DECODE_PASSES; pass += 1) { + const decoded = decodePercentPass(current); + if (decoded === current) break; + variants.push(decoded); + current = decoded; + } + const afterCap = decodePercentPass(current); + if (afterCap !== current) throw unsafeArtifact(); + return variants; +} + +function decodePercentPass(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value.replace(/%([a-f0-9]{2})/giu, (_match, hex: string) => + String.fromCharCode(Number.parseInt(hex, 16)) + ); + } +} + +export function serializeXtreamArtifactForPersistence( + value: unknown, + safety: XtreamArtifactSafetyContext +): string { + try { + return serializeArtifact(value, safety); + } catch { + throw unsafeArtifact(); + } +} + +function serializeArtifact( + value: unknown, + safety: XtreamArtifactSafetyContext +): string { + const controlToken = safety.controlToken; + const credentials = safety.syntheticCredentials; + const username = credentials?.username; + const password = credentials?.password; + if ( + typeof controlToken !== 'string' || + controlToken.length === 0 || + username !== 'performance' || + password !== 'performance' + ) { + throw unsafeArtifact(); + } + assertXtreamArtifactRedacted(value, [controlToken]); + let serialized: string | undefined; + try { + serialized = JSON.stringify(value); + } catch { + throw unsafeArtifact(); + } + if (typeof serialized !== 'string') throw unsafeArtifact(); + let canonical: unknown; + try { + canonical = JSON.parse(serialized) as unknown; + } catch { + throw unsafeArtifact(); + } + assertXtreamArtifactRedacted(canonical, [controlToken]); + if ( + JSON.stringify(canonical) !== serialized || + serialized.includes(controlToken) || + /performance(?::|%3a)performance/iu.test(serialized) || + /[?&](?:username=performance[^"#]*&password=performance|password=performance[^"#]*&username=performance)/iu.test( + serialized + ) || + /:\/\/performance(?::|%3a)performance@/iu.test(serialized) + ) { + throw unsafeArtifact(); + } + return serialized; +} + +function sha256(value: Uint8Array): string { + return createHash('sha256').update(value).digest('hex'); +} + +function unsafeArtifact(): Error { + return new Error('Xtream artifact must remain credential-safe'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-evidence-integrity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-evidence-integrity.spec.ts new file mode 100644 index 000000000..56d1ffe77 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-evidence-integrity.spec.ts @@ -0,0 +1,321 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + createXtreamIterationDefinitions, + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { iteration } from './xtream-benchmark-report.fixtures'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; +import type { XtreamRawIterationResult } from './xtream-summary-contract'; +import { validXtreamWorkerRequestIdentity } from './xtream-worker-request-identity'; + +test('enforces producer-coherent request identities and source-to-worker chronology', () => { + const raw = createRaw(XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE); + assert.doesNotThrow(() => assertXtreamIterationResult(raw)); + + const correlated = request(raw, 'DB_GET_CATEGORIES'); + const nonApplicable = request(raw, 'DB_GET_APP_STATE'); + const requiredOperationId = request(raw, 'DB_SAVE_CONTENT'); + const receivedEpochMs = requireEpoch(correlated.requestReceivedEpochMs); + + assert.equal( + validXtreamWorkerRequestIdentity({ + ...correlated, + sourceEpochMs: receivedEpochMs + 0.006, + }), + true + ); + assert.equal( + validXtreamWorkerRequestIdentity({ + ...correlated, + sourceEpochMs: receivedEpochMs + 1, + }), + false + ); + + assert.equal(nonApplicable.ipcCallId, null); + assert.equal(nonApplicable.operationId, null); + assert.equal( + nonApplicable.operationIdUnavailableReason, + 'preload-performance-marker-not-applicable' + ); + assert.equal(nonApplicable.sourceEpochMs, null); + + const invalidRequests = [ + replaceRequest(raw, correlated, { + ipcCallId: null, + operationIdUnavailableReason: + 'preload-performance-marker-not-applicable', + sourceEpochMs: null, + }), + replaceRequest(raw, nonApplicable, { + ipcCallId: 900_001, + operationId: 'forged-operation', + operationIdUnavailableReason: null, + sourceEpochMs: + requireEpoch(nonApplicable.requestReceivedEpochMs) - 1, + }), + replaceRequest(raw, requiredOperationId, { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-uncorrelated', + }), + replaceRequest(raw, correlated, { + operationId: 'forged-operation', + operationIdUnavailableReason: null, + }), + replaceRequest(raw, correlated, { + sourceEpochMs: receivedEpochMs + 1, + }), + ]; + + for (const requests of invalidRequests) { + assert.throws( + () => assertXtreamIterationResult(withRequests(raw, requests)), + /invalid Xtream iteration/ + ); + } +}); + +test('binds populated and empty playlist deletes to their real producer identities', () => { + const raw = createRaw(XTREAM_SCENARIO_ID.DELETE_LARGE); + const deletes = raw.databaseWorker.requests.evidence.filter( + ({ operation }) => operation === 'DB_DELETE_PLAYLIST' + ); + const populated = deletes.find( + (candidate) => + JSON.stringify(requestItemCounts(candidate)) === + JSON.stringify([100_100, 100_101]) + ); + const emptyFollowUp = deletes.find( + (candidate) => + JSON.stringify(requestItemCounts(candidate)) === + JSON.stringify([0, 1]) + ); + assert.ok(populated?.operationId); + assert.ok(emptyFollowUp); + + const producerCoherent = replaceRequest(raw, emptyFollowUp, { + operationId: null, + operationIdUnavailableReason: null, + }); + assert.doesNotThrow(() => + assertXtreamIterationResult(withRequests(raw, producerCoherent)) + ); + + const missingPopulatedIdentity = replaceRequest(raw, populated, { + operationId: null, + operationIdUnavailableReason: null, + }); + assert.throws( + () => + assertXtreamIterationResult( + withRequests(raw, missingPopulatedIdentity) + ), + /invalid Xtream iteration/ + ); + const fabricatedFollowUpIdentity = replaceRequest(raw, emptyFollowUp, { + operationId: 'fabricated-follow-up-operation', + operationIdUnavailableReason: null, + }); + assert.throws( + () => + assertXtreamIterationResult( + withRequests(raw, fabricatedFollowUpIdentity) + ), + /invalid Xtream iteration/ + ); +}); + +test('requires producer IPC IDs to preserve per-producer source chronology', () => { + const raw = createRaw(XTREAM_SCENARIO_ID.DELETE_LARGE); + const [first, second] = raw.databaseWorker.requests.evidence; + assert.ok(first); + assert.ok(second); + assert.ok(first.ipcCallId !== null); + assert.ok(second.ipcCallId !== null); + assert.ok(first.sourceEpochMs !== null); + assert.ok(second.sourceEpochMs !== null); + const laterSourceEpochMs = first.sourceEpochMs + 0.25; + const chronologicalRequests = raw.databaseWorker.requests.evidence.map( + (candidate) => + candidate === second + ? { ...candidate, sourceEpochMs: laterSourceEpochMs } + : candidate + ); + const chronologicalIpcSpans = raw.phaseCapture.ipcSpans.map((span) => + span.ipcCallId === second.ipcCallId + ? { ...span, sourceEpochMs: laterSourceEpochMs } + : span + ); + const swapped = chronologicalRequests.map((candidate) => { + if (candidate === first) { + return { ...candidate, ipcCallId: second.ipcCallId }; + } + return candidate.ipcCallId === second.ipcCallId + ? { ...candidate, ipcCallId: first.ipcCallId } + : candidate; + }); + assert.throws( + () => + assertXtreamIterationResult({ + ...withRequests(raw, swapped), + phaseCapture: { + ...raw.phaseCapture, + ipcSpans: chronologicalIpcSpans, + }, + }), + /invalid Xtream iteration/ + ); + const gapped = raw.databaseWorker.requests.evidence.map( + (candidate, index) => ({ + ...candidate, + ipcCallId: index === 0 ? 10 : 30, + }) + ); + const gappedIpcSpans = raw.phaseCapture.ipcSpans.map((span) => ({ + ...span, + ipcCallId: span.ipcCallId === first.ipcCallId ? 10 : 30, + })); + assert.doesNotThrow(() => + assertXtreamIterationResult({ + ...withRequests(raw, gapped), + phaseCapture: { + ...raw.phaseCapture, + ipcSpans: gappedIpcSpans, + }, + }) + ); + + const tied = raw.databaseWorker.requests.evidence.map((candidate) => + candidate === second + ? { ...candidate, sourceEpochMs: first.sourceEpochMs } + : candidate + ); + const tiedIpcSpans = raw.phaseCapture.ipcSpans.map((span) => + span.ipcCallId === second.ipcCallId + ? { ...span, sourceEpochMs: first.sourceEpochMs as number } + : span + ); + assert.doesNotThrow(() => + assertXtreamIterationResult({ + ...withRequests(raw, tied), + phaseCapture: { + ...raw.phaseCapture, + ipcSpans: tiedIpcSpans, + }, + }) + ); +}); + +test('does not let caller-owned evidence methods hide a missing populated delete identity', () => { + const raw = createRaw(XTREAM_SCENARIO_ID.DELETE_LARGE); + const pristine = raw.databaseWorker.requests.evidence; + const populated = pristine.find( + (candidate) => + candidate.operation === 'DB_DELETE_PLAYLIST' && + JSON.stringify(requestItemCounts(candidate)) === + JSON.stringify([100_100, 100_101]) + ); + assert.ok(populated); + const forged = replaceRequest(raw, populated, { + operationId: null, + operationIdUnavailableReason: null, + }); + const invoked: string[] = []; + for (const method of ['filter', 'flatMap', 'map', 'some'] as const) { + Object.defineProperty(forged, method, { + configurable: true, + value(...args: unknown[]) { + invoked.push(method); + return Reflect.apply(Array.prototype[method], pristine, args); + }, + }); + } + + assert.throws( + () => assertXtreamIterationResult(withRequests(raw, forged)), + /invalid Xtream iteration/ + ); + assert.deepEqual(invoked, []); +}); + +test('requires the database-worker isolate heap to fit inside main-process RSS only', () => { + const raw = createRaw(XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + peakHeapUsedBytes: raw.main.peakRssBytes + 1, + }, + }), + /invalid Xtream iteration/ + ); + assert.doesNotThrow(() => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + peakExternalBytes: raw.main.peakRssBytes + 1, + }, + }) + ); +}); + +function createRaw(scenarioId: XtreamScenarioId): XtreamRawIterationResult { + const definition = createXtreamIterationDefinitions(false).find( + (candidate) => candidate.scenarioId === scenarioId + ); + assert.ok(definition); + return iteration(definition, 1); +} + +function request(raw: XtreamRawIterationResult, operation: string) { + const value = raw.databaseWorker.requests.evidence.find( + (candidate) => candidate.operation === operation + ); + assert.ok(value); + return value; +} + +function replaceRequest( + raw: XtreamRawIterationResult, + target: (typeof raw.databaseWorker.requests.evidence)[number], + replacement: Partial +) { + return raw.databaseWorker.requests.evidence.map((candidate) => + candidate === target ? { ...candidate, ...replacement } : candidate + ); +} + +function withRequests( + raw: XtreamRawIterationResult, + evidence: typeof raw.databaseWorker.requests.evidence +): XtreamRawIterationResult { + return { + ...raw, + databaseWorker: { + ...raw.databaseWorker, + requests: { ...raw.databaseWorker.requests, evidence }, + }, + }; +} + +function requireEpoch(value: number | null): number { + assert.ok(value !== null); + return value; +} + +function requestItemCounts( + request: XtreamRawIterationResult['databaseWorker']['requests']['evidence'][number] +): readonly (number | null)[] { + return request.phaseEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ metadata }) => metadata?.itemCount ?? null); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-exact-schema.ts b/apps/electron-backend-e2e/src/performance/xtream-exact-schema.ts new file mode 100644 index 000000000..9e2882836 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-exact-schema.ts @@ -0,0 +1,369 @@ +import { + XTREAM_ITERATION_KIND, + type XtreamIterationDefinition, +} from './xtream-benchmark-contract'; +import { + XTREAM_IDENTIFIER, + XTREAM_SHA256, +} from './xtream-benchmark-manifest-schema'; +import { + XTREAM_NOT_APPLICABLE, + type XtreamRawIterationResult, + type XtreamValidatedIterationResult, +} from './xtream-summary-contract'; + +export { + assertXtreamBenchmarkManifest, + parseXtreamBenchmarkManifest, + XTREAM_IDENTIFIER, + XTREAM_SHA256, +} from './xtream-benchmark-manifest-schema'; + +export const XTREAM_RAW_ITERATION_KEYS = [ + 'cancellation', + 'cancellationEvidence', + 'catalogVerification', + 'databaseWorker', + 'eligibleForComparison', + 'kind', + 'main', + 'phaseCapture', + 'phases', + 'playlistRefreshWorker', + 'processIdentity', + 'renderer', + 'runId', + 'scenarioId', + 'validity', +] as const; + +export const XTREAM_PHASE_NUMBER_KEYS = [ + 'angularRenderingMs', + 'dataAcquisitionMs', + 'deserializationMs', + 'jsonParsingMs', + 'normalizationMs', + 'serializationMs', + 'sqliteReadMs', + 'sqliteWriteMs', + 'storeUpdateMs', + 'structuredCloneProxyMs', + 'totalMs', +] as const; + +export const XTREAM_PHASE_KEYS = [ + ...XTREAM_PHASE_NUMBER_KEYS, + 'backgroundSearchMarkerMs', + 'backgroundSearchMarkerReason', + 'indexesMs', + 'indexesReason', + 'restoreUserDataMs', + 'restoreUserDataReason', + 'structuredCloneAttribution', + 'transactionCommitMs', + 'transactionCommitReason', +] as const; + +export const XTREAM_PROFILE_KEYS = [ + 'cpuProfileDurationMicros', + 'cpuProfileUnavailableReason', +] as const; + +export const XTREAM_RENDERER_NUMBER_KEYS = [ + 'frameGapMaxMs', + 'frameGapP95Ms', + 'heartbeatMaxMs', + 'heartbeatP95Ms', + 'longTaskCount', + 'longTaskMaxMs', + 'peakHeapUsedBytes', + 'peakRssBytes', + 'postGcHeapUsedBytes', + 'storeToPaintMs', +] as const; + +export const XTREAM_MAIN_NUMBER_KEYS = [ + 'browserWindowId', + 'cpuSystemMicros', + 'cpuUserMicros', + 'eventLoopDelayMaxMs', + 'eventLoopDelayP95Ms', + 'eventLoopDelayP99Ms', + 'peakHeapUsedBytes', + 'peakRssBytes', + 'postGcHeapUsedBytes', + 'responsiveEvents', + 'unresponsiveEvents', + 'webContentsId', +] as const; + +export const XTREAM_WORKER_NUMBER_KEYS = [ + 'cpuSystemMicros', + 'cpuUserMicros', + 'eventLoopDelayMaxMs', + 'eventLoopDelayP95Ms', + 'eventLoopDelayP99Ms', + 'eventLoopUtilization', + 'ordinal', + 'peakExternalBytes', + 'peakHeapUsedBytes', + 'postGcHeapUsedBytes', +] as const; + +export const XTREAM_CANCEL_TIME_KEYS = [ + 'acknowledgementLatencyMs', + 'authoritativeTerminalEpochMs', + 'clickEpochMs', + 'clickToDatabaseDispatchMs', + 'clickToPaintMs', + 'clickToPreloadReturnMs', + 'databaseDispatchEpochMs', + 'paintedEpochMs', + 'preloadReturnEpochMs', + 'preloadStartEpochMs', + 'workerReceiptEpochMs', + 'workerReceiptLatencyMs', +] as const; + +export function assertFreshXtreamProcessIdentities( + iterations: readonly XtreamValidatedIterationResult[] +): void { + const unique = (select: (run: XtreamValidatedIterationResult) => unknown) => + new Set(iterations.map(select)).size === iterations.length; + if ( + !unique((run) => run.processIdentity.electronPid) || + !unique((run) => run.processIdentity.launchId) || + !unique((run) => run.processIdentity.profileDirectorySha256) || + !unique((run) => run.processIdentity.generationIdentitySha256) + ) { + throw new Error('Xtream benchmark requires a fresh process identity'); + } +} + +export function snapshotXtreamIterations( + value: unknown +): readonly XtreamValidatedIterationResult[] { + try { + if (!Array.isArray(value)) invalidIterationCollection(); + const lengthDescriptor = Reflect.getOwnPropertyDescriptor( + value, + 'length' + ); + const length = lengthDescriptor?.value; + if ( + !Number.isSafeInteger(length) || + Number(length) < 0 || + Number(length) > 128 + ) { + invalidIterationCollection(); + } + const snapshot: XtreamValidatedIterationResult[] = []; + for (let index = 0; index < Number(length); index += 1) { + const descriptor = Reflect.getOwnPropertyDescriptor( + value, + String(index) + ); + if ( + !descriptor || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + invalidIterationCollection(); + } + snapshot.push(descriptor.value as XtreamValidatedIterationResult); + } + return Object.freeze(snapshot); + } catch { + throw new Error('invalid Xtream iteration collection'); + } +} + +export function assertOrderedXtreamSchedule( + iterations: readonly XtreamValidatedIterationResult[], + expected: readonly XtreamIterationDefinition[], + mode: 'formal' | 'smoke' +): void { + if (iterations.length !== expected.length) scheduleError(mode); + for (let index = 0; index < expected.length; index += 1) { + const actual = iterations[index]; + const definition = expected[index]; + assertXtreamSafePathIdentifier(actual?.runId, 'run-id'); + assertXtreamSafePathIdentifier(definition?.runId, 'run-id'); + if ( + !actual || + !definition || + actual.scenarioId !== definition.scenarioId || + actual.runId !== definition.runId || + actual.kind !== definition.kind || + actual.eligibleForComparison !== definition.eligibleForComparison + ) { + throw new Error('invalid ordered Xtream benchmark schedule'); + } + } +} + +export function assertXtreamSafePathIdentifier( + value: unknown, + label: string +): asserts value is string { + if (typeof value !== 'string' || !XTREAM_IDENTIFIER.test(value)) { + throw new Error(`benchmark requires a safe Xtream ${label}`); + } +} + +export function toXtreamRawIteration( + value: XtreamValidatedIterationResult +): XtreamRawIterationResult { + const raw: Record = { ...value }; + delete raw['persistence']; + delete raw['validationStatus']; + return raw as unknown as XtreamRawIterationResult; +} + +export function freezeDeep(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + Object.values(value).forEach(freezeDeep); + } + return value; +} + +export function isExactRecord( + value: unknown, + keys: readonly string[] +): value is Record { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + ![Object.prototype, null].includes(Reflect.getPrototypeOf(value)) + ) { + return false; + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + ownKeys.some( + (key) => typeof key !== 'string' || !keys.includes(key) + ) + ) { + return false; + } + return keys.every((key) => { + const descriptor = Reflect.getOwnPropertyDescriptor(value, key); + return ( + descriptor !== undefined && + Object.prototype.hasOwnProperty.call(descriptor, 'value') + ); + }); + } catch { + return false; + } +} + +export function isFiniteNonNegative(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +export function isSafeNonNegativeInteger(value: unknown): boolean { + return Number.isSafeInteger(value) && Number(value) >= 0; +} + +export function isPositiveSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) > 0; +} + +export function isUtilization(value: unknown): boolean { + return isFiniteNonNegative(value) && value <= 1; +} + +export function hasFiniteNonNegativeKeys( + value: Record, + keys: readonly string[] +): boolean { + return keys.every((key) => isFiniteNonNegative(value[key])); +} + +export function hasOrderedEventLoopDelay( + value: Record +): boolean { + return ( + (value['eventLoopDelayP95Ms'] as number) <= + (value['eventLoopDelayP99Ms'] as number) && + (value['eventLoopDelayP99Ms'] as number) <= + (value['eventLoopDelayMaxMs'] as number) + ); +} + +export function hasMatchingEventLoopDelay( + worker: Record +): boolean { + const requests = worker['requests']; + if (typeof requests !== 'object' || requests === null) return false; + return [ + 'eventLoopDelayMaxMs', + 'eventLoopDelayP95Ms', + 'eventLoopDelayP99Ms', + ].every( + (key) => worker[key] === (requests as Record)[key] + ); +} + +export function isExactXtreamProcessIdentity(value: unknown): boolean { + const keys = [ + 'captureGeneration', + 'electronPid', + 'freshProcess', + 'generationIdentitySha256', + 'launchId', + 'profileDirectorySha256', + ] as const; + return ( + isExactRecord(value, keys) && + isPositiveSafeInteger(value['captureGeneration']) && + isPositiveSafeInteger(value['electronPid']) && + value['freshProcess'] === true && + typeof value['launchId'] === 'string' && + XTREAM_IDENTIFIER.test(value['launchId']) && + typeof value['generationIdentitySha256'] === 'string' && + XTREAM_SHA256.test(value['generationIdentitySha256']) && + typeof value['profileDirectorySha256'] === 'string' && + XTREAM_SHA256.test(value['profileDirectorySha256']) + ); +} + +export function isXtreamProfile( + value: Record, + kind: unknown +): boolean { + return kind === XTREAM_ITERATION_KIND.DIAGNOSTIC + ? isPositiveSafeInteger(value['cpuProfileDurationMicros']) && + value['cpuProfileUnavailableReason'] === null + : value['cpuProfileDurationMicros'] === null && + value['cpuProfileUnavailableReason'] === + XTREAM_NOT_APPLICABLE.PROFILE_OUTSIDE_DIAGNOSTIC; +} + +export function sameValues( + left: readonly unknown[], + right: readonly unknown[] +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +export function sameMaps( + left: ReadonlyMap, + right: ReadonlyMap +): boolean { + return sameValues([...left.entries()].sort(), [...right.entries()].sort()); +} + +function scheduleError(mode: 'formal' | 'smoke'): never { + throw new Error( + `Xtream benchmark requires exactly ${mode === 'formal' ? 'five' : 'one'} measured runs per scenario` + ); +} + +function invalidIterationCollection(): never { + throw new Error('invalid iteration collection'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-exclusive-artifact-writer.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-exclusive-artifact-writer.spec.ts new file mode 100644 index 000000000..0cf74f4a8 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-exclusive-artifact-writer.spec.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import { writeXtreamExclusiveArtifact } from './xtream-exclusive-artifact-writer'; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +describe('Xtream exclusive artifact writer', () => { + it('durably writes exact bytes and returns their receipt', async () => { + const directory = await createDirectory(); + const path = join(directory, 'raw.json'); + const content = '{"safe":true}'; + + const receipt = await writeXtreamExclusiveArtifact(path, content); + + assert.deepEqual(receipt, { + absolutePath: path, + bytes: Buffer.byteLength(content), + sha256: sha256(content), + }); + assert.equal(await readFile(path, 'utf8'), content); + }); + + it('never truncates or replaces an existing artifact', async () => { + const directory = await createDirectory(); + const path = join(directory, 'raw.json'); + await writeFile(path, 'original', 'utf8'); + + await assert.rejects( + writeXtreamExclusiveArtifact(path, 'replacement'), + /Xtream artifact path is not exclusively writable/ + ); + assert.equal(await readFile(path, 'utf8'), 'original'); + }); + + it('rejects a final-component symlink and relative paths', async () => { + const directory = await createDirectory(); + const target = join(directory, 'target.json'); + const alias = join(directory, 'alias.json'); + await writeFile(target, 'target', 'utf8'); + await symlink(target, alias); + + await assert.rejects( + writeXtreamExclusiveArtifact(alias, 'replacement'), + /Xtream artifact path is not exclusively writable/ + ); + await assert.rejects( + writeXtreamExclusiveArtifact('relative.json', '{}'), + /Xtream artifact path is not exclusively writable/ + ); + assert.equal(await readFile(target, 'utf8'), 'target'); + }); + + it('rejects empty payloads before creating a file', async () => { + const directory = await createDirectory(); + const path = join(directory, 'empty.json'); + + await assert.rejects( + writeXtreamExclusiveArtifact(path, ''), + /Xtream artifact path is not exclusively writable/ + ); + await assert.rejects(readFile(path), { code: 'ENOENT' }); + }); +}); + +async function createDirectory(): Promise { + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-xtream-exclusive-') + ); + directories.push(directory); + return directory; +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-exclusive-artifact-writer.ts b/apps/electron-backend-e2e/src/performance/xtream-exclusive-artifact-writer.ts new file mode 100644 index 000000000..9ace12d46 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-exclusive-artifact-writer.ts @@ -0,0 +1,49 @@ +import { createHash } from 'node:crypto'; +import { open } from 'node:fs/promises'; +import { isAbsolute } from 'node:path'; + +import type { XtreamPersistenceReceipt } from './xtream-summary-contract'; + +export async function writeXtreamExclusiveArtifact( + absolutePath: string, + serialized: string +): Promise { + try { + if ( + typeof absolutePath !== 'string' || + !isAbsolute(absolutePath) || + typeof serialized !== 'string' || + serialized.length === 0 + ) { + invalid(); + } + const content = Buffer.from(serialized, 'utf8'); + const handle = await open(absolutePath, 'wx', 0o600); + try { + await handle.writeFile(content); + await handle.sync(); + const stats = await handle.stat(); + if ( + !stats.isFile() || + stats.isSymbolicLink() || + stats.nlink !== 1 || + stats.size !== content.byteLength + ) { + invalid(); + } + } finally { + await handle.close(); + } + return Object.freeze({ + absolutePath, + bytes: content.byteLength, + sha256: createHash('sha256').update(content).digest('hex'), + }); + } catch { + throw new Error('Xtream artifact path is not exclusively writable'); + } +} + +function invalid(): never { + throw new Error('invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-filesystem-integrity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-filesystem-integrity.spec.ts new file mode 100644 index 000000000..291a0cebb --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-filesystem-integrity.spec.ts @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { link, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import { CPU_PROFILE } from './initial-import-diagnostic-test-fixtures'; +import { + validateXtreamDiagnosticArtifacts, + type XtreamDiagnosticCapture, +} from './xtream-diagnostic-artifacts'; +import { verifyXtreamPersistenceReceipt } from './xtream-raw-persistence-receipt'; + +const HEAP_SNAPSHOT = { + edges: [0, 0, 0], + nodes: [0, 0, 1, 0, 1, 0], + snapshot: { + meta: { + edge_fields: ['type', 'name_or_index', 'to_node'], + edge_types: [['context'], 'string_or_number', 'node'], + node_fields: [ + 'type', + 'name', + 'id', + 'self_size', + 'edge_count', + 'detachedness', + ], + node_types: [ + ['hidden'], + 'string', + 'number', + 'number', + 'number', + 'number', + ], + }, + }, + strings: ['', 'root'], +}; +const RENDERER_TRACE = { + traceEvents: [{ name: 'xtream-diagnostic', ph: 'X', ts: 1 }], +}; +const directories: string[] = []; + +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +describe('Xtream filesystem evidence integrity', () => { + it('rejects a raw persistence file with an external hard-link alias', async () => { + const directory = await createDirectory('raw'); + const outside = await createDirectory('raw-outside'); + const serialized = '{"safe":true}'; + const rawPath = join(directory, 'raw.json'); + const externalAlias = join(outside, 'external-alias.json'); + await writeFile(rawPath, serialized, 'utf8'); + await link(rawPath, externalAlias); + + await assert.rejects( + () => + verifyXtreamPersistenceReceipt(serialized, { + absolutePath: rawPath, + bytes: Buffer.byteLength(serialized), + sha256: sha256(serialized), + }), + /^Error: invalid Xtream persistence receipt$/ + ); + }); + + it('rejects a diagnostic artifact with an external hard-link alias', async () => { + const directory = await createDirectory('diagnostic'); + const outside = await createDirectory('outside'); + await writeValidArtifacts(directory); + const tracePath = join(directory, 'renderer.trace.json'); + const externalTrace = join(outside, 'renderer.trace.json'); + await unlink(tracePath); + await writeFile(externalTrace, JSON.stringify(RENDERER_TRACE)); + await link(externalTrace, tracePath); + + await assert.rejects( + () => + validateXtreamDiagnosticArtifacts( + capture(directory), + directory + ), + /^Error: xtream-diagnostic-artifact-path-invalid:renderer-trace$/ + ); + }); +}); + +function capture(directory: string): XtreamDiagnosticCapture { + return { + main: { + cpuProfilePath: join(directory, 'main.cpuprofile'), + heapSnapshotPath: join(directory, 'main.heapsnapshot'), + workers: [ + { + kind: 'database.worker', + ordinal: 4, + profilePath: join( + directory, + 'database.worker-4.cpuprofile' + ), + snapshotPath: join( + directory, + 'database.worker-4.heapsnapshot' + ), + }, + ], + }, + renderer: { + cpuProfilePath: join(directory, 'renderer.cpuprofile'), + heapSnapshotPath: join(directory, 'renderer.heapsnapshot'), + tracePath: join(directory, 'renderer.trace.json'), + }, + }; +} + +async function createDirectory(label: string): Promise { + const directory = await mkdtemp( + join(tmpdir(), `iptvnator-xtream-fs-${label}-`) + ); + directories.push(directory); + return directory; +} + +async function writeValidArtifacts(directory: string): Promise { + await Promise.all([ + writeJson(join(directory, 'main.cpuprofile'), CPU_PROFILE), + writeJson(join(directory, 'renderer.cpuprofile'), CPU_PROFILE), + writeJson(join(directory, 'database.worker-4.cpuprofile'), CPU_PROFILE), + writeJson( + join(directory, 'database.worker-4.heapsnapshot'), + HEAP_SNAPSHOT + ), + writeJson(join(directory, 'main.heapsnapshot'), HEAP_SNAPSHOT), + writeJson(join(directory, 'renderer.heapsnapshot'), HEAP_SNAPSHOT), + writeJson(join(directory, 'renderer.trace.json'), RENDERER_TRACE), + ]); +} + +function writeJson(path: string, value: unknown): Promise { + return writeFile(path, JSON.stringify(value)); +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-attribution-identity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-attribution-identity.spec.ts new file mode 100644 index 000000000..f5d6ac91b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-attribution-identity.spec.ts @@ -0,0 +1,239 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, +} from './xtream-benchmark-contract'; +import { iteration } from './xtream-benchmark-report.fixtures'; +import { + xtreamIpcProducerIdentityKey, + xtreamIpcProducerNamespaceForMethod, + xtreamIpcProducerNamespaceForWorkerOperation, +} from './xtream-ipc-producer-identity'; +import { attributeXtreamPhases } from './xtream-phase-attribution'; +import { scenarioCapture } from './xtream-phase-attribution.fixtures'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; + +const DB_METHOD_OPERATION = new Map([ + ['dbDeletePlaylist', 'DB_DELETE_PLAYLIST'], + ['dbDeleteXtreamContent', 'DB_DELETE_XTREAM_CONTENT'], + ['dbGetAppPlaylist', 'DB_GET_APP_PLAYLIST'], + ['dbGetCategories', 'DB_GET_CATEGORIES'], + ['dbGetContent', 'DB_GET_CONTENT'], + ['dbRestoreXtreamUserData', 'DB_RESTORE_XTREAM_USER_DATA'], + ['dbSaveCategories', 'DB_SAVE_CATEGORIES'], + ['dbSaveContent', 'DB_SAVE_CONTENT'], + ['dbUpsertAppPlaylist', 'DB_UPSERT_APP_PLAYLIST'], +]); + +describe('Xtream IPC attribution producer identity', () => { + it('requires exact pairs, distinct call IDs, and source chronology', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const requests = initial.ipcSpans + .filter( + ({ boundary, method }) => + boundary === 'request' && method === 'dbGetCategories' + ) + .sort((left, right) => left.ipcCallId - right.ipcCallId); + const first = requests[0]; + const second = requests[1]; + assert.ok(first); + assert.ok(second); + const pairDrift = initial.ipcSpans.map((span) => + span.correlationId === first.correlationId && + span.boundary === 'response' + ? { ...span, ipcCallId: span.ipcCallId + 1 } + : span + ); + const sourcePairDrift = initial.ipcSpans.map((span) => + span.correlationId === first.correlationId && + span.boundary === 'response' + ? { ...span, sourceEpochMs: span.sourceEpochMs + 0.01 } + : span + ); + const duplicate = initial.ipcSpans.map((span) => + span.correlationId === second.correlationId + ? { ...span, ipcCallId: first.ipcCallId } + : span + ); + const chronologyRegression = initial.ipcSpans.map((span) => + span.correlationId === first.correlationId + ? { ...span, sourceEpochMs: 100.75 } + : span + ); + + for (const ipcSpans of [ + pairDrift, + sourcePairDrift, + duplicate, + chronologyRegression, + ]) { + assert.throws( + () => attributeXtreamPhases({ ...initial, ipcSpans }), + /invalid Xtream phase capture/ + ); + } + }); + + it('accepts one numeric call ID in each independent preload producer namespace', () => { + const raw = iteration( + { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + }, + 1 + ); + const legacy = raw.phaseCapture.ipcSpans.find( + ({ boundary, method }) => + boundary === 'request' && method === 'dbUpsertAppPlaylist' + ); + const xtream = raw.phaseCapture.ipcSpans.find( + ({ boundary, method }) => + boundary === 'request' && method === 'dbGetCategories' + ); + assert.ok(legacy); + assert.ok(xtream); + assert.equal(legacy.ipcCallId, xtream.ipcCallId); + const legacySourceEpochMs = legacy.sourceEpochMs + 0.25; + const ipcSpans = raw.phaseCapture.ipcSpans.map((span) => { + if ( + xtreamIpcProducerNamespaceForMethod(span.method) === + 'legacy-preload' + ) { + return { ...span, sourceEpochMs: legacySourceEpochMs }; + } + return span.correlationId === xtream.correlationId + ? { ...span, ipcCallId: legacy.ipcCallId } + : span; + }); + const requests = raw.databaseWorker.requests.evidence.map((request) => { + if ( + xtreamIpcProducerNamespaceForWorkerOperation( + request.operation ?? '' + ) === 'legacy-preload' + ) { + return { ...request, sourceEpochMs: legacySourceEpochMs }; + } + return request.ipcCallId === xtream.ipcCallId && + request.operation === 'DB_GET_CATEGORIES' + ? { ...request, ipcCallId: legacy.ipcCallId } + : request; + }); + + assert.doesNotThrow(() => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + requests: { + ...raw.databaseWorker.requests, + evidence: requests, + }, + }, + phaseCapture: { ...raw.phaseCapture, ipcSpans }, + }) + ); + }); + + it('does not collide a cancellation call with the legacy preload namespace', () => { + const raw = iteration( + { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId: XTREAM_SCENARIO_ID.CANCEL_IMPORT, + }, + 1 + ); + const cancellation = raw.phaseCapture.ipcSpans.find( + ({ boundary, method }) => + boundary === 'request' && method === 'dbCancelOperation' + ); + const legacy = raw.phaseCapture.ipcSpans.find( + ({ boundary, method }) => + boundary === 'request' && method === 'dbUpsertAppPlaylist' + ); + assert.ok(cancellation); + assert.ok(legacy); + const ipcSpans = raw.phaseCapture.ipcSpans.map((span) => + span.correlationId === legacy.correlationId + ? { ...span, ipcCallId: cancellation.ipcCallId } + : span + ); + const requests = raw.databaseWorker.requests.evidence.map((request) => + request.operation === 'DB_UPSERT_APP_PLAYLIST' && + request.ipcCallId === legacy.ipcCallId + ? { ...request, ipcCallId: cancellation.ipcCallId } + : request + ); + + assert.doesNotThrow(() => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + requests: { + ...raw.databaseWorker.requests, + evidence: requests, + }, + }, + phaseCapture: { ...raw.phaseCapture, ipcSpans }, + }) + ); + }); + + it('shares each database preload identity with its worker request', () => { + for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) { + const raw = iteration( + { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId, + }, + 1 + ); + const workersByProducerIdentity = new Map( + raw.databaseWorker.requests.evidence.flatMap((request) => + request.ipcCallId === null + ? [] + : [ + [ + workerIdentityKey( + request.operation ?? '', + request.ipcCallId + ), + request, + ] as const, + ] + ) + ); + for (const span of raw.phaseCapture.ipcSpans) { + if (span.boundary !== 'request') continue; + const operation = DB_METHOD_OPERATION.get(span.method); + if (!operation) continue; + const namespace = xtreamIpcProducerNamespaceForMethod( + span.method + ); + assert.ok(namespace); + const worker = workersByProducerIdentity.get( + xtreamIpcProducerIdentityKey(namespace, span.ipcCallId) + ); + assert.ok(worker); + assert.equal(worker.operation, operation); + assert.equal(worker.sourceEpochMs, span.sourceEpochMs); + } + } + }); +}); + +function workerIdentityKey(operation: string, ipcCallId: number): string { + const namespace = xtreamIpcProducerNamespaceForWorkerOperation(operation); + assert.ok(namespace); + return xtreamIpcProducerIdentityKey(namespace, ipcCallId); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.model.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.model.ts new file mode 100644 index 000000000..8a27b32ef --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.model.ts @@ -0,0 +1,67 @@ +export interface XtreamIpcMarkerCaptureOptions { + readonly actions: readonly string[]; + readonly mainPhases: readonly string[]; + readonly methods: readonly string[]; + readonly schemaVersion: number; +} + +export interface XtreamIpcRequestIdentity { + ipcCallId: number | null; + operationId: string | null; + operationIdUnavailableReason: string | null; + sourceEpochMs: number | null; +} + +export interface XtreamIpcCallState { + completionOutcome: 'error' | 'success' | null; + downstreamMatched: boolean; + ended: boolean; + key: string; + marker: import('./xtream-ipc-marker-protocol').SafeXtreamPreloadMarker; + quarantined: boolean; + receivedEpochMs: number; +} + +export interface XtreamMainPhaseState { + endEpochMs: number | null; + startEpochMs: number; +} + +export interface XtreamIpcTimelineRecord { + readonly action?: string | null; + readonly boundary: 'end' | 'start'; + readonly categoryType?: string | null; + readonly contentType?: string | null; + readonly durationMs?: number | null; + readonly epochMs: number; + readonly ipcCallId?: number; + readonly itemCount?: number | null; + readonly method?: string; + readonly operationId?: string | null; + readonly outcome?: 'error' | 'success' | null; + readonly phase?: string; + readonly playlistId?: string | null; + readonly requestId?: string; + readonly sessionId?: string | null; + readonly sourceEpochMs?: number; + readonly type: 'xtream-main-phase' | 'xtream-preload-marker'; +} + +export interface XtreamIpcMarkerCaptureSnapshot { + readonly invalidReasons: readonly string[]; + readonly quarantinedKeyCount: number; + readonly timeline: readonly XtreamIpcTimelineRecord[]; +} + +export interface XtreamIpcMarkerCapture { + acceptMainPhase(input: unknown): boolean; + acceptPreload( + senderWebContentsId: number, + receivedEpochMs: number, + input: unknown + ): boolean; + matchDatabaseCancel(input: unknown): XtreamIpcRequestIdentity | null; + matchDatabaseRequest(input: unknown): XtreamIpcRequestIdentity | null; + start(expectedWebContentsId: number, captureStartedEpochMs: number): void; + stop(): XtreamIpcMarkerCaptureSnapshot; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.spec.ts new file mode 100644 index 000000000..443c1be70 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.spec.ts @@ -0,0 +1,379 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + XTREAM_MAIN_PERFORMANCE_PHASE, + XTREAM_PRELOAD_PERFORMANCE_METHOD, + XtreamCodeActions, +} from '@iptvnator/shared/interfaces'; + +import { + createXtreamIpcMarkerCapture, + type XtreamIpcMarkerCaptureOptions, +} from './xtream-ipc-marker-events'; +import { createXtreamIpcMarkerProtocol } from './xtream-ipc-marker-protocol'; +import { createXtreamMainLifecycleValidator } from './xtream-main-lifecycle'; + +const options: XtreamIpcMarkerCaptureOptions = { + actions: Object.values(XtreamCodeActions), + mainPhases: Object.values(XTREAM_MAIN_PERFORMANCE_PHASE), + methods: Object.values(XTREAM_PRELOAD_PERFORMANCE_METHOD), + schemaVersion: 1, +}; + +function marker(overrides: Record = {}) { + return { + schemaVersion: 1, + sourceEpochMs: 100, + ipcCallId: 1, + method: 'dbSaveContent', + boundary: 'start', + outcome: null, + playlistId: 'playlist-1', + operationId: 'operation-1', + sessionId: null, + action: null, + categoryType: null, + contentType: 'live', + itemCount: 60_000, + ...overrides, + }; +} + +describe('Xtream preload marker capture', () => { + it('restores both factories without module closures for Electron-main injection', () => { + const restore = (source: string): T => + new Function(`"use strict"; return (${source});`)() as T; + const protocolFactory = restore( + createXtreamIpcMarkerProtocol.toString() + ); + const captureFactory = restore( + createXtreamIpcMarkerCapture.toString() + ); + const lifecycleFactory = restore< + typeof createXtreamMainLifecycleValidator + >(createXtreamMainLifecycleValidator.toString()); + const capture = captureFactory( + options, + protocolFactory(options), + lifecycleFactory() + ); + + capture.start(42, 90); + assert.equal(capture.acceptPreload(42, 101, marker()), true); + }); + + it('accepts only the exact 13-key safe schema from the bound webContents', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + + assert.equal(capture.acceptPreload(42, 101, marker()), true); + assert.equal( + capture.acceptPreload( + 42, + 102, + marker({ + boundary: 'end', + outcome: 'success', + sourceEpochMs: 101, + }) + ), + true + ); + + const snapshot = capture.stop(); + assert.deepEqual(snapshot.invalidReasons, []); + assert.equal(snapshot.quarantinedKeyCount, 0); + assert.equal(snapshot.timeline.length, 2); + assert.deepEqual(snapshot.timeline[0], { + action: null, + boundary: 'start', + categoryType: null, + contentType: 'live', + epochMs: 101, + ipcCallId: 1, + itemCount: 60_000, + method: 'dbSaveContent', + operationId: 'operation-1', + outcome: null, + playlistId: 'playlist-1', + sessionId: null, + sourceEpochMs: 100, + type: 'xtream-preload-marker', + }); + }); + + it('normalizes sub-millisecond cross-process clock skew and rejects larger reversals', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + + assert.equal( + capture.acceptPreload(42, 100, marker({ sourceEpochMs: 100.006 })), + true + ); + assert.equal( + capture.acceptPreload( + 42, + 101, + marker({ + boundary: 'end', + outcome: 'success', + sourceEpochMs: 101.006, + }) + ), + true + ); + const snapshot = capture.stop(); + assert.deepEqual(snapshot.invalidReasons, []); + assert.equal(snapshot.timeline[0]?.epochMs, 100.006); + assert.equal(snapshot.timeline[1]?.epochMs, 101.006); + + const reversed = createXtreamIpcMarkerCapture(options); + reversed.start(42, 90); + assert.equal( + reversed.acceptPreload(42, 100, marker({ sourceEpochMs: 101.001 })), + false + ); + assert.ok( + reversed + .stop() + .invalidReasons.includes('preload-marker-source-after-receipt') + ); + }); + + it('rejects delayed preload markers from a completed capture generation', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + capture.stop(); + capture.start(42, 200); + + assert.equal(capture.acceptPreload(42, 210, marker()), false); + assert.equal( + capture.acceptPreload( + 42, + 211, + marker({ + boundary: 'end', + outcome: 'success', + sourceEpochMs: 101, + }) + ), + false + ); + assert.ok( + capture + .stop() + .invalidReasons.includes('preload-marker-before-capture-start') + ); + }); + + it('rejects another sender, extra or missing keys, malformed IDs, and regressing epochs', () => { + const invalidCases: Array<{ + first?: Record; + input: Record; + receivedEpochMs?: number; + sender?: number; + }> = [ + { input: marker(), sender: 7 }, + { input: { ...marker(), password: 'provider-secret' } }, + { + input: Object.fromEntries( + Object.entries(marker()).filter( + ([key]) => key !== 'itemCount' + ) + ), + }, + { input: marker({ playlistId: ' playlist-1' }) }, + { input: marker({ ipcCallId: 0 }) }, + { input: marker({ outcome: 'success' }) }, + { + first: marker(), + input: marker({ + boundary: 'end', + outcome: 'success', + sourceEpochMs: 99, + }), + receivedEpochMs: 102, + }, + { + input: marker({ sourceEpochMs: 102 }), + receivedEpochMs: 101, + }, + ]; + + for (const testCase of invalidCases) { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + if (testCase.first) { + assert.equal( + capture.acceptPreload(42, 101, testCase.first), + true + ); + } + assert.equal( + capture.acceptPreload( + testCase.sender ?? 42, + testCase.receivedEpochMs ?? 101, + testCase.input + ), + false + ); + assert.notEqual(capture.stop().invalidReasons.length, 0); + } + }); + + it('correlates DB requests FIFO and quarantines overlapping duplicate keys', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + assert.equal(capture.acceptPreload(42, 100, marker()), true); + + const identity = capture.matchDatabaseRequest({ + operation: 'DB_SAVE_CONTENT', + payload: { + operationId: 'operation-1', + playlistId: 'playlist-1', + type: 'live', + }, + }); + assert.deepEqual(identity, { + ipcCallId: 1, + operationId: 'operation-1', + operationIdUnavailableReason: null, + sourceEpochMs: 100, + }); + assert.equal( + capture.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + }), + null + ); + + const duplicateCapture = createXtreamIpcMarkerCapture(options); + duplicateCapture.start(42, 90); + assert.equal(duplicateCapture.acceptPreload(42, 100, marker()), true); + assert.equal( + duplicateCapture.acceptPreload( + 42, + 101, + marker({ ipcCallId: 2, sourceEpochMs: 101 }) + ), + false + ); + assert.deepEqual( + duplicateCapture.matchDatabaseRequest({ + operation: 'DB_SAVE_CONTENT', + payload: { + operationId: 'operation-1', + playlistId: 'playlist-1', + type: 'live', + }, + }), + { + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: + 'xtream-preload-performance-marker-ambiguous', + sourceEpochMs: null, + } + ); + assert.equal(duplicateCapture.stop().quarantinedKeyCount, 1); + }); + + it('retains a mutable missing identity when a valid preload start arrives after the worker request', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + const identity = capture.matchDatabaseRequest({ + operation: 'DB_SAVE_CONTENT', + payload: { + operationId: 'operation-1', + playlistId: 'playlist-1', + type: 'live', + }, + }); + assert.deepEqual(identity, { + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: + 'xtream-preload-performance-marker-missing', + sourceEpochMs: null, + }); + + assert.equal(capture.acceptPreload(42, 100, marker()), true); + assert.deepEqual(identity, { + ipcCallId: 1, + operationId: 'operation-1', + operationIdUnavailableReason: null, + sourceEpochMs: 100, + }); + }); + + it('correlates read-only content requests without inventing an operation ID', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + assert.equal( + capture.acceptPreload( + 42, + 100, + marker({ + ipcCallId: 3, + itemCount: null, + method: 'dbGetContent', + operationId: null, + }) + ), + true + ); + + assert.deepEqual( + capture.matchDatabaseRequest({ + operation: 'DB_GET_CONTENT', + payload: { playlistId: 'playlist-1', type: 'live' }, + }), + { + ipcCallId: 3, + operationId: null, + operationIdUnavailableReason: null, + sourceEpochMs: 100, + } + ); + }); + + it('correlates the preload cancel call separately from the active DB request', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + const cancelMarker = marker({ + contentType: null, + ipcCallId: 4, + itemCount: null, + method: 'dbCancelOperation', + playlistId: null, + }); + assert.equal(capture.acceptPreload(42, 100, cancelMarker), true); + assert.deepEqual( + capture.matchDatabaseCancel({ + operationId: 'operation-1', + type: 'cancel', + }), + { + ipcCallId: 4, + operationId: 'operation-1', + operationIdUnavailableReason: null, + sourceEpochMs: 100, + } + ); + assert.equal( + capture.acceptPreload( + 42, + 102, + marker({ + ...cancelMarker, + boundary: 'end', + outcome: 'success', + sourceEpochMs: 101, + }) + ), + true + ); + assert.deepEqual(capture.stop().invalidReasons, []); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.ts new file mode 100644 index 000000000..972377c0b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.ts @@ -0,0 +1,399 @@ +import { + createXtreamIpcMarkerProtocol, + type XtreamIpcMarkerProtocol, +} from './xtream-ipc-marker-protocol'; +import type { + XtreamIpcCallState, + XtreamIpcMarkerCapture, + XtreamIpcMarkerCaptureOptions, + XtreamIpcRequestIdentity, + XtreamIpcTimelineRecord, + XtreamMainPhaseState, +} from './xtream-ipc-marker-events.model'; +import { + createXtreamMainLifecycleValidator, + type XtreamMainLifecycleValidator, +} from './xtream-main-lifecycle'; +export { deriveXtreamAcquisitionDurationMs } from './xtream-acquisition-duration'; +export type * from './xtream-ipc-marker-events.model'; +export function createXtreamIpcMarkerCapture( + options: XtreamIpcMarkerCaptureOptions, + protocol: XtreamIpcMarkerProtocol = createXtreamIpcMarkerProtocol(options), + mainLifecycle: XtreamMainLifecycleValidator = createXtreamMainLifecycleValidator() +): XtreamIpcMarkerCapture { + let active = false; + let captureStartedEpochMs = 0; + let expectedWebContentsId: number | null = null; + let lastMainEpochMs = 0; + let lastReceivedEpochMs = 0; + let lastSourceEpochMs = 0; + let timeline: XtreamIpcTimelineRecord[] = []; + let invalidReasons: string[] = []; + const preloadSourceReceiptSkewToleranceMs = 1; + const calls = new Map(); + const activeByKey = new Map(); + const pendingRequests = new Map(); + const quarantinedKeys = new Set(); + const mainPhases = new Map(); + const mainRequestCalls = new Map(); + const helpers = { + invalidate(reason: string): false { + invalidReasons.push(reason); + return false; + }, + mainPhasesForRequest( + requestId: string + ): readonly (XtreamMainPhaseState & { readonly phase: string })[] { + const prefix = `${requestId}\u0000`; + return [...mainPhases.entries()] + .filter(([key]) => key.startsWith(prefix)) + .map(([key, state]) => ({ + ...state, + phase: key.slice(prefix.length), + })); + }, + matchKey( + key: string | null | undefined + ): XtreamIpcRequestIdentity | null { + if (key === undefined) { + return null; + } + if (key === null || quarantinedKeys.has(key)) { + return protocol.unavailableIdentity( + 'xtream-preload-performance-marker-ambiguous' + ); + } + const call = activeByKey.get(key); + if (call && !call.downstreamMatched && !call.quarantined) { + call.downstreamMatched = true; + const identity = protocol.unavailableIdentity( + 'xtream-preload-performance-marker-missing' + ); + protocol.applyMarkerIdentity(identity, call.marker); + return identity; + } + const identity = protocol.unavailableIdentity( + 'xtream-preload-performance-marker-missing' + ); + const waiting = pendingRequests.get(key) ?? []; + waiting.push(identity); + pendingRequests.set(key, waiting); + if (waiting.length > 1) { + helpers.quarantine(key); + } + return identity; + }, + quarantine(key: string): void { + quarantinedKeys.add(key); + const activeCall = activeByKey.get(key); + if (activeCall) { + activeCall.quarantined = true; + activeByKey.delete(key); + } + for (const identity of pendingRequests.get(key) ?? []) { + Object.assign( + identity, + protocol.unavailableIdentity( + 'xtream-preload-performance-marker-ambiguous' + ) + ); + } + pendingRequests.delete(key); + }, + }; + return { + acceptMainPhase(input) { + if (!active) return false; + const event = protocol.parseMainPhase(input); + if (!event) { + return helpers.invalidate('main-phase-invalid'); + } + const { boundary, durationMs, epochMs, phase, requestId } = event; + if (epochMs < captureStartedEpochMs) { + return helpers.invalidate('main-phase-before-capture-start'); + } + if (epochMs < lastMainEpochMs) { + return helpers.invalidate('main-phase-invalid'); + } + lastMainEpochMs = epochMs; + const boundaryKey = `${requestId}\u0000${phase}`; + const phaseState = mainPhases.get(boundaryKey); + if (boundary === 'start') { + if (phaseState) { + return helpers.invalidate('main-phase-duplicate-boundary'); + } + mainPhases.set(boundaryKey, { + endEpochMs: null, + startEpochMs: epochMs, + }); + } else { + if ( + !phaseState || + phaseState.endEpochMs !== null || + epochMs < phaseState.startEpochMs + ) { + return helpers.invalidate('main-phase-duplicate-boundary'); + } + phaseState.endEpochMs = epochMs; + } + let call = mainRequestCalls.get(requestId); + if ( + !call && + boundary === 'start' && + (phase === 'xtream.network.total' || + phase === 'xtream.cancel-session') + ) { + const method = + phase === 'xtream.network.total' + ? 'xtreamRequest' + : 'xtreamCancelSession'; + call = [...calls.values()] + .filter( + (candidate) => + !candidate.ended && + !candidate.quarantined && + !candidate.downstreamMatched && + candidate.marker.method === method + ) + .sort( + (left, right) => + left.receivedEpochMs - right.receivedEpochMs || + left.marker.ipcCallId - right.marker.ipcCallId + )[0]; + if (call) { + call.downstreamMatched = true; + mainRequestCalls.set(requestId, call); + } else { + invalidReasons.push( + 'main-phase-preload-performance-marker-missing' + ); + } + } + timeline.push({ + ...(call + ? { + action: call.marker.action, + categoryType: call.marker.categoryType, + contentType: call.marker.contentType, + ipcCallId: call.marker.ipcCallId, + } + : {}), + boundary, + durationMs: durationMs as number | null, + epochMs, + phase, + requestId, + type: 'xtream-main-phase', + }); + return true; + }, + acceptPreload(senderWebContentsId, receivedEpochMs, input) { + if ( + !active || + senderWebContentsId !== expectedWebContentsId || + !protocol.isEpoch(receivedEpochMs) + ) { + return helpers.invalidate( + 'preload-marker-sender-or-epoch-invalid' + ); + } + if (receivedEpochMs < captureStartedEpochMs) { + return helpers.invalidate( + 'preload-marker-before-capture-start' + ); + } + if (receivedEpochMs < lastReceivedEpochMs) { + return helpers.invalidate( + 'preload-marker-sender-or-epoch-invalid' + ); + } + const marker = protocol.parseMarker(input); + if (!marker) { + return helpers.invalidate('preload-marker-invalid-schema'); + } + if (marker.sourceEpochMs < captureStartedEpochMs) { + return helpers.invalidate( + 'preload-marker-before-capture-start' + ); + } + if ( + marker.sourceEpochMs - receivedEpochMs >= + preloadSourceReceiptSkewToleranceMs + ) { + return helpers.invalidate( + 'preload-marker-source-after-receipt' + ); + } + const normalizedReceivedEpochMs = Math.max( + receivedEpochMs, + marker.sourceEpochMs + ); + if (marker.sourceEpochMs < lastSourceEpochMs) { + return helpers.invalidate( + 'preload-marker-source-epoch-regressed' + ); + } + const key = protocol.correlationKey(marker); + if (!key) { + return helpers.invalidate( + 'preload-marker-correlation-key-invalid' + ); + } + lastReceivedEpochMs = receivedEpochMs; + lastSourceEpochMs = marker.sourceEpochMs; + if (marker.boundary === 'start') { + if (calls.has(marker.ipcCallId) || activeByKey.has(key)) { + helpers.quarantine(key); + return helpers.invalidate('preload-marker-duplicate-key'); + } + const call: XtreamIpcCallState = { + completionOutcome: null, + downstreamMatched: false, + ended: false, + key, + marker, + quarantined: false, + receivedEpochMs: normalizedReceivedEpochMs, + }; + calls.set(marker.ipcCallId, call); + activeByKey.set(key, call); + const waiting = pendingRequests.get(key) ?? []; + if (waiting.length === 1 && waiting[0]) { + protocol.applyMarkerIdentity(waiting[0], marker); + call.downstreamMatched = true; + pendingRequests.delete(key); + } else if (waiting.length > 1) { + helpers.quarantine(key); + return helpers.invalidate('preload-marker-duplicate-key'); + } + } else { + const call = calls.get(marker.ipcCallId); + if ( + !call || + call.ended || + call.quarantined || + call.key !== key || + !protocol.sameMarkerIdentity(call.marker, marker) + ) { + return helpers.invalidate('preload-marker-unmatched-end'); + } + call.ended = true; + call.completionOutcome = marker.outcome; + activeByKey.delete(key); + } + timeline.push({ + action: marker.action, + boundary: marker.boundary, + categoryType: marker.categoryType, + contentType: marker.contentType, + epochMs: normalizedReceivedEpochMs, + ipcCallId: marker.ipcCallId, + itemCount: marker.itemCount, + method: marker.method, + operationId: marker.operationId, + outcome: marker.outcome, + playlistId: marker.playlistId, + sessionId: marker.sessionId, + sourceEpochMs: marker.sourceEpochMs, + type: 'xtream-preload-marker', + }); + return true; + }, + matchDatabaseCancel(input) { + if (!active) { + return null; + } + try { + return helpers.matchKey(protocol.readCancelKey(input)); + } catch { + helpers.invalidate('preload-marker-correlation-input-invalid'); + return protocol.unavailableIdentity( + 'xtream-preload-performance-marker-ambiguous' + ); + } + }, + matchDatabaseRequest(input) { + if (!active) { + return null; + } + try { + return helpers.matchKey(protocol.readRequestKey(input)); + } catch { + helpers.invalidate('preload-marker-correlation-input-invalid'); + return protocol.unavailableIdentity( + 'xtream-preload-performance-marker-ambiguous' + ); + } + }, + start(webContentsId, startedEpochMs) { + const validStart = protocol.isEpoch(startedEpochMs); + active = true; + captureStartedEpochMs = validStart + ? startedEpochMs + : Number.POSITIVE_INFINITY; + expectedWebContentsId = webContentsId; + lastMainEpochMs = captureStartedEpochMs; + lastReceivedEpochMs = captureStartedEpochMs; + lastSourceEpochMs = captureStartedEpochMs; + timeline = []; + invalidReasons = []; + if (!validStart) { + invalidReasons.push('capture-start-epoch-invalid'); + } + calls.clear(); + activeByKey.clear(); + pendingRequests.clear(); + quarantinedKeys.clear(); + mainPhases.clear(); + mainRequestCalls.clear(); + }, + stop() { + for (const call of calls.values()) { + if (!call.ended && !call.quarantined) { + invalidReasons.push('preload-marker-unclosed'); + } + if ( + call.ended && + (call.marker.method === 'xtreamRequest' || + call.marker.method === 'xtreamCancelSession') + ) { + const linkedRequest = [...mainRequestCalls.entries()].find( + ([, candidate]) => candidate === call + ); + if (!linkedRequest) { + invalidReasons.push('preload-main-linkage-missing'); + continue; + } + const lifecycleInvalidReason = mainLifecycle.validate({ + method: call.marker.method, + outcome: call.completionOutcome, + phases: helpers.mainPhasesForRequest(linkedRequest[0]), + }); + if (lifecycleInvalidReason) { + invalidReasons.push(lifecycleInvalidReason); + } + } + } + for (const phase of mainPhases.values()) { + if (phase.endEpochMs === null) { + invalidReasons.push('main-phase-unclosed'); + } + } + for (const key of mainPhases.keys()) { + const requestId = key.slice(0, key.indexOf('\u0000')); + if (!mainRequestCalls.has(requestId)) { + invalidReasons.push( + 'main-phase-preload-performance-marker-missing' + ); + } + } + active = false; + expectedWebContentsId = null; + return Object.freeze({ + invalidReasons: Object.freeze([...invalidReasons]), + quarantinedKeyCount: quarantinedKeys.size, + timeline: Object.freeze([...timeline]), + }); + }, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-protocol.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-protocol.ts new file mode 100644 index 000000000..09e8998c7 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-marker-protocol.ts @@ -0,0 +1,333 @@ +import type { XtreamIpcMarkerCaptureOptions } from './xtream-ipc-marker-events.model'; +import type { XtreamIpcRequestIdentity } from './xtream-ipc-marker-events.model'; + +export interface SafeXtreamPreloadMarker { + action: string | null; + boundary: 'end' | 'start'; + categoryType: string | null; + contentType: string | null; + ipcCallId: number; + itemCount: number | null; + method: string; + operationId: string | null; + outcome: 'error' | 'success' | null; + playlistId: string | null; + schemaVersion: number; + sessionId: string | null; + sourceEpochMs: number; +} + +export interface SafeXtreamMainPhase { + boundary: 'end' | 'start'; + durationMs: number | null; + epochMs: number; + phase: string; + requestId: string; +} + +export interface XtreamIpcMarkerProtocol { + applyMarkerIdentity( + identity: XtreamIpcRequestIdentity, + marker: SafeXtreamPreloadMarker + ): void; + correlationKey(marker: SafeXtreamPreloadMarker): string | null; + isEpoch(value: unknown): value is number; + parseMainPhase(input: unknown): SafeXtreamMainPhase | null; + parseMarker(input: unknown): SafeXtreamPreloadMarker | null; + readCancelKey(input: unknown): string | null | undefined; + readRequestKey(input: unknown): string | null | undefined; + sameMarkerIdentity( + start: SafeXtreamPreloadMarker, + end: SafeXtreamPreloadMarker + ): boolean; + unavailableIdentity(reason: string): XtreamIpcRequestIdentity; +} + +export function createXtreamIpcMarkerProtocol( + options: XtreamIpcMarkerCaptureOptions +): XtreamIpcMarkerProtocol { + type JsonRecord = Record; + const markerKeys = new Set([ + 'schemaVersion', + 'sourceEpochMs', + 'ipcCallId', + 'method', + 'boundary', + 'outcome', + 'playlistId', + 'operationId', + 'sessionId', + 'action', + 'categoryType', + 'contentType', + 'itemCount', + ]); + const actions = new Set(options.actions); + const mainPhases = new Set(options.mainPhases); + const methods = new Set(options.methods); + const categoryTypes = new Set(['live', 'movies', 'series']); + const contentTypes = new Set(['live', 'movie', 'series']); + const identifierPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + const rendererRequestId = /^xtream-[0-9a-z]+-[1-9][0-9]*$/; + const internalRequestId = + /^xtream-main-(?:fallback-[1-9][0-9]*|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/; + const operationToMethod: Readonly> = { + DB_DELETE_PLAYLIST: 'dbDeletePlaylist', + DB_DELETE_XTREAM_CONTENT: 'dbDeleteXtreamContent', + DB_GET_CATEGORIES: 'dbGetCategories', + DB_GET_CONTENT: 'dbGetContent', + DB_GLOBAL_SEARCH: 'dbGlobalSearch', + DB_RESTORE_XTREAM_USER_DATA: 'dbRestoreXtreamUserData', + DB_SAVE_CATEGORIES: 'dbSaveCategories', + DB_SAVE_CONTENT: 'dbSaveContent', + DB_SEARCH_CONTENT: 'dbSearchContent', + }; + const helpers = { + correlationKey(marker: SafeXtreamPreloadMarker): string | null { + const values: Array = [marker.method]; + switch (marker.method) { + case 'xtreamRequest': + if (marker.action === null) return null; + values.push(marker.ipcCallId); + break; + case 'xtreamCancelSession': + values.push(marker.sessionId); + break; + case 'dbGetCategories': + case 'dbSaveCategories': + values.push(marker.playlistId, marker.categoryType); + break; + case 'dbGetContent': + values.push(marker.playlistId, marker.contentType); + break; + case 'dbSaveContent': + values.push( + marker.playlistId, + marker.contentType, + marker.operationId + ); + break; + case 'dbDeletePlaylist': + values.push( + marker.playlistId, + marker.operationId ?? '' + ); + break; + case 'dbDeleteXtreamContent': + case 'dbRestoreXtreamUserData': + values.push(marker.playlistId, marker.operationId); + break; + case 'dbSearchContent': + values.push(marker.playlistId); + break; + case 'dbGlobalSearch': + break; + case 'dbCancelOperation': + values.push(marker.operationId); + break; + default: + return null; + } + return values.some((value) => value === null) + ? null + : JSON.stringify(values); + }, + isEpoch(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isFinite(value) && value > 0 + ); + }, + isIdentifier(value: unknown): value is string { + return typeof value === 'string' && identifierPattern.test(value); + }, + isNullableIdentifier(value: unknown): value is string | null { + return value === null || helpers.isIdentifier(value); + }, + isNullableMember( + value: unknown, + allowed: ReadonlySet + ): value is string | null { + return ( + value === null || + (typeof value === 'string' && allowed.has(value)) + ); + }, + isRecord(value: unknown): value is JsonRecord { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) + ); + }, + }; + return { + applyMarkerIdentity(identity, marker): void { + identity.ipcCallId = marker.ipcCallId; + identity.operationId = marker.operationId; + identity.operationIdUnavailableReason = null; + identity.sourceEpochMs = marker.sourceEpochMs; + }, + correlationKey(marker): string | null { + return helpers.correlationKey(marker); + }, + isEpoch(value): value is number { + return helpers.isEpoch(value); + }, + parseMainPhase(input): SafeXtreamMainPhase | null { + try { + if (!helpers.isRecord(input)) { + return null; + } + const boundary = input['boundary']; + const durationMs = input['durationMs']; + const epochMs = input['epochMs']; + const phase = input['phase']; + const requestId = input['requestId']; + if ( + (boundary !== 'start' && boundary !== 'end') || + !helpers.isEpoch(epochMs) || + typeof phase !== 'string' || + !mainPhases.has(phase) || + typeof requestId !== 'string' || + requestId.length > 64 || + (!rendererRequestId.test(requestId) && + !internalRequestId.test(requestId)) || + (boundary === 'start' + ? durationMs !== null + : typeof durationMs !== 'number' || + !Number.isFinite(durationMs) || + durationMs < 0) + ) { + return null; + } + return { + boundary, + durationMs: durationMs as number | null, + epochMs, + phase, + requestId, + }; + } catch { + return null; + } + }, + parseMarker(input): SafeXtreamPreloadMarker | null { + try { + if ( + !helpers.isRecord(input) || + Reflect.ownKeys(input).length !== markerKeys.size || + Reflect.ownKeys(input).some( + (key) => typeof key !== 'string' || !markerKeys.has(key) + ) + ) { + return null; + } + const boundary = input['boundary']; + const outcome = input['outcome']; + const itemCount = input['itemCount']; + if ( + input['schemaVersion'] !== options.schemaVersion || + !helpers.isEpoch(input['sourceEpochMs']) || + !Number.isSafeInteger(input['ipcCallId']) || + Number(input['ipcCallId']) < 1 || + typeof input['method'] !== 'string' || + !methods.has(input['method']) || + (boundary !== 'start' && boundary !== 'end') || + (boundary === 'start' + ? outcome !== null + : outcome !== 'success' && outcome !== 'error') || + !helpers.isNullableIdentifier(input['playlistId']) || + !helpers.isNullableIdentifier(input['operationId']) || + !helpers.isNullableIdentifier(input['sessionId']) || + !helpers.isNullableMember(input['action'], actions) || + !helpers.isNullableMember( + input['categoryType'], + categoryTypes + ) || + !helpers.isNullableMember( + input['contentType'], + contentTypes + ) || + (itemCount !== null && + (!Number.isSafeInteger(itemCount) || + Number(itemCount) < 0 || + Number(itemCount) > 1_000_000)) + ) { + return null; + } + return input as unknown as SafeXtreamPreloadMarker; + } catch { + return null; + } + }, + readCancelKey(input): string | null | undefined { + if (!helpers.isRecord(input) || input['type'] !== 'cancel') { + return undefined; + } + return helpers.correlationKey({ + method: 'dbCancelOperation', + operationId: + typeof input['operationId'] === 'string' + ? input['operationId'] + : null, + } as SafeXtreamPreloadMarker); + }, + readRequestKey(input): string | null | undefined { + if ( + !helpers.isRecord(input) || + typeof input['operation'] !== 'string' + ) { + return undefined; + } + const method = operationToMethod[input['operation']]; + if (!method) { + return undefined; + } + const payload = helpers.isRecord(input['payload']) + ? input['payload'] + : {}; + return helpers.correlationKey({ + method, + playlistId: + typeof payload['playlistId'] === 'string' + ? payload['playlistId'] + : null, + operationId: + typeof payload['operationId'] === 'string' + ? payload['operationId'] + : null, + categoryType: + typeof payload['type'] === 'string' && + categoryTypes.has(payload['type']) + ? payload['type'] + : null, + contentType: + typeof payload['type'] === 'string' && + contentTypes.has(payload['type']) + ? payload['type'] + : null, + } as SafeXtreamPreloadMarker); + }, + sameMarkerIdentity(start, end): boolean { + return ( + start.ipcCallId === end.ipcCallId && + start.method === end.method && + start.playlistId === end.playlistId && + start.operationId === end.operationId && + start.sessionId === end.sessionId && + start.action === end.action && + start.categoryType === end.categoryType && + start.contentType === end.contentType && + start.itemCount === end.itemCount + ); + }, + unavailableIdentity(reason): XtreamIpcRequestIdentity { + return { + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: reason, + sourceEpochMs: null, + }; + }, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-producer-identity.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-producer-identity.ts new file mode 100644 index 000000000..7572c02fd --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-producer-identity.ts @@ -0,0 +1,96 @@ +import { + XTREAM_WORKER_REQUEST_IDENTITY_KIND, + xtreamWorkerRequestIdentityKind, +} from './xtream-worker-request-identity'; + +export const XTREAM_IPC_PRODUCER_NAMESPACE = { + LEGACY_PRELOAD: 'legacy-preload', + XTREAM_PRELOAD: 'xtream-preload', +} as const; + +export type XtreamIpcProducerNamespace = + (typeof XTREAM_IPC_PRODUCER_NAMESPACE)[keyof typeof XTREAM_IPC_PRODUCER_NAMESPACE]; + +export interface XtreamIpcProducerIdentity { + readonly ipcCallId: number; + readonly namespace: XtreamIpcProducerNamespace; + readonly sourceEpochMs: number; +} + +const LEGACY_PRELOAD_METHODS = new Set([ + 'dbGetAppPlaylist', + 'dbUpsertAppPlaylist', +]); +const XTREAM_PRELOAD_METHODS = new Set([ + 'dbCancelOperation', + 'dbDeletePlaylist', + 'dbDeleteXtreamContent', + 'dbGetCategories', + 'dbGetContent', + 'dbGlobalSearch', + 'dbRestoreXtreamUserData', + 'dbSaveCategories', + 'dbSaveContent', + 'dbSearchContent', + 'xtreamCancelSession', + 'xtreamRequest', +]); + +export function xtreamIpcProducerNamespaceForMethod( + method: string +): XtreamIpcProducerNamespace | null { + if (LEGACY_PRELOAD_METHODS.has(method)) { + return XTREAM_IPC_PRODUCER_NAMESPACE.LEGACY_PRELOAD; + } + if (XTREAM_PRELOAD_METHODS.has(method)) { + return XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD; + } + return null; +} + +export function xtreamIpcProducerNamespaceForWorkerOperation( + operation: string +): XtreamIpcProducerNamespace | null { + const kind = xtreamWorkerRequestIdentityKind(operation); + if (kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.LEGACY_PRELOAD) { + return XTREAM_IPC_PRODUCER_NAMESPACE.LEGACY_PRELOAD; + } + if ( + kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.PRODUCER_IPC || + kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.PRODUCER_OPERATION + ) { + return XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD; + } + return null; +} + +export function xtreamIpcProducerIdentityKey( + namespace: XtreamIpcProducerNamespace, + ipcCallId: number +): string { + return `${namespace}\0${ipcCallId}`; +} + +export function hasDistinctMonotonicXtreamIpcProducerIdentities( + identities: readonly XtreamIpcProducerIdentity[] +): boolean { + const keys = identities.map(({ ipcCallId, namespace }) => + xtreamIpcProducerIdentityKey(namespace, ipcCallId) + ); + if (new Set(keys).size !== keys.length) return false; + for (const namespace of Object.values(XTREAM_IPC_PRODUCER_NAMESPACE)) { + const ordered = identities + .filter((identity) => identity.namespace === namespace) + .sort((left, right) => left.ipcCallId - right.ipcCallId); + if ( + ordered.some( + ({ sourceEpochMs }, index) => + index > 0 && + sourceEpochMs < Number(ordered[index - 1]?.sourceEpochMs) + ) + ) { + return false; + } + } + return true; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-worker-identity-binding.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-worker-identity-binding.ts new file mode 100644 index 000000000..6291794f3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-worker-identity-binding.ts @@ -0,0 +1,78 @@ +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import type { XtreamIpcAttributionSpan } from './xtream-phase-attribution'; +import { + xtreamIpcProducerIdentityKey, + xtreamIpcProducerNamespaceForMethod, + xtreamIpcProducerNamespaceForWorkerOperation, +} from './xtream-ipc-producer-identity'; + +const DB_METHOD_OPERATION = Object.freeze({ + dbDeletePlaylist: 'DB_DELETE_PLAYLIST', + dbDeleteXtreamContent: 'DB_DELETE_XTREAM_CONTENT', + dbGetAppPlaylist: 'DB_GET_APP_PLAYLIST', + dbGetCategories: 'DB_GET_CATEGORIES', + dbGetContent: 'DB_GET_CONTENT', + dbRestoreXtreamUserData: 'DB_RESTORE_XTREAM_USER_DATA', + dbSaveCategories: 'DB_SAVE_CATEGORIES', + dbSaveContent: 'DB_SAVE_CONTENT', + dbUpsertAppPlaylist: 'DB_UPSERT_APP_PLAYLIST', +}); +const WORKER_OPERATIONS = new Set(Object.values(DB_METHOD_OPERATION)); + +export function xtreamWorkerOperationForIpcMethod( + method: string +): string | null { + return ( + DB_METHOD_OPERATION[method as keyof typeof DB_METHOD_OPERATION] ?? null + ); +} + +export function assertXtreamIpcWorkerIdentityBinding( + spans: readonly XtreamIpcAttributionSpan[], + requests: readonly WorkerRequestPerformanceMetrics[] +): void { + const workerByIdentity = new Map(); + for (const request of requests) { + if (request.ipcCallId === null) continue; + const namespace = xtreamIpcProducerNamespaceForWorkerOperation( + typeof request.operation === 'string' ? request.operation : '' + ); + if (namespace === null) invalid(); + const key = xtreamIpcProducerIdentityKey(namespace, request.ipcCallId); + if (workerByIdentity.has(key)) invalid(); + workerByIdentity.set(key, request); + } + const boundWorkerIdentities = new Set(); + for (const span of spans) { + if (span.boundary !== 'request') continue; + const operation = xtreamWorkerOperationForIpcMethod(span.method); + if (operation === null) continue; + const namespace = xtreamIpcProducerNamespaceForMethod(span.method); + if (namespace === null) invalid(); + const key = xtreamIpcProducerIdentityKey(namespace, span.ipcCallId); + const worker = workerByIdentity.get(key); + if ( + !worker || + worker.operation !== operation || + worker.sourceEpochMs !== span.sourceEpochMs + ) { + invalid(); + } + boundWorkerIdentities.add(key); + } + if ( + boundWorkerIdentities.size !== workerByIdentity.size || + [...workerByIdentity].some( + ([key, request]) => + !boundWorkerIdentities.has(key) || + typeof request.operation !== 'string' || + !WORKER_OPERATIONS.has(request.operation) + ) + ) { + invalid(); + } +} + +function invalid(): never { + throw new Error('invalid Xtream IPC worker identity binding'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ipc-worker-identity.fixture.ts b/apps/electron-backend-e2e/src/performance/xtream-ipc-worker-identity.fixture.ts new file mode 100644 index 000000000..fc685c982 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ipc-worker-identity.fixture.ts @@ -0,0 +1,85 @@ +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import type { + XtreamIpcAttributionSpan, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import { xtreamWorkerOperationForIpcMethod } from './xtream-ipc-worker-identity-binding'; + +interface ProducerIdentity { + readonly ipcCallId: number; + readonly sourceEpochMs: number; +} + +export function bindFixtureIpcWorkerIdentities( + input: XtreamPhaseAttributionInput, + requests: readonly WorkerRequestPerformanceMetrics[] +): { + readonly phaseCapture: XtreamPhaseAttributionInput; + readonly requests: readonly WorkerRequestPerformanceMetrics[]; +} { + const identities = new Map< + WorkerRequestPerformanceMetrics, + ProducerIdentity + >(); + const methods = new Set( + input.ipcSpans + .filter(({ boundary }) => boundary === 'request') + .map(({ method }) => method) + .filter( + (method) => xtreamWorkerOperationForIpcMethod(method) !== null + ) + ); + for (const method of methods) { + const operation = xtreamWorkerOperationForIpcMethod(method); + if (operation === null) fixtureError(); + const calls = requestSpans(input.ipcSpans, method); + const workers = requests + .filter((request) => request.operation === operation) + .sort( + (left, right) => + Number(left.requestReceivedEpochMs) - + Number(right.requestReceivedEpochMs) || + String(left.requestId).localeCompare( + String(right.requestId) + ) + ); + if (calls.length !== workers.length) fixtureError(); + calls.forEach((call, index) => { + const worker = workers[index]; + if ( + !worker || + call.outcome !== (worker.success ? 'success' : 'error') || + worker.requestReceivedEpochMs === null || + call.sourceEpochMs > worker.requestReceivedEpochMs + ) { + fixtureError(); + } + identities.set(worker, { + ipcCallId: call.ipcCallId, + sourceEpochMs: call.sourceEpochMs, + }); + }); + } + const bound = requests.map((request) => { + const identity = identities.get(request); + if (identity) return { ...request, ...identity }; + if (request.ipcCallId !== null || request.sourceEpochMs !== null) { + fixtureError(); + } + return request; + }); + return { phaseCapture: input, requests: bound }; +} + +function requestSpans( + spans: readonly XtreamIpcAttributionSpan[], + method: string +): readonly XtreamIpcAttributionSpan[] { + return spans + .filter((span) => span.boundary === 'request' && span.method === method) + .sort((left, right) => left.ipcCallId - right.ipcCallId); +} + +function fixtureError(): never { + throw new Error('invalid Xtream IPC worker identity fixture'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler-background-ui.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler-background-ui.spec.ts new file mode 100644 index 000000000..e31ee1fe8 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler-background-ui.spec.ts @@ -0,0 +1,201 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; +import { createXtreamAssemblerFixture } from './xtream-iteration-assembler.fixture'; +import { assembleXtreamRawIteration } from './xtream-iteration-assembler'; +import { summarizeXtreamUiActionSamples } from './xtream-ui-action-probe'; + +describe('Xtream raw iteration assembler background UI evidence', () => { + it('binds samples to the full refresh lifecycle after the delete prelude', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.BACKGROUND_UI + ); + const request = input.mainCapture.requests.find( + ({ operation }) => operation === 'DB_DELETE_XTREAM_CONTENT' + ); + const background = input.terminal.backgroundProbe; + const operation = input.terminal.operation; + const storeTerminalEpochMs = + input.rendererCapture.probe.storeTerminalEpochMs; + assert.ok(request?.workStartedEpochMs); + assert.ok(request.workEndedEpochMs); + assert.ok(background); + assert.ok(operation); + assert.ok(storeTerminalEpochMs); + const step = (storeTerminalEpochMs - request.workEndedEpochMs) / 12; + assert.ok(step > 0); + const samples = background.result.samples.map((sample, index) => { + const startEpochMs = + Number(request.workEndedEpochMs) + step * (index * 2 + 1); + const endEpochMs = startEpochMs + step; + return { + ...sample, + endEpochMs, + latencyMs: step, + startEpochMs, + }; + }); + const summary = summarizeXtreamUiActionSamples(samples); + + assert.doesNotThrow(() => + assembleXtreamRawIteration({ + ...input, + terminal: { + ...input.terminal, + backgroundProbe: { + ...background, + completedEpochMs: samples.at(-1)?.endEpochMs ?? 0, + result: { + ...summary, + dashboardPaintVisibility: 'visible-without-overlay', + samples, + }, + startedEpochMs: request.workStartedEpochMs, + }, + operation: { + ...operation, + terminalEpochMs: request.workEndedEpochMs, + }, + }, + }) + ); + }); + + it('accepts samples that finish before the delete prelude completes', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.BACKGROUND_UI + ); + const request = input.mainCapture.requests.find( + ({ operation }) => operation === 'DB_DELETE_XTREAM_CONTENT' + ); + const background = input.terminal.backgroundProbe; + const operation = input.terminal.operation; + assert.ok(request?.workStartedEpochMs); + assert.ok(request.workEndedEpochMs); + assert.ok(background); + assert.ok(operation); + const step = + (request.workEndedEpochMs - background.startedEpochMs) / 12; + assert.ok(step > 0); + const samples = background.result.samples.map((sample, index) => { + const startEpochMs = + background.startedEpochMs + step * (index * 2 + 1); + const endEpochMs = startEpochMs + step; + return { + ...sample, + endEpochMs, + latencyMs: step, + startEpochMs, + }; + }); + const summary = summarizeXtreamUiActionSamples(samples); + assert.ok( + Number(samples.at(-1)?.endEpochMs) < request.workEndedEpochMs + ); + + assert.doesNotThrow(() => + assembleXtreamRawIteration({ + ...input, + terminal: { + ...input.terminal, + backgroundProbe: { + ...background, + completedEpochMs: samples.at(-1)?.endEpochMs ?? 0, + result: { + ...summary, + dashboardPaintVisibility: 'visible-without-overlay', + samples, + }, + }, + operation: { + ...operation, + terminalEpochMs: request.workEndedEpochMs, + }, + }, + }) + ); + }); + + it('rejects overlapping samples', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.BACKGROUND_UI + ); + const background = input.terminal.backgroundProbe; + assert.ok(background); + const samples = background.result.samples.map((sample, index, all) => { + if (index !== 1) return sample; + const previous = all[0]; + assert.ok(previous); + const startEpochMs = previous.endEpochMs - 0.1; + return { + ...sample, + latencyMs: sample.endEpochMs - startEpochMs, + startEpochMs, + }; + }); + const summary = summarizeXtreamUiActionSamples(samples); + + assert.throws( + () => + assembleXtreamRawIteration({ + ...input, + terminal: { + ...input.terminal, + backgroundProbe: { + ...background, + result: { + ...summary, + dashboardPaintVisibility: + 'visible-without-overlay', + samples, + }, + }, + }, + }), + /xtream-iteration-assembly-invalid/ + ); + }); + + it('rejects a sample after the final refresh store terminal', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.BACKGROUND_UI + ); + const background = input.terminal.backgroundProbe; + const storeTerminalEpochMs = + input.rendererCapture.probe.storeTerminalEpochMs; + assert.ok(background); + assert.ok(storeTerminalEpochMs); + const samples = background.result.samples.map((sample, index, all) => { + if (index !== all.length - 1) return sample; + const endEpochMs = storeTerminalEpochMs + 0.1; + return { + ...sample, + endEpochMs, + latencyMs: endEpochMs - sample.startEpochMs, + }; + }); + const summary = summarizeXtreamUiActionSamples(samples); + + assert.throws( + () => + assembleXtreamRawIteration({ + ...input, + terminal: { + ...input.terminal, + backgroundProbe: { + ...background, + completedEpochMs: samples.at(-1)?.endEpochMs ?? 0, + result: { + ...summary, + dashboardPaintVisibility: + 'visible-without-overlay', + samples, + }, + }, + }, + }), + /xtream-iteration-assembly-invalid/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler-fixture-runtime.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler-fixture-runtime.ts new file mode 100644 index 000000000..a86c9d8e3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler-fixture-runtime.ts @@ -0,0 +1,319 @@ +import type { RendererPerformancePhaseEvent } from '@iptvnator/shared/logging'; + +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import type { + MainTimelineRecord, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { + XTREAM_IPC_PRODUCER_NAMESPACE, + xtreamIpcProducerNamespaceForWorkerOperation, +} from './xtream-ipc-producer-identity'; +import type { XtreamRawIterationResult } from './xtream-summary-contract'; +import type { + XtreamBackgroundProbeEvidence, + XtreamScenarioTerminalEvidence, +} from './xtream-scenario-driver'; +import { + summarizeXtreamUiActionSamples, + type XtreamUiActionProbeResult, + type XtreamUiActionSample, +} from './xtream-ui-action-probe'; + +const STORE_PREFIX = 'store.'; + +export function createLegacyTimeline( + requests: readonly WorkerRequestPerformanceMetrics[] +): MainTimelineRecord[] { + const legacy = requests.filter( + (request) => + xtreamIpcProducerNamespaceForWorkerOperation( + request.operation ?? '' + ) === XTREAM_IPC_PRODUCER_NAMESPACE.LEGACY_PRELOAD + ); + return legacy.flatMap((request) => { + const source = requireFixtureValue(request.sourceEpochMs); + const received = requireFixtureValue(request.requestReceivedEpochMs); + const response = request.responseEpochMs; + const completion = response + 0.1; + const shared = { + ipcCallId: requireFixtureValue(request.ipcCallId), + operation: requireFixtureValue(request.operation), + playlistId: requireFixtureValue(request.playlistId), + }; + return [ + { + ...shared, + epochMs: (source + received) / 2, + requestId: requireFixtureValue(request.requestId), + sourceEpochMs: source, + type: 'db-request', + }, + { + ...shared, + epochMs: response, + requestId: requireFixtureValue(request.requestId), + success: true, + type: 'db-response', + }, + { + ...shared, + epochMs: completion, + sourceEpochMs: completion, + success: true, + type: 'preload-performance-success', + }, + ]; + }); +} + +export function createRendererCapture( + raw: XtreamRawIterationResult, + background: XtreamUiActionProbeResult | undefined +) { + const storeEvents = raw.phaseCapture.events.filter(({ phase }) => + phase.startsWith(STORE_PREFIX) + ); + const itemsByPhaseId = new Map( + storeEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ correlationId, itemCount }) => [correlationId, itemCount]) + ); + const performancePhaseEvents = storeEvents.map( + (event): RendererPerformancePhaseEvent => { + const itemCount = itemsByPhaseId.get(event.correlationId) ?? null; + return { + boundary: event.boundary, + epochMs: event.epochMs, + ...(itemCount === null + ? {} + : { metadata: { items: itemCount } }), + monotonicMs: event.epochMs, + ...(event.boundary === 'end' ? { outcome: 'success' } : {}), + phase: event.phase as RendererPerformancePhaseEvent['phase'], + phaseId: event.correlationId, + schemaVersion: 1, + }; + } + ); + const probe = { + cancelEligibleProgressEpochMs: + raw.cancellation.clickEpochMs === null + ? null + : raw.cancellation.clickEpochMs - 0.1, + cancellationClickEpochMs: raw.cancellation.clickEpochMs, + dbCancellationTerminalEpochMs: + raw.cancellation.authoritativeTerminalEpochMs, + dbOperationEvents: [], + domReadyEpochMs: raw.phaseCapture.uiPaintEpochMs - 0.1, + frameGapsMs: [20, 45], + heartbeatDelaysMs: [5, 12], + invalidReason: null, + longTasksMs: [30, 75], + operationStartEpochMs: raw.phaseCapture.operationStartEpochMs, + performancePhaseEvents, + routeReadyEpochMs: raw.phaseCapture.operationStartEpochMs + 0.1, + scenarioId: raw.scenarioId, + storeTerminalEpochMs: Math.max( + ...storeEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ epochMs }) => epochMs) + ), + terminalEpochMs: raw.phaseCapture.terminalEpochMs, + uiPaintedEpochMs: raw.phaseCapture.uiPaintEpochMs, + }; + return { + captureCutoffEpochMs: raw.phaseCapture.terminalEpochMs + 0.5, + consoleErrorBreakdown: + raw.scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT + ? { + databaseSaveCancellation: 1, + fetchContentCancellation: 1, + initializationCancellation: 1, + unexpected: 0, + } + : { + databaseSaveCancellation: 0, + fetchContentCancellation: 0, + initializationCancellation: 0, + unexpected: 0, + }, + consoleErrorCount: + raw.scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT ? 3 : 0, + cpuProfilePath: null, + frameGap: { + count: 2, + max: 45, + mean: 32.5, + median: 32.5, + min: 20, + p95: 43.75, + p99: 44.75, + }, + heapSnapshotPath: null, + heartbeatDelay: { + count: 2, + max: 12, + mean: 8.5, + median: 8.5, + min: 5, + p95: 11.65, + p99: 11.93, + }, + longTask: { + count: 2, + max: 75, + mean: 52.5, + median: 52.5, + min: 30, + p95: 72.75, + p99: 74.55, + }, + measurementStartEpochMs: raw.phaseCapture.operationStartEpochMs, + pageErrorCount: 0, + peakHeapUsedBytes: raw.renderer.peakHeapUsedBytes, + postGcHeapUsedBytes: raw.renderer.postGcHeapUsedBytes, + probe, + storeToPaintMs: + raw.phaseCapture.uiPaintEpochMs - probe.storeTerminalEpochMs, + targetId: raw.renderer.targetId, + tracePath: null, + ...(background ? { background } : {}), + }; +} + +export function createBackgroundEvidence( + raw: XtreamRawIterationResult, + requests: readonly WorkerRequestPerformanceMetrics[] +): XtreamBackgroundProbeEvidence | null { + if (raw.scenarioId !== XTREAM_SCENARIO_ID.BACKGROUND_UI) return null; + const request = requireFixtureValue( + requests.find( + ({ operation }) => operation === 'DB_DELETE_XTREAM_CONTENT' + ) + ); + const start = requireFixtureValue(request.workStartedEpochMs); + const deleteEnd = requireFixtureValue(request.workEndedEpochMs); + const refreshEnd = Math.max( + ...raw.phaseCapture.events + .filter( + ({ boundary, phase }) => + boundary === 'end' && + phase === 'store.xtream-import-terminal' + ) + .map(({ epochMs }) => epochMs) + ); + const step = (refreshEnd - deleteEnd) / 12; + const categories = [ + 'action', + 'navigation', + 'navigation', + 'action', + 'search', + ] as const; + const kinds = [ + 'type-filter', + 'dashboard', + 'sources', + 'name-a-z', + 'global-search', + ] as const; + const samples: XtreamUiActionSample[] = kinds.map((kind, index) => ({ + activeAfter: true, + activeBefore: true, + category: requireFixtureValue(categories[index]), + endEpochMs: deleteEnd + step * (index * 2 + 2), + kind, + latencyMs: step, + startEpochMs: deleteEnd + step * (index * 2 + 1), + })); + const summary = summarizeXtreamUiActionSamples(samples); + return { + completedEpochMs: requireFixtureValue(samples.at(-1)).endEpochMs, + operation: 'delete-xtream-content', + operationId: requireFixtureValue(request.operationId), + playlistId: requireFixtureValue(request.playlistId), + result: { + ...summary, + dashboardPaintVisibility: 'visible-without-overlay', + samples, + }, + startedEpochMs: start, + }; +} + +export function createTerminal( + raw: XtreamRawIterationResult, + background: XtreamBackgroundProbeEvidence | null +): XtreamScenarioTerminalEvidence { + const cancellation = + raw.cancellation.clickEpochMs === null + ? null + : { + armedEpochMs: raw.phaseCapture.operationStartEpochMs - 0.5, + clickEpochMs: raw.cancellation.clickEpochMs, + current: 6_000, + operationId: requireFixtureValue( + raw.cancellation.failedOperationId + ), + progressEpochMs: raw.cancellation.clickEpochMs - 0.1, + threshold: 6_000, + total: 60_000, + }; + const operation = + background === null + ? null + : { + active: false, + operation: 'delete-xtream-content' as const, + operationId: background.operationId, + playlistId: background.playlistId, + startedEpochMs: background.startedEpochMs - 0.1, + terminalEpochMs: + requireFixtureValue(background.result.samples[0]) + .startEpochMs - + requireFixtureValue(background.result.samples[0]) + .latencyMs, + terminalStatus: 'completed' as const, + }; + return { + backgroundProbe: background, + cancellation, + operation, + scenarioId: raw.scenarioId, + terminalEpochMs: raw.phaseCapture.terminalEpochMs - 0.1, + }; +} + +export function operationOutcomes( + requests: readonly WorkerRequestPerformanceMetrics[] +) { + const counts = new Map(); + for (const request of requests) { + const outcome = request.success ? 'success' : 'error'; + const key = `${request.operation}\0${outcome}`; + counts.set(key, (counts.get(key) ?? 0) + 1); + } + return [...counts].map(([key, count]) => { + const [operation, outcome] = key.split('\0'); + return { + count, + operation: requireFixtureValue(operation), + outcome: outcome as 'error' | 'success', + }; + }); +} + +export function scenarioOrdinal(scenarioId: XtreamScenarioId): number { + return Object.values(XTREAM_SCENARIO_ID).indexOf(scenarioId) + 1; +} + +function requireFixtureValue(value: T | null | undefined): T { + if (value === null || value === undefined) { + throw new Error('invalid Xtream assembler fixture'); + } + return value; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.fixture.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.fixture.ts new file mode 100644 index 000000000..360fcf8f7 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.fixture.ts @@ -0,0 +1,337 @@ +import { + XTREAM_ITERATION_KIND, + type XtreamIterationDefinition, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { iteration } from './xtream-benchmark-report.fixtures'; +import type { + WorkerCaptureMetrics, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import type { + XtreamIpcAttributionSpan, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import { XTREAM_ATTRIBUTION_PHASE as PHASE } from './xtream-phase-inventory'; +import { + XTREAM_IPC_PRODUCER_NAMESPACE, + xtreamIpcProducerNamespaceForMethod, + xtreamIpcProducerNamespaceForWorkerOperation, +} from './xtream-ipc-producer-identity'; +import type { XtreamIterationAssemblyInput } from './xtream-iteration-assembler'; +import { + createBackgroundEvidence, + createLegacyTimeline, + createRendererCapture, + createTerminal, + operationOutcomes, + scenarioOrdinal, +} from './xtream-iteration-assembler-fixture-runtime'; +import type { XtreamIpcTimelineRecord } from './xtream-ipc-marker-events.model'; +import type { XtreamRawIterationResult } from './xtream-summary-contract'; + +const MAIN_PHASES = new Set([ + PHASE.CANCEL_SESSION, + PHASE.JSON_TRANSFORM, + PHASE.NETWORK_TOTAL, + PHASE.RESPONSE_READY, +]); + +export function createXtreamAssemblerFixture( + scenarioId: XtreamScenarioId +): XtreamIterationAssemblyInput { + const definition: XtreamIterationDefinition = { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId, + }; + const raw = iteration(definition, scenarioOrdinal(scenarioId)); + const worker = createWorker(raw); + const background = createBackgroundEvidence(raw, worker.requests); + const captureStartedEpochMs = raw.phaseCapture.operationStartEpochMs - 1; + const captureCutoffEpochMs = raw.phaseCapture.terminalEpochMs + 1; + const rendererCapture = createRendererCapture(raw, background?.result); + return { + catalogVerification: { + databaseStateValid: true, + fixtureIdentityValid: true, + providerCategoryCount: + raw.catalogVerification.providerCategoryCount, + providerItemCount: raw.catalogVerification.providerItemCount, + verifiedAfterCapture: true, + }, + definition, + diagnosticArtifacts: null, + mainCapture: { + cancelTimeline: + raw.cancellationEvidence?.timeline.map((entry) => ({ + ...entry, + })) ?? [], + capture: { + cpuProfilePath: null, + cpuSystemMicros: raw.main.cpuSystemMicros, + cpuUserMicros: raw.main.cpuUserMicros, + eventLoopDelay: { + maxMs: raw.main.eventLoopDelayMaxMs, + p95Ms: raw.main.eventLoopDelayP95Ms, + p99Ms: raw.main.eventLoopDelayP99Ms, + }, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + raw.main.eventLoopUtilizationUnavailableReason, + heapSnapshotPath: null, + memory: { + peakHeapUsedBytes: raw.main.peakHeapUsedBytes, + peakRssBytes: raw.main.peakRssBytes, + postGcHeapUsedBytes: raw.main.postGcHeapUsedBytes, + postGcRssBytes: raw.main.peakRssBytes - 1, + }, + rendererWindow: { + responsiveEvents: raw.main.responsiveEvents, + rss: { + identity: { creationTime: 1, pid: 42 }, + missingSampleCount: 0, + peakRssBytes: raw.renderer.peakRssBytes, + unavailableReason: null, + validSampleCount: 2, + }, + unresponsiveEvents: raw.main.unresponsiveEvents, + windowIdentity: { + browserWindowId: raw.main.browserWindowId, + webContentsId: raw.main.webContentsId, + }, + }, + rssScope: + 'electron-main-process-including-worker-threads-and-native-memory', + timeline: createLegacyTimeline(worker.requests), + workers: [worker], + }, + captureCutoffEpochMs, + captureGeneration: 1, + captureStartedEpochMs, + captureStoppedEpochMs: captureCutoffEpochMs + 1, + databaseWorkerCount: 1, + invalidReasons: { + capture: [], + databaseWorkerCancel: [], + xtreamIpc: [], + }, + ipcTimeline: createXtreamTimeline(raw), + lateDatabaseRequestCount: 0, + lateEventCount: 0, + measurementStartedEpochMs: + raw.phaseCapture.operationStartEpochMs - 0.5, + operationOutcomes: operationOutcomes(worker.requests), + playlistRefreshWorkerCount: 0, + quarantinedIpcMarkerCount: 0, + requests: worker.requests, + rollover: null, + workers: [worker], + }, + process: { + electronPid: 20_000 + scenarioOrdinal(scenarioId), + freshProcessVerified: true, + launchId: `launch-${scenarioOrdinal(scenarioId)}`, + profileDirectorySha256: String(scenarioOrdinal(scenarioId)).repeat( + 64 + ), + }, + rendererCapture, + terminal: createTerminal(raw, background), + trigger: { + scenarioId, + triggerEpochMs: raw.phaseCapture.operationStartEpochMs + 0.1, + }, + }; +} + +function createWorker(raw: XtreamRawIterationResult): WorkerCaptureMetrics { + const metric = raw.databaseWorker; + return { + cancelPostedEpochMs: raw.cancellation.databaseDispatchEpochMs, + cpuSystemMicros: metric.cpuSystemMicros, + cpuUserMicros: metric.cpuUserMicros, + eventLoopDelay: { + maxMs: metric.eventLoopDelayMaxMs, + p95Ms: metric.eventLoopDelayP95Ms, + p99Ms: metric.eventLoopDelayP99Ms, + }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: metric.eventLoopUtilization, + externalMemorySampleCount: 2, + heapUsedSampleCount: 2, + kind: 'database.worker', + operationId: null, + ordinal: metric.ordinal, + peakExternalBytes: metric.peakExternalBytes, + peakExternalUnavailableReason: null, + peakHeapUsedBytes: metric.peakHeapUsedBytes, + peakHeapUsedUnavailableReason: null, + playlistId: null, + postGcHeapUnavailableReason: null, + postGcHeapUsedBytes: metric.postGcHeapUsedBytes, + profilePath: null, + requests: metric.requests.evidence.map(cloneRequest), + responseEpochMs: null, + snapshotPath: null, + terminatedEpochMs: null, + }; +} + +function cloneRequest( + request: WorkerRequestPerformanceMetrics +): WorkerRequestPerformanceMetrics { + return { + ...request, + eventLoopDelay: + request.eventLoopDelay === null + ? null + : { ...request.eventLoopDelay }, + phaseEvents: request.phaseEvents.map((event) => ({ + ...event, + ...(event.metadata ? { metadata: { ...event.metadata } } : {}), + })), + }; +} + +function createXtreamTimeline( + raw: XtreamRawIterationResult +): XtreamIpcTimelineRecord[] { + const main = raw.phaseCapture.events + .filter(({ phase }) => MAIN_PHASES.has(phase)) + .map((event): XtreamIpcTimelineRecord => ({ + action: event.providerAction, + boundary: event.boundary, + categoryType: event.categoryType, + contentType: event.contentType, + durationMs: event.durationMs, + epochMs: event.epochMs, + phase: event.phase, + requestId: event.correlationId, + type: 'xtream-main-phase', + })); + const markers = xtreamCalls(raw).flatMap(({ request, response }) => [ + marker(raw, request, 'start'), + marker(raw, response, 'end'), + ]); + return [...main, ...markers].sort((left, right) => { + return left.epochMs - right.epochMs; + }); +} + +function marker( + raw: XtreamRawIterationResult, + span: XtreamIpcAttributionSpan, + boundary: 'end' | 'start' +): XtreamIpcTimelineRecord { + const semantic = markerSemantic(raw, span); + return { + action: semantic.providerAction, + boundary, + categoryType: semantic.categoryType, + contentType: semantic.contentType, + epochMs: span.endEpochMs, + ipcCallId: span.ipcCallId, + itemCount: null, + method: span.method, + operationId: + span.method === 'dbCancelOperation' + ? raw.cancellation.failedOperationId + : semantic.operationId, + outcome: boundary === 'start' ? null : span.outcome, + playlistId: semantic.playlistId, + sessionId: span.method.startsWith('xtream') ? 'session-1' : null, + sourceEpochMs: + boundary === 'start' ? span.sourceEpochMs : span.startEpochMs, + type: 'xtream-preload-marker', + }; +} + +function markerSemantic( + raw: XtreamRawIterationResult, + span: XtreamIpcAttributionSpan +) { + if (span.method === 'xtreamRequest') { + const providers = providerGroups(raw.phaseCapture); + const calls = xtreamCalls(raw).filter( + ({ request }) => request.method === 'xtreamRequest' + ); + const index = calls.findIndex( + ({ request }) => request.ipcCallId === span.ipcCallId + ); + const provider = providers[index]?.[0]; + return { + categoryType: null, + contentType: null, + operationId: null, + playlistId: null, + providerAction: provider?.providerAction ?? null, + }; + } + const request = raw.databaseWorker.requests.evidence.find( + (candidate) => + candidate.ipcCallId === span.ipcCallId && + xtreamIpcProducerNamespaceForWorkerOperation( + candidate.operation ?? '' + ) === XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD + ); + const event = raw.phaseCapture.events.find( + (candidate) => + candidate.correlationId === request?.requestId && + candidate.boundary === 'start' + ); + return { + categoryType: event?.categoryType ?? null, + contentType: event?.contentType ?? null, + operationId: request?.operationId ?? null, + playlistId: request?.playlistId ?? null, + providerAction: null, + }; +} + +function xtreamCalls(raw: XtreamRawIterationResult) { + return pairIpc(raw.phaseCapture.ipcSpans).filter( + ({ request }) => + xtreamIpcProducerNamespaceForMethod(request.method) === + XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD + ); +} + +function pairIpc(spans: readonly XtreamIpcAttributionSpan[]) { + const groups = new Map(); + for (const span of spans) { + groups.set(span.correlationId, [ + ...(groups.get(span.correlationId) ?? []), + span, + ]); + } + return [...groups.values()].map((group) => { + const request = group.find(({ boundary }) => boundary === 'request'); + const response = group.find(({ boundary }) => boundary === 'response'); + if (!request || !response) { + throw new Error('invalid Xtream assembler IPC fixture'); + } + return { request, response }; + }); +} + +function providerGroups(capture: XtreamPhaseAttributionInput) { + const groups = new Map(); + for (const event of capture.events.filter( + ({ phase }) => phase === PHASE.NETWORK_TOTAL + )) { + groups.set(event.correlationId, [ + ...(groups.get(event.correlationId) ?? []), + event, + ]); + } + return [...groups.values()].sort((left, right) => { + const leftStart = left[0]; + const rightStart = right[0]; + if (!leftStart || !rightStart) { + throw new Error('invalid Xtream assembler provider fixture'); + } + return leftStart.epochMs - rightStart.epochMs; + }); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.spec.ts new file mode 100644 index 000000000..fc529d740 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.spec.ts @@ -0,0 +1,330 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { createXtreamAssemblerFixture } from './xtream-iteration-assembler.fixture'; +import { assembleXtreamRawIteration } from './xtream-iteration-assembler'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; + +describe('Xtream raw iteration assembler', () => { + for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) { + it(`assembles measured ${scenarioId} evidence without inventing fields`, () => { + const input = createXtreamAssemblerFixture( + scenarioId as XtreamScenarioId + ); + + const result = assembleXtreamRawIteration(input); + + assert.doesNotThrow(() => assertXtreamIterationResult(result)); + assert.equal( + result.processIdentity.captureGeneration, + input.mainCapture.captureGeneration + ); + assert.equal( + result.databaseWorker.requests.evidence.length, + input.mainCapture.requests.length + ); + assert.equal(result.validity.databaseWorkerCount, 1); + assert.equal(result.validity.playlistWorkerCount, 0); + assert.equal(result.main.cpuMetricScope, 'electron-main-thread'); + assert.equal( + result.databaseWorker.cpuMetricScope, + 'sum-valid-request-thread-cpu' + ); + assert.equal( + result.databaseWorker.cpuUserMicros, + result.databaseWorker.requests.evidence.reduce( + (total, request) => + total + + (request.invalidReason === null + ? Number(request.threadCpuUserMicros) + : 0), + 0 + ) + ); + assert.equal( + result.cancellationEvidence === null, + scenarioId !== XTREAM_SCENARIO_ID.CANCEL_IMPORT + ); + }); + } + + it('fails closed on capture invalidation instead of manufacturing a valid result', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + + assert.throws( + () => + assembleXtreamRawIteration({ + ...input, + mainCapture: { + ...input.mainCapture, + invalidReasons: { + ...input.mainCapture.invalidReasons, + capture: ['database-worker-activity-after-cutoff'], + }, + }, + }), + /xtream-iteration-assembly-invalid/ + ); + }); + + it('accepts a coarse trigger clock from the same millisecond as renderer start', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const operationStartEpochMs = + input.rendererCapture.probe.operationStartEpochMs + 0.05; + + assert.doesNotThrow(() => + assembleXtreamRawIteration({ + ...input, + rendererCapture: { + ...input.rendererCapture, + measurementStartEpochMs: operationStartEpochMs, + probe: { + ...input.rendererCapture.probe, + operationStartEpochMs, + }, + }, + trigger: { + ...input.trigger, + triggerEpochMs: Math.floor(operationStartEpochMs), + }, + }) + ); + }); + + it('rejects a coarse trigger clock from the millisecond before renderer start', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const operationStartEpochMs = + input.rendererCapture.probe.operationStartEpochMs + 0.05; + + assert.throws( + () => + assembleXtreamRawIteration({ + ...input, + rendererCapture: { + ...input.rendererCapture, + probe: { + ...input.rendererCapture.probe, + operationStartEpochMs, + }, + }, + trigger: { + ...input.trigger, + triggerEpochMs: Math.floor(operationStartEpochMs) - 1, + }, + }), + /xtream-iteration-assembly-invalid/ + ); + }); + + it('accepts a late delete driver observation after renderer and main capture cutoffs', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.DELETE_LARGE + ); + + assert.doesNotThrow(() => + assembleXtreamRawIteration({ + ...input, + terminal: { + ...input.terminal, + terminalEpochMs: + input.mainCapture.captureCutoffEpochMs + 100, + }, + }) + ); + }); + + it('accepts renderer cancellation delivery after the authoritative database response', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ); + const authoritativeTerminalEpochMs = input.mainCapture.requests.find( + ({ operation, success }) => + operation === 'DB_SAVE_CONTENT' && !success + )?.responseEpochMs; + assert.equal(typeof authoritativeTerminalEpochMs, 'number'); + const dbCancellationTerminalEpochMs = + Number(authoritativeTerminalEpochMs) + 0.25; + assert.ok( + dbCancellationTerminalEpochMs < + Number(input.rendererCapture.probe.uiPaintedEpochMs) + ); + + assert.doesNotThrow(() => + assembleXtreamRawIteration({ + ...input, + rendererCapture: { + ...input.rendererCapture, + probe: { + ...input.rendererCapture.probe, + dbCancellationTerminalEpochMs, + }, + }, + }) + ); + }); + + it('accepts main observing a network cancel response after the next renderer IPC starts', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ); + const databaseCancelStart = input.mainCapture.ipcTimeline.find( + ({ boundary, method }) => + boundary === 'start' && method === 'dbCancelOperation' + ); + assert.ok(databaseCancelStart); + assert.equal(typeof databaseCancelStart.sourceEpochMs, 'number'); + const rendererHandoffEpochMs = Number( + databaseCancelStart.sourceEpochMs + ); + const delayedMainObservationEpochMs = rendererHandoffEpochMs + 0.1; + assert.ok(delayedMainObservationEpochMs < databaseCancelStart.epochMs); + + assert.doesNotThrow(() => + assembleXtreamRawIteration({ + ...input, + mainCapture: { + ...input.mainCapture, + ipcTimeline: input.mainCapture.ipcTimeline.map((record) => + record.boundary === 'end' && + record.method === 'xtreamCancelSession' + ? { + ...record, + epochMs: delayedMainObservationEpochMs, + sourceEpochMs: rendererHandoffEpochMs, + } + : record + ), + }, + }) + ); + }); + + it('accepts the non-authoritative cancel IPC returning after the database acknowledgement', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ); + const authoritativeTerminalEpochMs = input.mainCapture.requests.find( + ({ operation, success }) => + operation === 'DB_SAVE_CONTENT' && !success + )?.responseEpochMs; + assert.equal(typeof authoritativeTerminalEpochMs, 'number'); + const responseSourceEpochMs = + Number(authoritativeTerminalEpochMs) + 0.1; + const responseObservedEpochMs = responseSourceEpochMs + 0.1; + assert.ok( + responseObservedEpochMs < + Number(input.rendererCapture.probe.uiPaintedEpochMs) + ); + + assert.doesNotThrow(() => + assembleXtreamRawIteration({ + ...input, + mainCapture: { + ...input.mainCapture, + ipcTimeline: input.mainCapture.ipcTimeline + .map((record) => + record.boundary === 'end' && + record.method === 'dbCancelOperation' + ? { + ...record, + epochMs: responseObservedEpochMs, + sourceEpochMs: responseSourceEpochMs, + } + : record + ) + .sort((left, right) => left.epochMs - right.epochMs), + }, + }) + ); + }); + + it('binds store-to-paint duration to the renderer terminal and paint epochs', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ); + + assert.doesNotThrow(() => assembleXtreamRawIteration(input)); + assert.throws( + () => + assembleXtreamRawIteration({ + ...input, + rendererCapture: { + ...input.rendererCapture, + storeToPaintMs: + input.rendererCapture.storeToPaintMs + 0.1, + }, + }), + /xtream-iteration-assembly-invalid/ + ); + }); + + it('includes trailing delete database and IPC evidence after the visible paint', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.DELETE_LARGE + ); + const rendererTerminalEpochMs = + input.rendererCapture.probe.uiPaintedEpochMs; + + const result = assembleXtreamRawIteration({ + ...input, + rendererCapture: { + ...input.rendererCapture, + probe: { + ...input.rendererCapture.probe, + terminalEpochMs: rendererTerminalEpochMs, + }, + }, + }); + + assert.ok( + result.phaseCapture.terminalEpochMs > rendererTerminalEpochMs + ); + assert.equal( + result.phases.totalMs, + result.phaseCapture.terminalEpochMs - + result.phaseCapture.operationStartEpochMs + ); + }); + + it('binds diagnostic durations and worker ordinal only from validated artifact evidence', () => { + const input = createXtreamAssemblerFixture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + + assert.throws( + () => + assembleXtreamRawIteration({ + ...input, + definition: { + ...input.definition, + eligibleForComparison: false, + kind: 'diagnostic', + runId: 'diagnostic', + }, + diagnosticArtifacts: { + artifacts: [], + directorySha256: '0'.repeat(64), + profiles: { + databaseWorker: { + durationMicros: 1, + ordinal: 1, + }, + main: { durationMicros: 1 }, + renderer: { durationMicros: 1 }, + }, + } as never, + }), + /xtream-iteration-assembly-invalid/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.ts new file mode 100644 index 000000000..e2c6ddb53 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembler.ts @@ -0,0 +1,259 @@ +import { createHash } from 'node:crypto'; + +import type { XtreamIterationDefinition } from './xtream-benchmark-contract'; +import { + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, +} from './xtream-benchmark-contract'; +import type { XtreamCatalogVerification } from './xtream-catalog-database-verification'; +import { + assertXtreamDiagnosticArtifactEvidence, + type XtreamDiagnosticArtifactEvidence, +} from './xtream-diagnostic-artifacts'; +import { freezeDeep } from './xtream-exact-schema'; +import { + assertXtreamMainEvidenceGates, + createXtreamDatabaseWorkerMetrics, + createXtreamMainMetrics, + createXtreamRendererMetrics, +} from './xtream-iteration-assembly-metrics'; +import { deriveXtreamPhaseCapture } from './xtream-iteration-assembly-phases'; +import { assembleXtreamScenarioEvidence } from './xtream-iteration-assembly-scenarios'; +import { assertXtreamIterationEvidenceBinding } from './xtream-iteration-evidence-binding'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; +import type { XtreamMainCaptureResult } from './m3u-refresh-main-capture'; +import { attributeXtreamPhases } from './xtream-phase-attribution'; +import type { XtreamRendererCaptureMetrics } from './xtream-renderer-capture'; +import { validXtreamRendererConsoleErrorEvidence } from './xtream-renderer-console-errors'; +import { assertCompleteXtreamRendererProbe } from './xtream-renderer-probe'; +import { + XTREAM_SCENARIO_DATABASE_REQUEST_COUNT, + type XtreamScenarioTerminalEvidence, + type XtreamScenarioTriggerEvidence, +} from './xtream-scenario-driver'; +import { + XTREAM_NOT_APPLICABLE, + type XtreamCatalogVerification as XtreamSummaryCatalogVerification, + type XtreamIterationProcessIdentity, + type XtreamRawIterationResult, +} from './xtream-summary-contract'; + +export interface XtreamIterationProcessEvidence { + readonly electronPid: number; + readonly freshProcessVerified: true; + readonly launchId: string; + readonly profileDirectorySha256: string; +} + +export interface XtreamIterationAssemblyInput { + readonly catalogVerification: XtreamCatalogVerification; + readonly definition: XtreamIterationDefinition; + readonly diagnosticArtifacts: XtreamDiagnosticArtifactEvidence | null; + readonly mainCapture: XtreamMainCaptureResult; + readonly process: XtreamIterationProcessEvidence; + readonly rendererCapture: XtreamRendererCaptureMetrics; + readonly terminal: XtreamScenarioTerminalEvidence; + readonly trigger: XtreamScenarioTriggerEvidence; +} + +export function assembleXtreamRawIteration( + input: XtreamIterationAssemblyInput +): XtreamRawIterationResult { + try { + return assembleStrict(input); + } catch { + throw new Error('xtream-iteration-assembly-invalid'); + } +} + +function assembleStrict( + input: XtreamIterationAssemblyInput +): XtreamRawIterationResult { + assertInputBoundary(input); + const diagnostic = validateDiagnosticEvidence(input); + assertXtreamMainEvidenceGates(input.mainCapture); + assertCompleteXtreamRendererProbe(input.rendererCapture.probe); + const phaseCapture = deriveXtreamPhaseCapture( + input.definition.scenarioId, + input.mainCapture, + input.rendererCapture + ); + const phases = attributeXtreamPhases(phaseCapture); + const scenario = assembleXtreamScenarioEvidence({ + main: input.mainCapture, + phaseCapture, + renderer: input.rendererCapture, + scenarioId: input.definition.scenarioId, + terminal: input.terminal, + trigger: input.trigger, + }); + const context = { + diagnostic, + kind: input.definition.kind, + }; + const raw: XtreamRawIterationResult = { + ...input.definition, + cancellation: scenario.cancellation, + cancellationEvidence: scenario.cancellationEvidence, + catalogVerification: copyCatalogVerification(input.catalogVerification), + databaseWorker: createXtreamDatabaseWorkerMetrics( + input.mainCapture, + context + ), + main: createXtreamMainMetrics(input.mainCapture, context), + phaseCapture, + phases, + playlistRefreshWorker: { + instantiated: false, + reason: XTREAM_NOT_APPLICABLE.PLAYLIST_REFRESH_WORKER, + }, + processIdentity: createProcessIdentity(input), + renderer: createXtreamRendererMetrics( + input.mainCapture, + input.rendererCapture, + context, + scenario.backgroundMetrics + ), + validity: { + actionContractValid: true, + catalogContractValid: true, + databaseWorkerCount: input.mainCapture.databaseWorkerCount, + dbPhaseContractValid: true, + diagnosticArtifactsValid: + input.definition.kind === XTREAM_ITERATION_KIND.DIAGNOSTIC + ? true + : null, + lateDatabaseRequestCount: + input.mainCapture.lateDatabaseRequestCount, + playlistWorkerCount: input.mainCapture.playlistRefreshWorkerCount, + rendererTargetCount: 1, + }, + }; + assertXtreamIterationEvidenceBinding(raw); + assertXtreamIterationResult(raw); + return freezeDeep(raw); +} + +function assertInputBoundary(input: XtreamIterationAssemblyInput): void { + const scenarioId = input.definition.scenarioId; + if ( + !Object.values(XTREAM_SCENARIO_ID).includes(scenarioId) || + input.mainCapture.requests.length !== + XTREAM_SCENARIO_DATABASE_REQUEST_COUNT[scenarioId] || + !validXtreamRendererConsoleErrorEvidence( + input.rendererCapture.consoleErrorCount, + input.rendererCapture.consoleErrorBreakdown, + scenarioId + ) || + input.rendererCapture.pageErrorCount !== 0 || + input.rendererCapture.storeToPaintMs !== + requireEpoch(input.rendererCapture.probe.uiPaintedEpochMs) - + requireEpoch(input.rendererCapture.probe.storeTerminalEpochMs) + ) { + invalid(); + } + const catalog = input.catalogVerification; + if ( + catalog.databaseStateValid !== true || + catalog.fixtureIdentityValid !== true || + catalog.verifiedAfterCapture !== true || + catalog.providerCategoryCount !== 100 || + catalog.providerItemCount !== 100_000 + ) { + invalid(); + } +} + +function copyCatalogVerification( + catalog: XtreamCatalogVerification +): XtreamSummaryCatalogVerification { + return { + databaseStateValid: catalog.databaseStateValid, + fixtureIdentityValid: catalog.fixtureIdentityValid, + providerCategoryCount: catalog.providerCategoryCount as 100, + providerItemCount: catalog.providerItemCount as 100_000, + verifiedAfterCapture: catalog.verifiedAfterCapture, + }; +} + +function validateDiagnosticEvidence( + input: XtreamIterationAssemblyInput +): XtreamDiagnosticArtifactEvidence | null { + if (input.definition.kind !== XTREAM_ITERATION_KIND.DIAGNOSTIC) { + if ( + input.diagnosticArtifacts !== null || + input.mainCapture.capture.cpuProfilePath !== null || + input.mainCapture.capture.heapSnapshotPath !== null || + input.rendererCapture.cpuProfilePath !== null || + input.rendererCapture.heapSnapshotPath !== null || + input.rendererCapture.tracePath !== null || + input.mainCapture.workers.some( + ({ profilePath, snapshotPath }) => + profilePath !== null || snapshotPath !== null + ) + ) { + invalid(); + } + return null; + } + assertXtreamDiagnosticArtifactEvidence(input.diagnosticArtifacts); + if ( + input.process.profileDirectorySha256 !== + input.diagnosticArtifacts.directorySha256 + ) { + invalid(); + } + return input.diagnosticArtifacts; +} + +function createProcessIdentity( + input: XtreamIterationAssemblyInput +): XtreamIterationProcessIdentity { + const process = input.process; + if ( + process.freshProcessVerified !== true || + !Number.isSafeInteger(process.electronPid) || + process.electronPid <= 0 || + !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(process.launchId) || + !/^[a-f0-9]{64}$/.test(process.profileDirectorySha256) + ) { + invalid(); + } + const captureGeneration = input.mainCapture.captureGeneration; + if (!Number.isSafeInteger(captureGeneration) || captureGeneration <= 0) { + invalid(); + } + const identity = { + captureGeneration, + electronPid: process.electronPid, + launchId: process.launchId, + profileDirectorySha256: process.profileDirectorySha256, + rendererTargetId: input.rendererCapture.targetId, + browserWindowId: + input.mainCapture.capture.rendererWindow.windowIdentity + .browserWindowId, + webContentsId: + input.mainCapture.capture.rendererWindow.windowIdentity + .webContentsId, + }; + return { + captureGeneration, + electronPid: process.electronPid, + freshProcess: true, + generationIdentitySha256: createHash('sha256') + .update(JSON.stringify(identity)) + .digest('hex'), + launchId: process.launchId, + profileDirectorySha256: process.profileDirectorySha256, + }; +} + +function requireEpoch(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) + invalid(); + return value; +} + +function invalid(): never { + throw new Error('invalid Xtream iteration assembly input'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-metrics.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-metrics.ts new file mode 100644 index 000000000..77c1efb25 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-metrics.ts @@ -0,0 +1,332 @@ +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import type { + XtreamMainCaptureResult, + XtreamMainCaptureOperationOutcome, +} from './m3u-refresh-main-capture'; +import { + XTREAM_ITERATION_KIND, + type XtreamIterationKind, +} from './xtream-benchmark-contract'; +import type { XtreamDiagnosticArtifactEvidence } from './xtream-diagnostic-evidence'; +import type { XtreamRendererCaptureMetrics } from './xtream-renderer-capture'; +import { + XTREAM_NOT_APPLICABLE, + XTREAM_MAIN_CPU_METRIC_SCOPE, + XTREAM_WORKER_CPU_METRIC_SCOPE, + XTREAM_WORKER_REQUEST_INVALID_REASON, + XTREAM_WORKER_REQUEST_METRIC_SCOPE, + XTREAM_WORKER_WHOLE_METRIC_SCOPE, + type XtreamDatabaseWorkerMetrics, + type XtreamMainMetrics, + type XtreamRendererMetrics, + type XtreamWorkerRequestAccounting, +} from './xtream-summary-contract'; +import { deriveXtreamWorkerRequestWindowProxy } from './xtream-worker-request-proxy'; + +interface MetricContext { + readonly diagnostic: XtreamDiagnosticArtifactEvidence | null; + readonly kind: XtreamIterationKind; +} + +export function createXtreamMainMetrics( + main: XtreamMainCaptureResult, + context: MetricContext +): XtreamMainMetrics { + const memory = main.capture.memory; + const window = main.capture.rendererWindow; + return { + ...profile(context, 'main'), + browserWindowId: window.windowIdentity.browserWindowId, + cpuMetricScope: XTREAM_MAIN_CPU_METRIC_SCOPE.ELECTRON_MAIN_THREAD, + cpuSystemMicros: main.capture.cpuSystemMicros, + cpuUserMicros: main.capture.cpuUserMicros, + eventLoopDelayMaxMs: main.capture.eventLoopDelay.maxMs, + eventLoopDelayP95Ms: main.capture.eventLoopDelay.p95Ms, + eventLoopDelayP99Ms: main.capture.eventLoopDelay.p99Ms, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + XTREAM_NOT_APPLICABLE.MAIN_EVENT_LOOP_UTILIZATION, + peakHeapUsedBytes: memory.peakHeapUsedBytes, + peakRssBytes: memory.peakRssBytes, + postGcHeapUsedBytes: requireNumber(memory.postGcHeapUsedBytes), + responsiveEvents: window.responsiveEvents, + unresponsiveEvents: window.unresponsiveEvents, + webContentsId: window.windowIdentity.webContentsId, + }; +} + +export function createXtreamRendererMetrics( + main: XtreamMainCaptureResult, + renderer: XtreamRendererCaptureMetrics, + context: MetricContext, + background: { + readonly actionLatencyMs: number; + readonly dashboardPaintVisibility: 'visible-without-overlay'; + readonly navigationLatencyMs: number; + readonly searchLatencyMs: number; + } | null +): XtreamRendererMetrics { + const rss = main.capture.rendererWindow.rss; + if ( + rss.unavailableReason !== null || + rss.missingSampleCount !== 0 || + rss.validSampleCount <= 0 + ) { + invalid(); + } + return { + ...profile(context, 'renderer'), + actionLatencyMs: background?.actionLatencyMs ?? null, + consoleErrors: [], + dashboardPaintVisibility: background?.dashboardPaintVisibility ?? null, + frameGapMaxMs: requireDistribution(renderer.frameGap, 'max', true), + frameGapP95Ms: requireDistribution(renderer.frameGap, 'p95', true), + heartbeatMaxMs: requireDistribution( + renderer.heartbeatDelay, + 'max', + true + ), + heartbeatP95Ms: requireDistribution( + renderer.heartbeatDelay, + 'p95', + true + ), + longTaskCount: renderer.longTask.count, + longTaskMaxMs: + renderer.longTask.count === 0 + ? 0 + : requireDistribution(renderer.longTask, 'max', true), + navigationLatencyMs: background?.navigationLatencyMs ?? null, + peakHeapUsedBytes: renderer.peakHeapUsedBytes, + peakRssBytes: requireNumber(rss.peakRssBytes), + postGcHeapUsedBytes: renderer.postGcHeapUsedBytes, + searchLatencyMs: background?.searchLatencyMs ?? null, + storeToPaintMs: renderer.storeToPaintMs, + targetId: renderer.targetId, + }; +} + +export function createXtreamDatabaseWorkerMetrics( + main: XtreamMainCaptureResult, + context: MetricContext +): XtreamDatabaseWorkerMetrics { + const worker = main.workers[0]; + if ( + main.workers.length !== 1 || + !worker || + worker.kind !== 'database.worker' || + worker.heapUsedSampleCount <= 0 || + worker.externalMemorySampleCount <= 0 || + worker.peakHeapUsedUnavailableReason !== null || + worker.peakExternalUnavailableReason !== null || + worker.postGcHeapUnavailableReason !== null + ) { + invalid(); + } + const requests = createRequestAccounting(worker.requests); + const threadCpu = sumXtreamRequestThreadCpu(requests.evidence); + if ( + context.diagnostic && + context.diagnostic.profiles.databaseWorker.ordinal !== worker.ordinal + ) { + invalid(); + } + return { + ...profile(context, 'databaseWorker'), + cpuMetricScope: XTREAM_WORKER_CPU_METRIC_SCOPE.SUM_VALID_REQUEST_WORK, + cpuSystemMicros: threadCpu.system, + cpuUserMicros: threadCpu.user, + currentForCapture: true, + eventLoopDelayMaxMs: requests.eventLoopDelayMaxMs, + eventLoopDelayMetricScope: + XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY, + eventLoopDelayP95Ms: requests.eventLoopDelayP95Ms, + eventLoopDelayP99Ms: requests.eventLoopDelayP99Ms, + eventLoopUtilization: requireNumber(worker.eventLoopUtilization), + kind: 'database.worker', + ordinal: worker.ordinal, + peakExternalBytes: requireNumber(worker.peakExternalBytes), + peakHeapUsedBytes: requireNumber(worker.peakHeapUsedBytes), + postGcHeapUsedBytes: requireNumber(worker.postGcHeapUsedBytes), + requests, + rssBytes: null, + rssUnavailableReason: XTREAM_NOT_APPLICABLE.DATABASE_WORKER_RSS, + wholeWorkerMetricScope: + XTREAM_WORKER_WHOLE_METRIC_SCOPE.OPERATION_WINDOW, + }; +} + +function sumXtreamRequestThreadCpu( + requests: readonly WorkerRequestPerformanceMetrics[] +): { readonly system: number; readonly user: number } { + let system = 0; + let user = 0; + for (const request of requests) { + if ( + request.invalidReason === + XTREAM_WORKER_REQUEST_INVALID_REASON.OVERLAPPING_DATABASE_REQUESTS + ) { + continue; + } + if ( + request.invalidReason !== null || + request.threadCpuUnavailableReason !== null || + request.threadCpuSystemMicros === null || + request.threadCpuUserMicros === null + ) { + invalid(); + } + system += request.threadCpuSystemMicros; + user += request.threadCpuUserMicros; + } + if ( + !Number.isSafeInteger(system) || + system < 0 || + !Number.isSafeInteger(user) || + user < 0 + ) { + invalid(); + } + return Object.freeze({ system, user }); +} + +export function assertXtreamMainEvidenceGates( + main: XtreamMainCaptureResult +): void { + if ( + main.databaseWorkerCount !== 1 || + main.playlistRefreshWorkerCount !== 0 || + main.workers.length !== 1 || + main.workers[0]?.kind !== 'database.worker' || + main.lateDatabaseRequestCount !== 0 || + main.lateEventCount !== 0 || + main.quarantinedIpcMarkerCount !== 0 || + main.rollover !== null || + Object.values(main.invalidReasons).some( + (reasons) => reasons.length !== 0 + ) || + main.capture.workers.length !== main.workers.length || + main.capture.workers[0]?.ordinal !== main.workers[0]?.ordinal || + main.requests.length !== main.workers[0]?.requests.length || + !sameOutcomes(main.operationOutcomes, main.requests) + ) { + invalid(); + } +} + +function createRequestAccounting( + evidence: readonly WorkerRequestPerformanceMetrics[] +): XtreamWorkerRequestAccounting { + const snapshot = evidence.map(cloneRequest); + const proxy = deriveXtreamWorkerRequestWindowProxy(snapshot); + if (!proxy) invalid(); + const invalidSampleCount = snapshot.filter( + ({ invalidReason }) => + invalidReason === + XTREAM_WORKER_REQUEST_INVALID_REASON.OVERLAPPING_DATABASE_REQUESTS + ).length; + return { + evidence: Object.freeze(snapshot), + ...proxy, + invalidReasons: + invalidSampleCount === 0 + ? [] + : [ + { + count: invalidSampleCount, + reason: XTREAM_WORKER_REQUEST_INVALID_REASON.OVERLAPPING_DATABASE_REQUESTS, + }, + ], + invalidSampleCount, + metricScope: + XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY, + totalCount: snapshot.length, + validSampleCount: snapshot.length - invalidSampleCount, + }; +} + +function cloneRequest( + request: WorkerRequestPerformanceMetrics +): WorkerRequestPerformanceMetrics { + return { + ...request, + eventLoopDelay: + request.eventLoopDelay === null + ? null + : { ...request.eventLoopDelay }, + phaseEvents: request.phaseEvents.map((event) => ({ + ...event, + ...(event.metadata ? { metadata: { ...event.metadata } } : {}), + })), + }; +} + +function sameOutcomes( + actual: readonly XtreamMainCaptureOperationOutcome[], + requests: readonly WorkerRequestPerformanceMetrics[] +): boolean { + const expected = new Map(); + for (const request of requests) { + const key = `${request.operation}\0${ + request.success ? 'success' : 'error' + }`; + expected.set(key, (expected.get(key) ?? 0) + 1); + } + const observed = new Map( + actual.map(({ count, operation, outcome }) => [ + `${operation}\0${outcome}`, + count, + ]) + ); + return ( + actual.every(({ outcome }) => outcome !== 'pending') && + expected.size === observed.size && + [...expected].every(([key, count]) => observed.get(key) === count) + ); +} + +function profile( + context: MetricContext, + process: 'databaseWorker' | 'main' | 'renderer' +) { + if (context.kind !== XTREAM_ITERATION_KIND.DIAGNOSTIC) { + if (context.diagnostic !== null) invalid(); + return { + cpuProfileDurationMicros: null, + cpuProfileUnavailableReason: + XTREAM_NOT_APPLICABLE.PROFILE_OUTSIDE_DIAGNOSTIC, + } as const; + } + if (!context.diagnostic) invalid(); + const duration = + process === 'databaseWorker' + ? context.diagnostic.profiles.databaseWorker.durationMicros + : context.diagnostic.profiles[process].durationMicros; + return { + cpuProfileDurationMicros: duration, + cpuProfileUnavailableReason: null, + } as const; +} + +function requireDistribution( + value: { + readonly count: number; + readonly max: number | null; + readonly p95: number | null; + }, + key: 'max' | 'p95', + requireSamples: boolean +): number { + if (requireSamples && value.count <= 0) invalid(); + return requireNumber(value[key]); +} + +function requireNumber(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) + invalid(); + return value; +} + +function invalid(): never { + throw new Error('invalid Xtream assembly metrics evidence'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-phases.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-phases.ts new file mode 100644 index 000000000..80191208b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-phases.ts @@ -0,0 +1,376 @@ +import { pairRendererPerformancePhaseEvents } from './renderer-performance-phase-events'; +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import type { XtreamMainCaptureResult } from './m3u-refresh-main-capture'; +import type { + XtreamAttributionPhaseEvent, + XtreamIpcAttributionSpan, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import { + XTREAM_ATTRIBUTION_PHASE as PHASE, + XTREAM_PHASE_SEMANTIC_TYPE as SEMANTIC, + type XtreamPhaseSemanticType, +} from './xtream-phase-inventory'; +import { + semanticTypeFromXtreamPhaseSource, + xtreamDataSemanticSource, + type XtreamPhaseSemanticSource, +} from './xtream-phase-semantic-source'; +import { + XTREAM_IPC_PRODUCER_NAMESPACE, + xtreamIpcProducerIdentityKey, + xtreamIpcProducerNamespaceForWorkerOperation, +} from './xtream-ipc-producer-identity'; +import { xtreamWorkerOperationForIpcMethod } from './xtream-ipc-worker-identity-binding'; +import type { XtreamIpcTimelineRecord } from './xtream-ipc-marker-events.model'; +import type { XtreamRendererCaptureMetrics } from './xtream-renderer-capture'; + +const MAIN_PHASES = new Set([ + PHASE.CANCEL_SESSION, + PHASE.JSON_TRANSFORM, + PHASE.NETWORK_TOTAL, + PHASE.RESPONSE_READY, +]); +const LEGACY_METHODS = Object.freeze({ + DB_GET_APP_PLAYLIST: 'dbGetAppPlaylist', + DB_UPSERT_APP_PLAYLIST: 'dbUpsertAppPlaylist', +}); + +export function deriveXtreamPhaseCapture( + scenarioId: XtreamPhaseAttributionInput['scenarioId'], + main: XtreamMainCaptureResult, + renderer: XtreamRendererCaptureMetrics +): XtreamPhaseAttributionInput { + const operationStartEpochMs = renderer.probe.operationStartEpochMs; + const rendererTerminalEpochMs = requireEpoch( + renderer.probe.terminalEpochMs + ); + const uiPaintEpochMs = requireEpoch(renderer.probe.uiPaintedEpochMs); + const events = [ + ...mainPhaseEvents(main), + ...workerPhaseEvents(main.requests, main), + ...rendererPhaseEvents(renderer), + ]; + const ipc = ipcSpans(main); + const terminalEpochMs = Math.max( + rendererTerminalEpochMs, + ...events.map(({ epochMs }) => epochMs), + ...ipc.map(({ endEpochMs }) => endEpochMs) + ); + return Object.freeze({ + backgroundSearchMarkerObserved: events.some( + ({ phase }) => phase === PHASE.STORE_SEARCH_RESULTS + ), + events: Object.freeze(events), + ipcSpans: Object.freeze(ipc), + operationStartEpochMs, + scenarioId, + terminalEpochMs, + uiPaintEpochMs, + }); +} + +function mainPhaseEvents( + main: XtreamMainCaptureResult +): XtreamAttributionPhaseEvent[] { + return main.ipcTimeline + .filter( + ({ phase, type }) => + type === 'xtream-main-phase' && + typeof phase === 'string' && + MAIN_PHASES.has(phase) + ) + .map((record) => { + const source = { + categoryType: record.categoryType ?? null, + contentType: record.contentType ?? null, + providerAction: record.action ?? null, + } as XtreamPhaseSemanticSource; + return phaseEvent({ + boundary: record.boundary, + correlationId: requireText(record.requestId), + durationMs: record.durationMs ?? null, + epochMs: record.epochMs, + itemCount: null, + phase: requireText(record.phase), + source, + }); + }); +} + +function workerPhaseEvents( + requests: readonly WorkerRequestPerformanceMetrics[], + main: XtreamMainCaptureResult +): XtreamAttributionPhaseEvent[] { + return requests.flatMap((request) => { + const correlationId = requireText(request.requestId); + const source = workerSemanticSource(request, main); + return request.phaseEvents.map((event) => + phaseEvent({ + boundary: event.boundary, + correlationId, + durationMs: event.durationMs, + epochMs: event.epochMs, + itemCount: + event.boundary === 'end' + ? (event.metadata?.itemCount ?? null) + : null, + phase: event.phase, + source, + }) + ); + }); +} + +function workerSemanticSource( + request: WorkerRequestPerformanceMetrics, + main: XtreamMainCaptureResult +): XtreamPhaseSemanticSource { + const operation = requireText(request.operation); + const namespace = xtreamIpcProducerNamespaceForWorkerOperation(operation); + if (namespace !== XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD) { + return xtreamDataSemanticSource(null); + } + const marker = main.ipcTimeline.filter( + (record) => + record.type === 'xtream-preload-marker' && + record.boundary === 'start' && + record.ipcCallId === request.ipcCallId && + record.sourceEpochMs === request.sourceEpochMs && + xtreamWorkerOperationForIpcMethod(record.method ?? '') === operation + ); + if (marker.length !== 1 || !marker[0]) invalid(); + return { + categoryType: marker[0].categoryType ?? null, + contentType: marker[0].contentType ?? null, + providerAction: null, + } as XtreamPhaseSemanticSource; +} + +function rendererPhaseEvents( + renderer: XtreamRendererCaptureMetrics +): XtreamAttributionPhaseEvent[] { + return pairRendererPerformancePhaseEvents( + renderer.probe.performancePhaseEvents + ).flatMap((span) => { + const source = rendererSemanticSource(span.phase); + const itemCount = span.metadata?.items ?? null; + return [ + phaseEvent({ + boundary: 'start', + correlationId: span.phaseId, + durationMs: null, + epochMs: span.startedEpochMs, + itemCount: null, + phase: span.phase, + source, + }), + phaseEvent({ + boundary: 'end', + correlationId: span.phaseId, + durationMs: span.durationMs, + epochMs: span.endedEpochMs, + itemCount, + phase: span.phase, + source, + }), + ]; + }); +} + +function rendererSemanticSource(phase: string): XtreamPhaseSemanticSource { + const semantic: XtreamPhaseSemanticType | null = + phase === PHASE.STORE_PUBLISH_LIVE + ? SEMANTIC.LIVE_CONTENT + : phase === PHASE.STORE_PUBLISH_VOD + ? SEMANTIC.VOD_CONTENT + : phase === PHASE.STORE_PUBLISH_SERIES + ? SEMANTIC.SERIES_CONTENT + : null; + return xtreamDataSemanticSource(semantic); +} + +function ipcSpans(main: XtreamMainCaptureResult): XtreamIpcAttributionSpan[] { + const spans = xtreamIpcSpans(main); + for (const request of main.requests) { + const operation = request.operation ?? ''; + const method = LEGACY_METHODS[operation as keyof typeof LEGACY_METHODS]; + if (method) spans.push(...legacyIpcPair(main, request, method)); + } + return spans; +} + +function xtreamIpcSpans( + main: XtreamMainCaptureResult +): XtreamIpcAttributionSpan[] { + const groups = new Map(); + for (const marker of main.ipcTimeline) { + if ( + marker.type !== 'xtream-preload-marker' || + !Number.isSafeInteger(marker.ipcCallId) || + typeof marker.method !== 'string' + ) { + continue; + } + const key = xtreamIpcProducerIdentityKey( + XTREAM_IPC_PRODUCER_NAMESPACE.XTREAM_PRELOAD, + Number(marker.ipcCallId) + ); + groups.set(key, [...(groups.get(key) ?? []), marker]); + } + return [...groups.entries()].flatMap(([correlationId, markers]) => { + const start = markers.find(({ boundary }) => boundary === 'start'); + const end = markers.find(({ boundary }) => boundary === 'end'); + if ( + markers.length !== 2 || + !start || + !end || + start.method !== end.method || + start.outcome !== null || + !['error', 'success'].includes(String(end.outcome)) + ) { + invalid(); + } + const sourceEpochMs = requireEpoch(start.sourceEpochMs); + return [ + ipcSpan( + 'request', + correlationId, + start, + sourceEpochMs, + sourceEpochMs, + end.outcome as 'error' | 'success' + ), + ipcSpan( + 'response', + correlationId, + end, + requireEpoch(end.sourceEpochMs), + sourceEpochMs, + end.outcome as 'error' | 'success' + ), + ]; + }); +} + +function legacyIpcPair( + main: XtreamMainCaptureResult, + request: WorkerRequestPerformanceMetrics, + method: string +): XtreamIpcAttributionSpan[] { + const ipcCallId = requirePositiveInteger(request.ipcCallId); + const sourceEpochMs = requireEpoch(request.sourceEpochMs); + const requestTimeline = oneTimeline(main, request, 'db-request'); + const success = oneTimeline(main, request, 'preload-performance-success'); + const responseStartEpochMs = requireEpoch(request.responseEpochMs); + const correlationId = xtreamIpcProducerIdentityKey( + XTREAM_IPC_PRODUCER_NAMESPACE.LEGACY_PRELOAD, + ipcCallId + ); + return [ + { + boundary: 'request', + correlationId, + endEpochMs: requestTimeline.epochMs, + ipcCallId, + method, + outcome: 'success', + sourceEpochMs, + startEpochMs: sourceEpochMs, + }, + { + boundary: 'response', + correlationId, + endEpochMs: requireEpoch(success.sourceEpochMs), + ipcCallId, + method, + outcome: 'success', + sourceEpochMs, + startEpochMs: responseStartEpochMs, + }, + ]; +} + +function oneTimeline( + main: XtreamMainCaptureResult, + request: WorkerRequestPerformanceMetrics, + type: 'db-request' | 'preload-performance-success' +) { + const matches = main.capture.timeline.filter( + (record) => + record.type === type && + record.ipcCallId === request.ipcCallId && + record.operation === request.operation && + record.playlistId === request.playlistId && + (type !== 'db-request' || + (record.requestId === request.requestId && + record.sourceEpochMs === request.sourceEpochMs)) + ); + if (matches.length !== 1 || !matches[0]) invalid(); + return matches[0]; +} + +function ipcSpan( + boundary: 'request' | 'response', + correlationId: string, + marker: XtreamIpcTimelineRecord, + startEpochMs: number, + sourceEpochMs: number, + outcome: 'error' | 'success' +): XtreamIpcAttributionSpan { + return { + boundary, + correlationId, + endEpochMs: marker.epochMs, + ipcCallId: requirePositiveInteger(marker.ipcCallId), + method: requireText(marker.method), + outcome, + sourceEpochMs, + startEpochMs, + }; +} + +function phaseEvent(input: { + boundary: 'end' | 'start'; + correlationId: string; + durationMs: number | null; + epochMs: number; + itemCount: number | null; + phase: string; + source: XtreamPhaseSemanticSource; +}): XtreamAttributionPhaseEvent { + const semanticType = semanticTypeFromXtreamPhaseSource(input.source); + if (semanticType === undefined) invalid(); + return { + boundary: input.boundary, + categoryType: input.source.categoryType, + contentType: input.source.contentType, + correlationId: input.correlationId, + durationMs: input.durationMs, + epochMs: input.epochMs, + itemCount: input.itemCount, + phase: input.phase, + providerAction: input.source.providerAction, + semanticType, + }; +} + +function requireEpoch(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) + invalid(); + return value; +} + +function requirePositiveInteger(value: unknown): number { + if (!Number.isSafeInteger(value) || Number(value) <= 0) invalid(); + return Number(value); +} + +function requireText(value: unknown): string { + if (typeof value !== 'string' || value.length === 0) invalid(); + return value; +} + +function invalid(): never { + throw new Error('invalid Xtream assembly phase evidence'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-scenarios.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-scenarios.ts new file mode 100644 index 000000000..e07ce6dd9 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-assembly-scenarios.ts @@ -0,0 +1,363 @@ +import { isDeepStrictEqual } from 'node:util'; + +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import type { XtreamMainCaptureResult } from './m3u-refresh-main-capture'; +import type { + XtreamIpcAttributionSpan, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import type { XtreamRendererCaptureMetrics } from './xtream-renderer-capture'; +import type { + XtreamScenarioTerminalEvidence, + XtreamScenarioTriggerEvidence, +} from './xtream-scenario-driver'; +import { + XTREAM_CANCEL_NETWORK_ABORT_REASON, + type XtreamCancellationEvidence, + type XtreamCancellationMetrics, +} from './xtream-summary-contract'; +import { + summarizeXtreamUiActionSamples, + XTREAM_UI_ACTION_ORDER, + type XtreamUiActionProbeResult, + type XtreamUiActionSample, +} from './xtream-ui-action-probe'; + +export interface XtreamScenarioAssembly { + readonly backgroundMetrics: { + readonly actionLatencyMs: number; + readonly dashboardPaintVisibility: 'visible-without-overlay'; + readonly navigationLatencyMs: number; + readonly searchLatencyMs: number; + } | null; + readonly cancellation: XtreamCancellationMetrics; + readonly cancellationEvidence: XtreamCancellationEvidence | null; +} + +export function assembleXtreamScenarioEvidence(input: { + readonly main: XtreamMainCaptureResult; + readonly phaseCapture: XtreamPhaseAttributionInput; + readonly renderer: XtreamRendererCaptureMetrics; + readonly scenarioId: XtreamScenarioId; + readonly terminal: XtreamScenarioTerminalEvidence; + readonly trigger: XtreamScenarioTriggerEvidence; +}): XtreamScenarioAssembly { + assertEnvelope(input); + return { + backgroundMetrics: backgroundMetrics(input), + ...cancellationEvidence(input), + }; +} + +function assertEnvelope(input: { + readonly main: XtreamMainCaptureResult; + readonly phaseCapture: XtreamPhaseAttributionInput; + readonly renderer: XtreamRendererCaptureMetrics; + readonly scenarioId: XtreamScenarioId; + readonly terminal: XtreamScenarioTerminalEvidence; + readonly trigger: XtreamScenarioTriggerEvidence; +}): void { + const start = input.phaseCapture.operationStartEpochMs; + const terminal = input.phaseCapture.terminalEpochMs; + if ( + input.trigger.scenarioId !== input.scenarioId || + input.terminal.scenarioId !== input.scenarioId || + input.renderer.probe.scenarioId !== input.scenarioId || + input.trigger.triggerEpochMs < Math.floor(start) || + input.trigger.triggerEpochMs > input.terminal.terminalEpochMs || + input.main.captureStartedEpochMs > + input.main.measurementStartedEpochMs || + input.main.measurementStartedEpochMs > start || + input.renderer.measurementStartEpochMs < start || + input.renderer.probe.terminalEpochMs === null || + input.renderer.probe.terminalEpochMs > + input.renderer.captureCutoffEpochMs || + input.renderer.captureCutoffEpochMs > input.main.captureCutoffEpochMs || + terminal > input.main.captureCutoffEpochMs || + input.main.captureCutoffEpochMs > input.main.captureStoppedEpochMs + ) { + invalid(); + } +} + +function backgroundMetrics( + input: Parameters[0] +): XtreamScenarioAssembly['backgroundMetrics'] { + const background = input.terminal.backgroundProbe; + const operation = input.terminal.operation; + if (input.scenarioId !== XTREAM_SCENARIO_ID.BACKGROUND_UI) { + if (background !== null || operation !== null) invalid(); + return null; + } + if ( + !background || + !operation || + operation.active || + operation.terminalStatus !== 'completed' || + operation.terminalEpochMs === null || + background.operation !== 'delete-xtream-content' || + operation.operation !== background.operation || + operation.operationId !== background.operationId || + operation.playlistId !== background.playlistId || + operation.startedEpochMs > background.startedEpochMs || + operation.terminalEpochMs > input.terminal.terminalEpochMs || + background.completedEpochMs > input.terminal.terminalEpochMs || + background.completedEpochMs >= input.renderer.probe.uiPaintedEpochMs + ) { + invalid(); + } + const requests = input.main.requests.filter( + ({ operation: candidate }) => candidate === 'DB_DELETE_XTREAM_CONTENT' + ); + const request = requests[0]; + if ( + requests.length !== 1 || + !request || + request.operationId !== background.operationId || + request.playlistId !== background.playlistId || + request.workStartedEpochMs === null || + request.workEndedEpochMs === null || + background.startedEpochMs < request.workStartedEpochMs || + background.startedEpochMs > request.workEndedEpochMs || + request.workEndedEpochMs > input.terminal.terminalEpochMs + ) { + invalid(); + } + const result = readUiResult(background.result); + const firstSample = result.samples[0]; + const lastSample = result.samples.at(-1); + const dashboardSample = result.samples.find( + ({ kind }) => kind === 'dashboard' + ); + const storeTerminalEpochMs = input.renderer.probe.storeTerminalEpochMs; + if ( + !firstSample || + !lastSample || + !dashboardSample || + storeTerminalEpochMs === null || + firstSample.startEpochMs < background.startedEpochMs || + lastSample.endEpochMs > background.completedEpochMs || + lastSample.endEpochMs > storeTerminalEpochMs || + operation.terminalEpochMs > storeTerminalEpochMs || + request.workEndedEpochMs > storeTerminalEpochMs + ) { + invalid(); + } + return { + actionLatencyMs: requireNumber(result.action.max), + dashboardPaintVisibility: result.dashboardPaintVisibility, + navigationLatencyMs: requireNumber(result.navigation.max), + searchLatencyMs: requireNumber(result.search.max), + }; +} + +function readUiResult(value: unknown): XtreamUiActionProbeResult { + if (!isRecord(value)) invalid(); + const samplesValue = value['samples']; + if ( + value['dashboardPaintVisibility'] !== 'visible-without-overlay' || + !Array.isArray(samplesValue) || + samplesValue.length !== XTREAM_UI_ACTION_ORDER.length + ) { + invalid(); + } + const samples = samplesValue.map((entry, index) => + readSample(entry, index) + ); + if ( + samples.some((sample, index) => { + const previous = samples[index - 1]; + return ( + previous !== undefined && + previous.endEpochMs > sample.startEpochMs + ); + }) + ) { + invalid(); + } + const summary = summarizeXtreamUiActionSamples(samples); + if ( + !isDeepStrictEqual(summary.action, value['action']) || + !isDeepStrictEqual(summary.navigation, value['navigation']) || + !isDeepStrictEqual(summary.search, value['search']) + ) { + invalid(); + } + return { + ...summary, + dashboardPaintVisibility: 'visible-without-overlay', + samples, + }; +} + +function readSample(value: unknown, index: number): XtreamUiActionSample { + if ( + !isRecord(value) || + value['activeAfter'] !== true || + value['activeBefore'] !== true || + value['kind'] !== XTREAM_UI_ACTION_ORDER[index] || + !['action', 'navigation', 'search'].includes(String(value['category'])) + ) { + invalid(); + } + const startEpochMs = requireNumber(value['startEpochMs']); + const endEpochMs = requireNumber(value['endEpochMs']); + const latencyMs = requireNumber(value['latencyMs']); + if ( + endEpochMs < startEpochMs || + Math.abs(latencyMs - (endEpochMs - startEpochMs)) > 0.001 + ) { + invalid(); + } + return { + activeAfter: true, + activeBefore: true, + category: value['category'] as XtreamUiActionSample['category'], + endEpochMs, + kind: value['kind'] as XtreamUiActionSample['kind'], + latencyMs, + startEpochMs, + }; +} + +function cancellationEvidence( + input: Parameters[0] +): Pick { + const driver = input.terminal.cancellation; + if (input.scenarioId !== XTREAM_SCENARIO_ID.CANCEL_IMPORT) { + if (driver !== null) invalid(); + return { + cancellation: emptyCancellation(), + cancellationEvidence: null, + }; + } + const failed = input.main.requests.filter( + ({ operation, success }) => operation === 'DB_SAVE_CONTENT' && !success + ); + const request = failed[0]; + const timeline = input.main.cancelTimeline; + const renderer = input.renderer.probe; + if ( + failed.length !== 1 || + !request || + !driver || + typeof request.requestId !== 'string' || + typeof request.operationId !== 'string' || + driver.operationId !== request.operationId || + driver.armedEpochMs > driver.progressEpochMs || + driver.progressEpochMs > driver.clickEpochMs || + driver.threshold !== Math.max(100, Math.floor(driver.total * 0.1)) || + driver.current < driver.threshold || + renderer.cancellationClickEpochMs === null || + renderer.cancellationClickEpochMs < driver.clickEpochMs || + renderer.dbCancellationTerminalEpochMs === null || + timeline.length !== 3 + ) { + invalid(); + } + const dispatch = timeline[0]; + const receipt = timeline[1]; + const workerTerminal = timeline[2]; + const preload = ipcPair(input.phaseCapture.ipcSpans, 'dbCancelOperation'); + const authoritative = request.responseEpochMs; + const painted = requireNumber(renderer.uiPaintedEpochMs); + if ( + !dispatch || + !receipt || + !workerTerminal || + dispatch.type !== 'db-cancel-dispatched' || + receipt.type !== 'db-cancel-received' || + workerTerminal.type !== 'db-cancel-terminal-received' || + renderer.dbCancellationTerminalEpochMs < workerTerminal.epochMs || + renderer.dbCancellationTerminalEpochMs > painted || + authoritative > painted + ) { + invalid(); + } + const click = driver.clickEpochMs; + const cancellation: XtreamCancellationMetrics = { + acknowledgementLatencyMs: authoritative - click, + authoritativeDatabaseAcknowledgementObserved: true, + authoritativeTerminalEpochMs: authoritative, + clickEpochMs: click, + clickToDatabaseDispatchMs: dispatch.epochMs - click, + clickToPaintMs: painted - click, + clickToPreloadReturnMs: preload[1].endEpochMs - click, + databaseDispatchEpochMs: dispatch.epochMs, + failedDatabaseRequestId: request.requestId, + failedOperationId: request.operationId, + mainNetworkAbortObserved: false, + mainNetworkAbortReason: + XTREAM_CANCEL_NETWORK_ABORT_REASON.AFTER_NETWORK_COMPLETE, + paintedEpochMs: painted, + preloadReturnEpochMs: preload[1].endEpochMs, + preloadStartEpochMs: preload[0].startEpochMs, + workerReceiptEpochMs: receipt.epochMs, + workerReceiptIsAuthoritative: false, + workerReceiptLatencyMs: receipt.epochMs - click, + }; + if ( + Object.values(cancellation).some( + (entry) => typeof entry === 'number' && entry < 0 + ) + ) { + invalid(); + } + return { + cancellation, + cancellationEvidence: { + timeline: timeline.map((entry) => ({ ...entry })), + }, + }; +} + +function ipcPair( + spans: readonly XtreamIpcAttributionSpan[], + method: string +): readonly [XtreamIpcAttributionSpan, XtreamIpcAttributionSpan] { + const selected = spans.filter((span) => span.method === method); + const request = selected.find(({ boundary }) => boundary === 'request'); + const response = selected.find(({ boundary }) => boundary === 'response'); + if (selected.length !== 2 || !request || !response) invalid(); + return [request, response]; +} + +function emptyCancellation(): XtreamCancellationMetrics { + return { + acknowledgementLatencyMs: null, + authoritativeDatabaseAcknowledgementObserved: false, + authoritativeTerminalEpochMs: null, + clickEpochMs: null, + clickToDatabaseDispatchMs: null, + clickToPaintMs: null, + clickToPreloadReturnMs: null, + databaseDispatchEpochMs: null, + failedDatabaseRequestId: null, + failedOperationId: null, + mainNetworkAbortObserved: false, + mainNetworkAbortReason: null, + paintedEpochMs: null, + preloadReturnEpochMs: null, + preloadStartEpochMs: null, + workerReceiptEpochMs: null, + workerReceiptIsAuthoritative: false, + workerReceiptLatencyMs: null, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requireNumber(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) + invalid(); + return value; +} + +function invalid(): never { + throw new Error('invalid Xtream scenario assembly evidence'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-evidence-binding.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-evidence-binding.spec.ts new file mode 100644 index 000000000..026215095 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-evidence-binding.spec.ts @@ -0,0 +1,389 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, + type XtreamIterationDefinition, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { iteration } from './xtream-benchmark-report.fixtures'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; +import type { XtreamRawIterationResult } from './xtream-summary-contract'; + +describe('Xtream persisted iteration evidence binding', () => { + it('rejects an independently edited phase headline', () => { + const raw = createIteration(); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + phases: { + ...raw.phases, + sqliteWriteMs: 99, + }, + }), + /invalid Xtream iteration/ + ); + }); + + it('rejects worker phase evidence that no longer backs the phase headline', () => { + const raw = createIteration(); + const requests = raw.databaseWorker.requests.evidence.map((request) => { + if (request.operation !== 'DB_SAVE_CONTENT') return request; + const phaseEvents = request.phaseEvents.map((event, index) => + index === 1 + ? { + ...event, + durationMs: 1.5, + epochMs: event.epochMs + 0.5, + } + : event + ); + return { ...request, phaseEvents }; + }); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + requests: { + ...raw.databaseWorker.requests, + evidence: requests, + }, + }, + }), + /invalid Xtream iteration/ + ); + }); + + it('rejects a self-consistent cancel headline outside the failed request', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const cancellation = { + ...raw.cancellation, + authoritativeTerminalEpochMs: + Number(raw.cancellation.authoritativeTerminalEpochMs) + 5_000, + clickEpochMs: Number(raw.cancellation.clickEpochMs) + 5_000, + databaseDispatchEpochMs: + Number(raw.cancellation.databaseDispatchEpochMs) + 5_000, + paintedEpochMs: Number(raw.cancellation.paintedEpochMs) + 5_000, + preloadReturnEpochMs: + Number(raw.cancellation.preloadReturnEpochMs) + 5_000, + preloadStartEpochMs: + Number(raw.cancellation.preloadStartEpochMs) + 5_000, + workerReceiptEpochMs: + Number(raw.cancellation.workerReceiptEpochMs) + 5_000, + }; + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellation, + }), + /invalid Xtream iteration/ + ); + }); + + it('binds the dedicated cancel receipt and failed-request identities exactly', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const receiptDrift = { + ...raw.cancellation, + workerReceiptEpochMs: + Number(raw.cancellation.workerReceiptEpochMs) + 0.5, + workerReceiptLatencyMs: + Number(raw.cancellation.workerReceiptLatencyMs) + 0.5, + }; + const wrongRequest = { + ...raw.cancellation, + failedDatabaseRequestId: 'another-request', + }; + const wrongOperation = { + ...raw.cancellation, + failedOperationId: 'another-operation', + }; + + for (const cancellation of [ + receiptDrift, + wrongRequest, + wrongOperation, + ]) { + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellation, + }), + /invalid Xtream iteration/ + ); + } + }); + + it('accepts either ordering of worker receipt and preload return after dispatch', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const dispatch = Number(raw.cancellation.databaseDispatchEpochMs); + const preloadReturn = Number(raw.cancellation.preloadReturnEpochMs); + const workerTerminal = Number( + raw.cancellationEvidence?.timeline.find( + ({ type }) => type === 'db-cancel-terminal-received' + )?.epochMs + ); + const beforeReturn = dispatch + (preloadReturn - dispatch) / 2; + const afterReturn = + preloadReturn + (workerTerminal - preloadReturn) / 2; + + assert.notEqual( + raw.cancellation.workerReceiptEpochMs, + preloadReturn, + 'fixture receipt must be independent from the preload response' + ); + for (const receiptEpochMs of [beforeReturn, afterReturn]) { + assert.doesNotThrow(() => + assertXtreamIterationResult(withReceipt(raw, receiptEpochMs)) + ); + } + }); + + it('rejects cancel-receipt identity drift and receipt after the authoritative terminal', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const evidence = cancellationEvidence(raw); + const receiptIndex = evidence.timeline.findIndex( + ({ type }) => type === 'db-cancel-received' + ); + assert.notEqual(receiptIndex, -1); + + for (const receipt of [ + { requestId: 'another-request' }, + { operationId: 'another-operation' }, + { ipcCallId: 999 }, + { sourceEpochMs: 999 }, + { + epochMs: + Number(raw.cancellation.authoritativeTerminalEpochMs) + 1, + }, + ]) { + const timeline = evidence.timeline.map((record, index) => + index === receiptIndex ? { ...record, ...receipt } : record + ); + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellationEvidence: { timeline }, + }), + /invalid Xtream iteration/ + ); + } + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellationEvidence: { + timeline: evidence.timeline.map((record) => ({ + ...record, + sourceEpochMs: record.sourceEpochMs + 1, + })), + }, + }), + /invalid Xtream iteration/ + ); + }); + + it('rejects reuse of a worker request IPC identity for cancellation', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const evidence = cancellationEvidence(raw); + const failedRequest = raw.databaseWorker.requests.evidence.find( + ({ operation, success }) => + operation === 'DB_SAVE_CONTENT' && !success + ); + assert.ok(failedRequest?.ipcCallId); + + for (const ipcCallId of [failedRequest.ipcCallId, 999]) { + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellationEvidence: { + timeline: evidence.timeline.map((record) => ({ + ...record, + ipcCallId: ipcCallId as number, + })), + }, + }), + /invalid Xtream iteration/ + ); + } + }); + + it('persists producer identity on the database cancel IPC pair', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const databaseCancel = raw.phaseCapture.ipcSpans.filter( + ({ method }) => method === 'dbCancelOperation' + ) as readonly ((typeof raw.phaseCapture.ipcSpans)[number] & { + readonly ipcCallId?: number; + readonly sourceEpochMs?: number; + })[]; + + assert.equal(databaseCancel.length, 2); + assert.equal(typeof databaseCancel[0]?.ipcCallId, 'number'); + assert.equal( + databaseCancel[0]?.ipcCallId, + databaseCancel[1]?.ipcCallId + ); + assert.equal(typeof databaseCancel[0]?.sourceEpochMs, 'number'); + assert.equal( + databaseCancel[0]?.sourceEpochMs, + databaseCancel[1]?.sourceEpochMs + ); + }); + + it('rejects database preload identities swapped across worker operations', () => { + const raw = createIteration(); + const contentRead = raw.phaseCapture.ipcSpans.find( + ({ boundary, method }) => + boundary === 'request' && method === 'dbGetContent' + ); + const contentWrite = raw.phaseCapture.ipcSpans.find( + ({ boundary, method }) => + boundary === 'request' && method === 'dbSaveContent' + ); + assert.ok(contentRead); + assert.ok(contentWrite); + const ipcSpans = raw.phaseCapture.ipcSpans.map((span) => { + if (span.correlationId === contentRead.correlationId) { + return { + ...span, + ipcCallId: contentWrite.ipcCallId, + sourceEpochMs: contentWrite.sourceEpochMs, + }; + } + return span.correlationId === contentWrite.correlationId + ? { + ...span, + ipcCallId: contentRead.ipcCallId, + sourceEpochMs: contentRead.sourceEpochMs, + } + : span; + }); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + phaseCapture: { ...raw.phaseCapture, ipcSpans }, + }), + /invalid Xtream iteration/ + ); + }); + + it('rejects cancellation source chronology before an earlier worker call', () => { + const raw = createIteration(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const evidence = cancellationEvidence(raw); + const lastWorkerCall = raw.databaseWorker.requests.evidence + .filter( + (request) => + request.ipcCallId !== null && request.sourceEpochMs !== null + ) + .sort( + (left, right) => + Number(right.ipcCallId) - Number(left.ipcCallId) + )[0]; + assert.ok(lastWorkerCall?.sourceEpochMs); + const databaseSourceEpochMs = + Number(lastWorkerCall.sourceEpochMs) - 0.25; + const networkSourceEpochMs = databaseSourceEpochMs - 0.25; + const ipcSpans = raw.phaseCapture.ipcSpans.map((span) => { + if (span.method === 'xtreamCancelSession') { + return { ...span, sourceEpochMs: networkSourceEpochMs }; + } + return span.method === 'dbCancelOperation' + ? { ...span, sourceEpochMs: databaseSourceEpochMs } + : span; + }); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellationEvidence: { + timeline: evidence.timeline.map((record) => ({ + ...record, + sourceEpochMs: databaseSourceEpochMs, + })), + }, + phaseCapture: { ...raw.phaseCapture, ipcSpans }, + }), + /invalid Xtream iteration/ + ); + }); + + it('ignores caller-owned phase array methods when checking evidence', () => { + const raw = createIteration(); + const events = [ + ...raw.phaseCapture.events, + ] as unknown as typeof raw.phaseCapture.events & { + filter: () => readonly never[]; + map: () => readonly never[]; + }; + Object.defineProperties(events, { + filter: { value: () => [] }, + map: { value: () => [] }, + }); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + phaseCapture: { ...raw.phaseCapture, events }, + phases: { ...raw.phases, sqliteWriteMs: 99 }, + }), + /invalid Xtream iteration/ + ); + }); +}); + +function withReceipt( + raw: XtreamRawIterationResult, + receiptEpochMs: number +): XtreamRawIterationResult { + const click = Number(raw.cancellation.clickEpochMs); + const evidence = cancellationEvidence(raw); + return { + ...raw, + cancellation: { + ...raw.cancellation, + workerReceiptEpochMs: receiptEpochMs, + workerReceiptLatencyMs: receiptEpochMs - click, + }, + cancellationEvidence: { + timeline: evidence.timeline.map((record) => + record.type === 'db-cancel-received' + ? { ...record, epochMs: receiptEpochMs } + : record + ), + }, + }; +} + +function cancellationEvidence( + raw: XtreamRawIterationResult +): NonNullable { + const evidence = raw.cancellationEvidence; + assert.ok(evidence); + return evidence; +} + +function createIteration( + scenarioId: XtreamScenarioId = XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE +): XtreamRawIterationResult { + const definition: XtreamIterationDefinition = { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId, + }; + return iteration(definition, 1); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-evidence-binding.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-evidence-binding.ts new file mode 100644 index 000000000..880487d07 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-evidence-binding.ts @@ -0,0 +1,154 @@ +import { isDeepStrictEqual } from 'node:util'; + +import { + attributeXtreamPhases, + type XtreamAttributionPhaseEvent, +} from './xtream-phase-attribution'; +import { assertXtreamCancellationEvidenceBinding } from './xtream-cancellation-evidence-binding'; +import { isExactRecord } from './xtream-exact-schema'; +import { assertXtreamIpcWorkerIdentityBinding } from './xtream-ipc-worker-identity-binding'; +import { snapshotXtreamPhaseInput } from './xtream-own-array-snapshot'; +import { XTREAM_ATTRIBUTION_PHASE as PHASE } from './xtream-phase-inventory'; +import type { XtreamRawIterationResult } from './xtream-summary-contract'; + +const PHASE_CAPTURE_KEYS = [ + 'backgroundSearchMarkerObserved', + 'events', + 'ipcSpans', + 'operationStartEpochMs', + 'scenarioId', + 'terminalEpochMs', + 'uiPaintEpochMs', +] as const; +const WORKER_PHASES = new Set([ + PHASE.DESERIALIZE_PLAYLIST, + PHASE.NORMALIZE_CATEGORIES, + PHASE.NORMALIZE_CONTENT, + PHASE.NORMALIZE_SEARCH_RANK, + PHASE.SERIALIZE_PLAYLIST, + PHASE.SQLITE_CATEGORIES_READ, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.SQLITE_CONTENT_READ, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + PHASE.SQLITE_GENERIC_READ, + PHASE.SQLITE_GENERIC_WRITE, + PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + PHASE.SQLITE_SEARCH_QUERY, + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, +]); +type WorkerBindablePhaseEvent = Omit< + XtreamAttributionPhaseEvent, + 'categoryType' | 'contentType' | 'providerAction' | 'semanticType' +>; + +export function assertXtreamIterationEvidenceBinding( + raw: XtreamRawIterationResult +): void { + if ( + !isExactRecord(raw.phaseCapture, PHASE_CAPTURE_KEYS) || + raw.phaseCapture.scenarioId !== raw.scenarioId + ) { + invalid(); + } + const canonicalCapture = snapshotXtreamPhaseInput(raw.phaseCapture); + const events = canonicalCapture.events; + const ipcSpans = canonicalCapture.ipcSpans; + const requests = snapshotOwnArray< + XtreamRawIterationResult['databaseWorker']['requests']['evidence'][number] + >(raw.databaseWorker.requests.evidence); + const attributed = attributeXtreamPhases(canonicalCapture); + if (!isDeepStrictEqual(attributed, raw.phases)) invalid(); + assertWorkerPhaseBinding(events, requests); + assertXtreamIpcWorkerIdentityBinding(ipcSpans, requests); + assertXtreamCancellationEvidenceBinding(raw, events, ipcSpans, requests); +} + +function assertWorkerPhaseBinding( + events: readonly XtreamAttributionPhaseEvent[], + requests: readonly XtreamRawIterationResult['databaseWorker']['requests']['evidence'][number][] +): void { + const captured = events + .filter(({ phase }) => WORKER_PHASES.has(phase)) + .map(copyCaptureEvent) + .sort(compareEvents); + const projected = requests + .flatMap((request) => { + if (typeof request.requestId !== 'string') invalid(); + return snapshotOwnArray<(typeof request.phaseEvents)[number]>( + request.phaseEvents + ).map((event): WorkerBindablePhaseEvent => ({ + boundary: event.boundary, + correlationId: request.requestId as string, + durationMs: event.durationMs, + epochMs: event.epochMs, + itemCount: + event.boundary === 'end' + ? (event.metadata?.itemCount ?? null) + : null, + phase: event.phase, + })); + }) + .sort(compareEvents); + if (!isDeepStrictEqual(captured, projected)) invalid(); +} + +function copyCaptureEvent( + event: XtreamAttributionPhaseEvent +): WorkerBindablePhaseEvent { + return { + boundary: event.boundary, + correlationId: event.correlationId, + durationMs: event.durationMs, + epochMs: event.epochMs, + itemCount: event.itemCount, + phase: event.phase, + }; +} + +function compareEvents( + left: WorkerBindablePhaseEvent, + right: WorkerBindablePhaseEvent +): number { + return eventKey(left).localeCompare(eventKey(right)); +} + +function eventKey(event: WorkerBindablePhaseEvent): string { + return [ + event.correlationId, + event.phase, + event.boundary, + event.epochMs, + event.durationMs, + event.itemCount, + ].join('\0'); +} + +function snapshotOwnArray(value: unknown): readonly T[] { + if (!Array.isArray(value)) invalid(); + const descriptor = Reflect.getOwnPropertyDescriptor(value, 'length'); + const length = descriptor?.value; + if ( + !Number.isSafeInteger(length) || + Number(length) < 0 || + Number(length) > 512 + ) { + invalid(); + } + const result: T[] = []; + for (let index = 0; index < Number(length); index += 1) { + const entry = Reflect.getOwnPropertyDescriptor(value, String(index)); + if (!entry || !Object.prototype.hasOwnProperty.call(entry, 'value')) { + invalid(); + } + result[index] = entry.value as T; + } + return Object.freeze(result); +} + +function invalid(): never { + throw new Error('invalid Xtream persisted evidence binding'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-fixed-contracts.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-fixed-contracts.ts new file mode 100644 index 000000000..32cf93b9b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-fixed-contracts.ts @@ -0,0 +1,53 @@ +import { XTREAM_ITERATION_KIND } from './xtream-benchmark-contract'; +import { isExactRecord } from './xtream-exact-schema'; +import { XTREAM_NOT_APPLICABLE } from './xtream-summary-contract'; + +export function assertXtreamFixedIterationContracts( + raw: Record +): void { + const playlist = raw['playlistRefreshWorker']; + const catalog = raw['catalogVerification']; + const validity = raw['validity']; + if ( + !isExactRecord(playlist, ['instantiated', 'reason']) || + playlist['instantiated'] !== false || + playlist['reason'] !== XTREAM_NOT_APPLICABLE.PLAYLIST_REFRESH_WORKER || + !isExactRecord(catalog, [ + 'databaseStateValid', + 'fixtureIdentityValid', + 'providerCategoryCount', + 'providerItemCount', + 'verifiedAfterCapture', + ]) || + ![ + catalog['databaseStateValid'], + catalog['fixtureIdentityValid'], + catalog['verifiedAfterCapture'], + ].every((entry) => entry === true) || + catalog['providerCategoryCount'] !== 100 || + catalog['providerItemCount'] !== 100_000 || + !isExactRecord(validity, [ + 'actionContractValid', + 'catalogContractValid', + 'databaseWorkerCount', + 'dbPhaseContractValid', + 'diagnosticArtifactsValid', + 'lateDatabaseRequestCount', + 'playlistWorkerCount', + 'rendererTargetCount', + ]) || + ![ + validity['actionContractValid'], + validity['catalogContractValid'], + validity['dbPhaseContractValid'], + ].every((entry) => entry === true) || + validity['databaseWorkerCount'] !== 1 || + validity['playlistWorkerCount'] !== 0 || + validity['rendererTargetCount'] !== 1 || + validity['lateDatabaseRequestCount'] !== 0 || + validity['diagnosticArtifactsValid'] !== + (raw['kind'] === XTREAM_ITERATION_KIND.DIAGNOSTIC ? true : null) + ) { + throw new Error('invalid'); + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-semantics.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-semantics.spec.ts new file mode 100644 index 000000000..2321f8a91 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-semantics.spec.ts @@ -0,0 +1,338 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, + type XtreamIterationDefinition, +} from './xtream-benchmark-contract'; +import { iteration as createIteration } from './xtream-benchmark-report.fixtures'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; +import type { XtreamRawIterationResult } from './xtream-summary-contract'; + +describe('Xtream iteration semantic evidence', () => { + it('labels only the selected IPC proxy boundary', () => { + const raw = iteration(); + + assert.equal( + raw.phases.structuredCloneAttribution, + 'selected-ipc-proxy-includes-queueing-and-scheduling' + ); + assert.equal( + raw.phases.indexesReason, + 'schema-indexes-pre-exist; operation-creates-none' + ); + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + phases: { + ...raw.phases, + structuredCloneAttribution: + 'proxy-includes-queueing-and-scheduling', + }, + }), + /invalid Xtream iteration/ + ); + }); + + it('preserves Electron main ELU as an explicit unavailable pair', () => { + const raw = iteration(); + + assert.equal(raw.main.eventLoopUtilization, null); + assert.equal( + raw.main.eventLoopUtilizationUnavailableReason, + 'electron-main-embedded-event-loop' + ); + for (const main of [ + { + ...raw.main, + eventLoopUtilization: 0, + eventLoopUtilizationUnavailableReason: null, + }, + { + ...raw.main, + eventLoopUtilizationUnavailableReason: null, + }, + ]) { + assert.throws( + () => assertXtreamIterationResult({ ...raw, main }), + /invalid Xtream iteration/ + ); + } + }); + + it('requires physically possible process memory, phase bounds, and long-task pairs', () => { + const raw = iteration(); + const invalid = [ + { + ...raw, + renderer: { ...raw.renderer, peakHeapUsedBytes: 0 }, + }, + { + ...raw, + renderer: { + ...raw.renderer, + postGcHeapUsedBytes: raw.renderer.peakHeapUsedBytes + 1, + }, + }, + { + ...raw, + main: { ...raw.main, peakRssBytes: 0 }, + }, + { + ...raw, + main: { + ...raw.main, + peakHeapUsedBytes: raw.main.peakRssBytes + 1, + }, + }, + { + ...raw, + databaseWorker: { + ...raw.databaseWorker, + peakHeapUsedBytes: 0, + }, + }, + { + ...raw, + databaseWorker: { + ...raw.databaseWorker, + postGcHeapUsedBytes: + raw.databaseWorker.peakHeapUsedBytes + 1, + }, + }, + { + ...raw, + databaseWorker: { + ...raw.databaseWorker, + ordinal: 0, + }, + }, + { + ...raw, + phases: { + ...raw.phases, + storeUpdateMs: raw.phases.totalMs + 1, + }, + }, + { + ...raw, + renderer: { + ...raw.renderer, + longTaskCount: 0, + longTaskMaxMs: 75, + }, + }, + { + ...raw, + renderer: { + ...raw.renderer, + longTaskCount: 2, + longTaskMaxMs: 0, + }, + }, + ]; + + for (const candidate of invalid) { + assert.throws( + () => + assertXtreamIterationResult( + candidate as XtreamRawIterationResult + ), + /invalid Xtream iteration/ + ); + } + }); + + it('requires positive safe diagnostic CPU-profile durations', () => { + const diagnostic = iteration({ + ...definition(), + eligibleForComparison: false, + kind: XTREAM_ITERATION_KIND.DIAGNOSTIC, + runId: 'diagnostic', + }); + const mutate = ( + process: 'databaseWorker' | 'main' | 'renderer', + cpuProfileDurationMicros: number + ): XtreamRawIterationResult => ({ + ...diagnostic, + [process]: { + ...diagnostic[process], + cpuProfileDurationMicros, + }, + }); + + for (const process of ['databaseWorker', 'main', 'renderer'] as const) { + for (const duration of [0, 1.5]) { + assert.throws( + () => + assertXtreamIterationResult(mutate(process, duration)), + /invalid Xtream iteration/ + ); + } + } + }); + + it('requires distinct worker request and producer preload identities', () => { + const raw = iteration(); + const evidence = raw.databaseWorker.requests.evidence; + const first = evidence[0]; + const second = evidence[1]; + const correlated = evidence.filter( + ({ operation }) => operation === 'DB_GET_CATEGORIES' + ); + const operationIdentities = evidence.filter( + ({ operation }) => operation === 'DB_SAVE_CONTENT' + ); + const firstCorrelated = correlated[0]; + const secondCorrelated = correlated[1]; + const firstOperation = operationIdentities[0]; + const secondOperation = operationIdentities[1]; + assert.ok(first?.requestId); + assert.ok(second); + assert.ok(firstCorrelated?.ipcCallId !== null); + assert.ok(secondCorrelated); + assert.ok(firstOperation?.operationId); + assert.ok(secondOperation); + + const duplicateRequestId = evidence.map((request, index) => + index === 1 + ? { + ...request, + requestId: first.requestId, + phaseEvents: request.phaseEvents.map((event) => ({ + ...event, + requestId: first.requestId, + })), + } + : request + ); + const duplicateIpcCallId = evidence.map((request) => + request === secondCorrelated + ? { + ...request, + ipcCallId: firstCorrelated.ipcCallId, + sourceEpochMs: firstCorrelated.sourceEpochMs, + } + : request + ); + const duplicateOperationId = evidence.map((request) => + request === secondOperation + ? { + ...request, + operationId: firstOperation.operationId, + operationIdUnavailableReason: null, + } + : request + ); + const zeroSourceEpoch = evidence.map((request) => + request === firstCorrelated + ? { ...request, sourceEpochMs: 0 } + : request + ); + const missingProducerIdentity = evidence.map((request) => + request === firstCorrelated + ? { + ...request, + ipcCallId: null, + operationIdUnavailableReason: + 'preload-performance-marker-not-applicable', + sourceEpochMs: null, + } + : request + ); + + for (const candidate of [ + duplicateRequestId, + duplicateIpcCallId, + duplicateOperationId, + zeroSourceEpoch, + missingProducerIdentity, + ]) { + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + requests: { + ...raw.databaseWorker.requests, + evidence: candidate, + }, + }, + }), + /invalid Xtream iteration/ + ); + } + }); + + it('requires exact per-request operation, outcome, phase, and accounting evidence', () => { + const raw = iteration(); + const evidence = raw.databaseWorker.requests.evidence; + assert.ok(evidence.length > 1); + + const mutations = [ + { + ...raw.databaseWorker.requests, + totalCount: 1, + validSampleCount: 1, + }, + { + ...raw.databaseWorker.requests, + evidence: evidence.slice(1), + totalCount: evidence.length - 1, + validSampleCount: evidence.length - 1, + }, + { + ...raw.databaseWorker.requests, + evidence: evidence.map((request, index) => + index === 0 + ? { ...request, operation: 'DB_GET_CONTENT' } + : request + ), + }, + { + ...raw.databaseWorker.requests, + evidence: evidence.map((request, index) => + index === 0 ? { ...request, success: false } : request + ), + }, + { + ...raw.databaseWorker.requests, + evidence: evidence.map((request, index) => + index === 0 ? { ...request, phaseEvents: [] } : request + ), + }, + ]; + + for (const requests of mutations) { + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + requests, + }, + } as XtreamRawIterationResult), + /invalid Xtream iteration/ + ); + } + }); +}); + +function definition(): XtreamIterationDefinition { + return { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + }; +} + +function iteration( + input: XtreamIterationDefinition = definition() +): XtreamRawIterationResult { + return createIteration(input, 1); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.spec.ts new file mode 100644 index 000000000..759cc3b06 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.spec.ts @@ -0,0 +1,373 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, + type XtreamIterationDefinition, +} from './xtream-benchmark-contract'; +import { assertXtreamIterationResult } from './xtream-iteration-validity'; +import { + iteration as createIteration, + withWorkerRequestOverlap, +} from './xtream-benchmark-report.fixtures'; +import { + XTREAM_WORKER_REQUEST_METRIC_SCOPE, + XTREAM_WORKER_WHOLE_METRIC_SCOPE, + type XtreamRawIterationResult, +} from './xtream-summary-contract'; + +describe('Xtream raw iteration validity', () => { + it('requires exact finite nonnegative numeric keys for every process and phase', () => { + const raw = iteration(); + const candidates = [ + { + ...raw, + phases: { ...raw.phases, totalMs: '100' }, + }, + { + ...raw, + renderer: { ...raw.renderer, peakRssBytes: undefined }, + }, + { + ...raw, + main: { ...raw.main, cpuUserMicros: null }, + }, + { + ...raw, + databaseWorker: { + ...raw.databaseWorker, + peakExternalBytes: Number.NaN, + }, + }, + { + ...raw, + main: { ...raw.main, inventedMetric: 1 }, + }, + ]; + + assert.doesNotThrow(() => assertXtreamIterationResult(raw)); + for (const candidate of candidates) { + assert.throws( + () => + assertXtreamIterationResult( + candidate as unknown as XtreamRawIterationResult + ), + /invalid Xtream iteration/ + ); + } + }); + + it('uses explicit diagnostic-only CPU-profile availability pairs', () => { + const measured = iteration(); + const diagnostic = iteration({ + ...definition(), + kind: XTREAM_ITERATION_KIND.DIAGNOSTIC, + runId: 'diagnostic', + eligibleForComparison: false, + }); + + assert.doesNotThrow(() => assertXtreamIterationResult(measured)); + assert.doesNotThrow(() => assertXtreamIterationResult(diagnostic)); + assert.throws( + () => + assertXtreamIterationResult({ + ...measured, + main: { + ...measured.main, + cpuProfileDurationMicros: 100, + cpuProfileUnavailableReason: null, + }, + }), + /invalid Xtream iteration/ + ); + assert.throws( + () => + assertXtreamIterationResult({ + ...diagnostic, + renderer: { + ...diagnostic.renderer, + cpuProfileDurationMicros: null, + cpuProfileUnavailableReason: + 'not-captured-outside-diagnostic', + }, + }), + /invalid Xtream iteration/ + ); + }); + + it('requires one exact store-to-paint value and a fresh process identity', () => { + const raw = iteration(); + + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + renderer: { ...raw.renderer, storeToPaintMs: 12.999 }, + }), + /invalid Xtream iteration/ + ); + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + processIdentity: { + ...raw.processIdentity, + freshProcess: false, + }, + }), + /invalid Xtream iteration/ + ); + const { processIdentity: _removed, ...missingIdentity } = raw; + void _removed; + assert.throws( + () => + assertXtreamIterationResult( + missingIdentity as XtreamRawIterationResult + ), + /invalid Xtream iteration/ + ); + }); + + it('requires every cancel receipt and validates the authoritative timeline', () => { + const cancel = iteration({ + ...definition(), + scenarioId: XTREAM_SCENARIO_ID.CANCEL_IMPORT, + }); + + assert.doesNotThrow(() => assertXtreamIterationResult(cancel)); + for (const cancellation of [ + { + ...cancel.cancellation, + clickToPreloadReturnMs: + Number(cancel.cancellation.clickToPreloadReturnMs) - 0.5, + preloadReturnEpochMs: + Number(cancel.cancellation.preloadReturnEpochMs) - 0.5, + }, + { + ...cancel.cancellation, + clickToPreloadReturnMs: + Number(cancel.cancellation.clickToPreloadReturnMs) + 0.5, + preloadReturnEpochMs: + Number(cancel.cancellation.preloadReturnEpochMs) + 0.5, + }, + { ...cancel.cancellation, workerReceiptLatencyMs: null }, + { + ...cancel.cancellation, + workerReceiptEpochMs: + Number(cancel.cancellation.databaseDispatchEpochMs) - 0.5, + }, + { + ...cancel.cancellation, + clickToPaintMs: + Number(cancel.cancellation.clickToPaintMs) + 0.1, + }, + { + ...cancel.cancellation, + preloadReturnEpochMs: + Number(cancel.cancellation.preloadStartEpochMs) - 0.1, + }, + { + ...cancel.cancellation, + clickToPreloadReturnMs: + Number(cancel.cancellation.clickToPreloadReturnMs) + 1, + preloadReturnEpochMs: + Number(cancel.cancellation.preloadReturnEpochMs) + 1, + }, + ]) { + assert.throws( + () => + assertXtreamIterationResult({ + ...cancel, + cancellation, + }), + /invalid Xtream iteration|authoritative database cancellation/ + ); + } + }); + + it('requires all non-cancel timeline values to be exact nulls', () => { + const raw = iteration(); + + for (const cancellation of [ + { ...raw.cancellation, clickEpochMs: 100 }, + { + ...raw.cancellation, + failedDatabaseRequestId: 'unexpected-request', + }, + { + ...raw.cancellation, + failedOperationId: 'unexpected-operation', + }, + ]) { + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + cancellation, + }), + /invalid Xtream iteration/ + ); + } + }); + + it('keeps the interactive background refresh visible without an overlay', () => { + const background = iteration({ + ...definition(), + scenarioId: XTREAM_SCENARIO_ID.BACKGROUND_UI, + }); + + assert.equal( + background.renderer.dashboardPaintVisibility, + 'visible-without-overlay' + ); + assert.doesNotThrow(() => assertXtreamIterationResult(background)); + assert.throws( + () => + assertXtreamIterationResult({ + ...background, + renderer: { + ...background.renderer, + dashboardPaintVisibility: 'occluded-by-overlay', + }, + } as unknown as XtreamRawIterationResult), + /invalid Xtream iteration/ + ); + }); + + it('accounts for scheduler-dependent overlap without filtering samples', () => { + for (const scenarioId of [ + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + XTREAM_SCENARIO_ID.REFRESH_LARGE, + XTREAM_SCENARIO_ID.CANCEL_IMPORT, + XTREAM_SCENARIO_ID.BACKGROUND_UI, + ]) { + const overlapped = withWorkerRequestOverlap( + iteration({ ...definition(), scenarioId }), + 2 + ); + assert.doesNotThrow(() => assertXtreamIterationResult(overlapped)); + } + + const overlapped = withWorkerRequestOverlap(iteration(), 2); + for (const requests of [ + { + ...overlapped.databaseWorker.requests, + invalidReasons: [{ count: 2, reason: 'missing-event-loop' }], + }, + { + ...overlapped.databaseWorker.requests, + invalidReasons: [ + { + count: 1, + reason: 'overlapping-database-worker-requests', + }, + ], + }, + { + ...overlapped.databaseWorker.requests, + eventLoopDelayP95Ms: null, + }, + { + ...overlapped.databaseWorker.requests, + metricScope: 'whole-worker-operation', + }, + ]) { + assert.throws( + () => + assertXtreamIterationResult({ + ...overlapped, + databaseWorker: { + ...overlapped.databaseWorker, + requests, + }, + } as unknown as XtreamRawIterationResult), + /invalid Xtream iteration/ + ); + } + const deletion = withWorkerRequestOverlap( + iteration({ + ...definition(), + scenarioId: XTREAM_SCENARIO_ID.DELETE_LARGE, + }), + 1 + ); + assert.throws( + () => assertXtreamIterationResult(deletion), + /invalid Xtream iteration/ + ); + }); + + it('derives request-window headline delay without relabelling whole-worker ELU', () => { + const raw = iteration(); + assert.equal( + raw.databaseWorker.eventLoopDelayMetricScope, + XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY + ); + assert.equal( + raw.databaseWorker.requests.metricScope, + XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY + ); + assert.equal( + raw.databaseWorker.wholeWorkerMetricScope, + XTREAM_WORKER_WHOLE_METRIC_SCOPE.OPERATION_WINDOW + ); + assert.notEqual( + raw.databaseWorker.eventLoopUtilization, + raw.databaseWorker.requests.eventLoopUtilization + ); + assert.doesNotThrow(() => assertXtreamIterationResult(raw)); + const arbitraryRequestProxy = { + eventLoopDelayMaxMs: 4, + eventLoopDelayP95Ms: 1, + eventLoopDelayP99Ms: 2, + eventLoopUtilization: 0.9, + }; + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + ...arbitraryRequestProxy, + eventLoopUtilization: + raw.databaseWorker.eventLoopUtilization, + requests: { + ...raw.databaseWorker.requests, + ...arbitraryRequestProxy, + }, + }, + }), + /invalid Xtream iteration/ + ); + assert.throws( + () => + assertXtreamIterationResult({ + ...raw, + databaseWorker: { + ...raw.databaseWorker, + requests: { + ...raw.databaseWorker.requests, + eventLoopDelayP95Ms: 2.5, + }, + }, + }), + /invalid Xtream iteration/ + ); + }); +}); + +function definition(): XtreamIterationDefinition { + return { + eligibleForComparison: true, + kind: XTREAM_ITERATION_KIND.MEASURED, + runId: 'run-01', + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + }; +} + +function iteration( + input: XtreamIterationDefinition = definition() +): XtreamRawIterationResult { + return createIteration(input, 1); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.ts b/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.ts new file mode 100644 index 000000000..4ff857463 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-iteration-validity.ts @@ -0,0 +1,361 @@ +import { + XTREAM_ITERATION_KIND, + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { + XTREAM_CANCEL_NETWORK_ABORT_REASON, + XTREAM_MAIN_CPU_METRIC_SCOPE, + XTREAM_NOT_APPLICABLE, + XTREAM_RENDERER_VISIBILITY, + XTREAM_STRUCTURED_CLONE_ATTRIBUTION, + XTREAM_WORKER_CPU_METRIC_SCOPE, + XTREAM_WORKER_REQUEST_METRIC_SCOPE, + XTREAM_WORKER_WHOLE_METRIC_SCOPE, + type XtreamRawIterationResult, +} from './xtream-summary-contract'; +import { + hasFiniteNonNegativeKeys, + hasMatchingEventLoopDelay, + hasOrderedEventLoopDelay, + assertXtreamSafePathIdentifier, + isExactXtreamProcessIdentity, + isExactRecord, + isFiniteNonNegative, + isPositiveSafeInteger, + isSafeNonNegativeInteger, + isUtilization, + isXtreamProfile, + XTREAM_CANCEL_TIME_KEYS, + XTREAM_MAIN_NUMBER_KEYS, + XTREAM_PHASE_KEYS, + XTREAM_PHASE_NUMBER_KEYS, + XTREAM_PROFILE_KEYS, + XTREAM_RAW_ITERATION_KEYS, + XTREAM_RENDERER_NUMBER_KEYS, + XTREAM_WORKER_NUMBER_KEYS, +} from './xtream-exact-schema'; +import { assertXtreamWorkerHeapWithinMainRss } from './xtream-memory-validity'; +import { assertXtreamIterationEvidenceBinding } from './xtream-iteration-evidence-binding'; +import { assertXtreamFixedIterationContracts } from './xtream-iteration-fixed-contracts'; +import { validXtreamWorkerRequestAccounting } from './xtream-worker-request-evidence'; + +export function assertXtreamIterationResult( + input: unknown +): asserts input is XtreamRawIterationResult { + try { + if (!isExactRecord(input, XTREAM_RAW_ITERATION_KEYS)) invalid(); + assertDefinition(input); + assertPhases(input['phases'], input['scenarioId']); + assertRenderer(input['renderer'], input); + assertMain(input['main'], input['kind']); + assertWorker( + input['databaseWorker'], + input['kind'], + input['scenarioId'] as XtreamScenarioId + ); + assertXtreamWorkerHeapWithinMainRss( + input['databaseWorker'], + input['main'] + ); + assertProcessIdentity(input['processIdentity']); + assertCancellation(input['cancellation'], input['scenarioId']); + assertXtreamFixedIterationContracts(input); + assertXtreamIterationEvidenceBinding( + input as unknown as XtreamRawIterationResult + ); + } catch { + throw new Error('invalid Xtream iteration'); + } +} + +function assertDefinition(value: Record): void { + if ( + !Object.values(XTREAM_SCENARIO_ID).includes( + value['scenarioId'] as never + ) || + !Object.values(XTREAM_ITERATION_KIND).includes( + value['kind'] as never + ) || + typeof value['eligibleForComparison'] !== 'boolean' || + (value['kind'] !== XTREAM_ITERATION_KIND.MEASURED && + value['eligibleForComparison']) + ) { + invalid(); + } + assertXtreamSafePathIdentifier(value['runId'], 'run-id'); +} + +function assertPhases(value: unknown, scenarioId: unknown): void { + if ( + !isExactRecord(value, XTREAM_PHASE_KEYS) || + !hasFiniteNonNegativeKeys(value, XTREAM_PHASE_NUMBER_KEYS) + ) { + invalid(); + } + const background = scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI; + const restores = + background || scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE; + if ( + (value['totalMs'] as number) <= 0 || + (value['angularRenderingMs'] as number) <= 0 || + value['indexesMs'] !== null || + value['indexesReason'] !== XTREAM_NOT_APPLICABLE.INDEXES_PRE_EXIST || + value['transactionCommitMs'] !== null || + value['transactionCommitReason'] !== + XTREAM_NOT_APPLICABLE.COMMIT_INCLUDED_IN_SQLITE_WRITE || + value['structuredCloneAttribution'] !== + XTREAM_STRUCTURED_CLONE_ATTRIBUTION.SELECTED_IPC_PROXY || + value['restoreUserDataMs'] !== null || + value['restoreUserDataReason'] !== + (restores + ? XTREAM_NOT_APPLICABLE.RESTORE_NO_USER_DATA + : XTREAM_NOT_APPLICABLE.RESTORE_NOT_INVOKED) || + value['backgroundSearchMarkerMs'] !== null || + value['backgroundSearchMarkerReason'] !== + (background ? XTREAM_NOT_APPLICABLE.SOURCES_SEARCH_MARKER : null) || + XTREAM_PHASE_NUMBER_KEYS.some( + (key) => (value[key] as number) > (value['totalMs'] as number) + ) || + (scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE && + (value['dataAcquisitionMs'] !== 0 || + value['jsonParsingMs'] !== 0 || + value['normalizationMs'] !== 0 || + value['deserializationMs'] !== 0)) + ) { + invalid(); + } +} + +function assertRenderer(value: unknown, raw: Record): void { + const keys = [ + ...XTREAM_PROFILE_KEYS, + ...XTREAM_RENDERER_NUMBER_KEYS, + 'actionLatencyMs', + 'consoleErrors', + 'dashboardPaintVisibility', + 'navigationLatencyMs', + 'searchLatencyMs', + 'targetId', + ] as const; + if ( + !isExactRecord(value, keys) || + !hasFiniteNonNegativeKeys(value, XTREAM_RENDERER_NUMBER_KEYS) || + !isXtreamProfile(value, raw['kind']) || + !isSafeNonNegativeInteger(value['longTaskCount']) || + typeof value['targetId'] !== 'string' || + value['targetId'].length === 0 || + !Array.isArray(value['consoleErrors']) || + value['consoleErrors'].length !== 0 || + value['storeToPaintMs'] !== + (raw['phases'] as Record)['angularRenderingMs'] || + (value['frameGapP95Ms'] as number) > + (value['frameGapMaxMs'] as number) || + (value['heartbeatP95Ms'] as number) > + (value['heartbeatMaxMs'] as number) || + (value['peakHeapUsedBytes'] as number) <= 0 || + (value['peakRssBytes'] as number) <= 0 || + (value['postGcHeapUsedBytes'] as number) <= 0 || + (value['postGcHeapUsedBytes'] as number) > + (value['peakHeapUsedBytes'] as number) || + (value['peakHeapUsedBytes'] as number) > + (value['peakRssBytes'] as number) || + (value['longTaskCount'] === 0) !== (value['longTaskMaxMs'] === 0) + ) { + invalid(); + } + const background = raw['scenarioId'] === XTREAM_SCENARIO_ID.BACKGROUND_UI; + const optional = [ + value['navigationLatencyMs'], + value['searchLatencyMs'], + value['actionLatencyMs'], + ]; + if ( + (background && + (value['dashboardPaintVisibility'] !== + XTREAM_RENDERER_VISIBILITY.VISIBLE_WITHOUT_OVERLAY || + !optional.every(isFiniteNonNegative))) || + (!background && + (value['dashboardPaintVisibility'] !== null || + optional.some((entry) => entry !== null))) + ) { + invalid(); + } +} + +function assertMain(value: unknown, kind: unknown): void { + const keys = [ + ...XTREAM_PROFILE_KEYS, + ...XTREAM_MAIN_NUMBER_KEYS, + 'cpuMetricScope', + 'eventLoopUtilization', + 'eventLoopUtilizationUnavailableReason', + ] as const; + if ( + !isExactRecord(value, keys) || + !hasFiniteNonNegativeKeys(value, XTREAM_MAIN_NUMBER_KEYS) || + !isXtreamProfile(value, kind) || + !isPositiveSafeInteger(value['browserWindowId']) || + !isPositiveSafeInteger(value['webContentsId']) || + !isSafeNonNegativeInteger(value['responsiveEvents']) || + !isSafeNonNegativeInteger(value['unresponsiveEvents']) || + value['eventLoopUtilization'] !== null || + value['eventLoopUtilizationUnavailableReason'] !== + XTREAM_NOT_APPLICABLE.MAIN_EVENT_LOOP_UTILIZATION || + value['cpuMetricScope'] !== + XTREAM_MAIN_CPU_METRIC_SCOPE.ELECTRON_MAIN_THREAD || + !hasOrderedEventLoopDelay(value) || + (value['peakHeapUsedBytes'] as number) <= 0 || + (value['peakRssBytes'] as number) <= 0 || + (value['postGcHeapUsedBytes'] as number) <= 0 || + (value['postGcHeapUsedBytes'] as number) > + (value['peakHeapUsedBytes'] as number) || + (value['peakHeapUsedBytes'] as number) > + (value['peakRssBytes'] as number) + ) { + invalid(); + } +} + +function assertWorker( + value: unknown, + kind: unknown, + scenarioId: XtreamScenarioId +): void { + const keys = [ + ...XTREAM_PROFILE_KEYS, + ...XTREAM_WORKER_NUMBER_KEYS, + 'cpuMetricScope', + 'currentForCapture', + 'eventLoopDelayMetricScope', + 'kind', + 'requests', + 'rssBytes', + 'rssUnavailableReason', + 'wholeWorkerMetricScope', + ] as const; + if ( + !isExactRecord(value, keys) || + !hasFiniteNonNegativeKeys(value, XTREAM_WORKER_NUMBER_KEYS) || + !isXtreamProfile(value, kind) || + value['kind'] !== 'database.worker' || + value['cpuMetricScope'] !== + XTREAM_WORKER_CPU_METRIC_SCOPE.SUM_VALID_REQUEST_WORK || + value['currentForCapture'] !== true || + !isPositiveSafeInteger(value['ordinal']) || + !isUtilization(value['eventLoopUtilization']) || + !hasOrderedEventLoopDelay(value) || + !validXtreamWorkerRequestAccounting(value['requests'], scenarioId) || + !hasMatchingEventLoopDelay(value) || + value['eventLoopDelayMetricScope'] !== + XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY || + value['wholeWorkerMetricScope'] !== + XTREAM_WORKER_WHOLE_METRIC_SCOPE.OPERATION_WINDOW || + value['rssBytes'] !== null || + value['rssUnavailableReason'] !== + XTREAM_NOT_APPLICABLE.DATABASE_WORKER_RSS || + (value['peakHeapUsedBytes'] as number) <= 0 || + (value['postGcHeapUsedBytes'] as number) <= 0 || + (value['postGcHeapUsedBytes'] as number) > + (value['peakHeapUsedBytes'] as number) + ) { + invalid(); + } +} + +function assertProcessIdentity(value: unknown): void { + if (!isExactXtreamProcessIdentity(value)) invalid(); +} + +function assertCancellation(value: unknown, scenarioId: unknown): void { + const keys = [ + ...XTREAM_CANCEL_TIME_KEYS, + 'authoritativeDatabaseAcknowledgementObserved', + 'failedDatabaseRequestId', + 'failedOperationId', + 'mainNetworkAbortObserved', + 'mainNetworkAbortReason', + 'workerReceiptIsAuthoritative', + ] as const; + if (!isExactRecord(value, keys)) invalid(); + const cancel = scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT; + if (!cancel) { + if ( + XTREAM_CANCEL_TIME_KEYS.some((key) => value[key] !== null) || + value['authoritativeDatabaseAcknowledgementObserved'] !== false || + value['failedDatabaseRequestId'] !== null || + value['failedOperationId'] !== null || + value['mainNetworkAbortObserved'] !== false || + value['mainNetworkAbortReason'] !== null || + value['workerReceiptIsAuthoritative'] !== false + ) { + invalid(); + } + return; + } + if ( + !XTREAM_CANCEL_TIME_KEYS.every((key) => + isFiniteNonNegative(value[key]) + ) || + value['authoritativeDatabaseAcknowledgementObserved'] !== true || + typeof value['failedDatabaseRequestId'] !== 'string' || + value['failedDatabaseRequestId'].length === 0 || + typeof value['failedOperationId'] !== 'string' || + value['failedOperationId'].length === 0 || + value['mainNetworkAbortObserved'] !== false || + value['mainNetworkAbortReason'] !== + XTREAM_CANCEL_NETWORK_ABORT_REASON.AFTER_NETWORK_COMPLETE || + value['workerReceiptIsAuthoritative'] !== false + ) { + invalid(); + } + const click = value['clickEpochMs'] as number; + const preloadStart = value['preloadStartEpochMs'] as number; + const databaseDispatch = value['databaseDispatchEpochMs'] as number; + const preloadReturn = value['preloadReturnEpochMs'] as number; + const workerReceipt = value['workerReceiptEpochMs'] as number; + const authoritativeTerminal = value[ + 'authoritativeTerminalEpochMs' + ] as number; + const painted = value['paintedEpochMs'] as number; + if ( + click > preloadStart || + preloadStart > databaseDispatch || + databaseDispatch > preloadReturn || + databaseDispatch > workerReceipt || + workerReceipt > authoritativeTerminal || + authoritativeTerminal > painted || + !derived( + value, + 'clickToDatabaseDispatchMs', + 'databaseDispatchEpochMs' + ) || + !derived(value, 'clickToPreloadReturnMs', 'preloadReturnEpochMs') || + !derived(value, 'workerReceiptLatencyMs', 'workerReceiptEpochMs') || + !derived( + value, + 'acknowledgementLatencyMs', + 'authoritativeTerminalEpochMs' + ) || + !derived(value, 'clickToPaintMs', 'paintedEpochMs') + ) { + invalid(); + } +} + +function derived( + value: Record, + latency: string, + epoch: string +): boolean { + return ( + Math.abs( + (value[latency] as number) - + ((value[epoch] as number) - (value['clickEpochMs'] as number)) + ) <= 0.001 + ); +} + +function invalid(): never { + throw new Error('invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-capture-api.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-main-capture-api.spec.ts new file mode 100644 index 000000000..8037eecda --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-capture-api.spec.ts @@ -0,0 +1,341 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +import type { ElectronApplication } from '@playwright/test'; + +import { + readXtreamMainCaptureStatus, + rolloverXtreamMainCapture, + stopXtreamMainCapture, + type XtreamMainCaptureTransport, +} from './m3u-refresh-main-capture'; + +const STATE_KEY = '__iptvnatorM3uRefreshMainCapture'; +const target = globalThis as unknown as Record; + +function electronApp(): ElectronApplication { + return { + evaluate: async ( + callback: (...args: never[]) => unknown, + input: never + ) => callback({} as never, input), + } as unknown as ElectronApplication; +} + +function electronAppRejectingAsyncEvaluation(): ElectronApplication { + return { + evaluate: (callback: (...args: never[]) => unknown, input: never) => { + const result = callback({} as never, input); + if (result instanceof Promise) { + return Promise.reject( + new Error('async-main-evaluator-not-allowed') + ); + } + return Promise.resolve(result); + }, + } as unknown as ElectronApplication; +} + +function transport( + overrides: Partial = {} +): XtreamMainCaptureTransport { + return { + captureGeneration: 7, + metrics: { + cpuProfilePath: null, + cpuSystemMicros: 11, + cpuUserMicros: 13, + eventLoopDelay: { maxMs: 3, p95Ms: 1, p99Ms: 2 }, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + 'electron-main-embedded-event-loop', + heapSnapshotPath: null, + memory: { + peakHeapUsedBytes: 100, + peakRssBytes: 200, + postGcHeapUsedBytes: 80, + postGcRssBytes: 160, + }, + rendererWindow: { + responsiveEvents: 0, + rss: { + identity: { creationTime: 1_000, pid: 44 }, + missingSampleCount: 0, + peakRssBytes: 90, + unavailableReason: null, + validSampleCount: 1, + }, + unresponsiveEvents: 0, + windowIdentity: { + browserWindowId: 2, + webContentsId: 3, + }, + }, + rssScope: + 'electron-main-process-including-worker-threads-and-native-memory', + timeline: [], + }, + rollover: null, + workers: [ + { + captureGeneration: 7, + metrics: { + cancelPostedEpochMs: null, + cpuSystemMicros: 5, + cpuUserMicros: 8, + eventLoopUtilization: 0.2, + externalMemorySampleCount: 1, + heapUsedSampleCount: 1, + kind: 'database.worker', + operationId: null, + ordinal: 1, + peakExternalBytes: 20, + peakExternalUnavailableReason: null, + peakHeapUsedBytes: 40, + peakHeapUsedUnavailableReason: null, + playlistId: null, + postGcHeapUnavailableReason: null, + postGcHeapUsedBytes: 30, + profilePath: null, + responseEpochMs: null, + snapshotPath: null, + terminatedEpochMs: null, + }, + requests: [ + { + ipcCallId: 5, + operation: 'DB_DELETE_XTREAM_CONTENT', + operationId: 'operation-1', + operationIdUnavailableReason: null, + performanceCapture: null, + playlistId: 'playlist-1', + requestId: 'request-1', + responseEpochMs: 180, + sourceEpochMs: 110, + success: true, + }, + ], + }, + ], + xtream: { + cancelCapture: { + invalidReasons: [], + timeline: [], + }, + captureCutoffEpochMs: 190, + captureGeneration: 7, + captureStartedEpochMs: 100, + captureStoppedEpochMs: 220, + databaseWorkerCount: 1, + invalidReasons: [], + ipcCapture: { + invalidReasons: [], + quarantinedKeyCount: 0, + timeline: [], + }, + lateDatabaseRequestCount: 0, + lateEventCount: 0, + measurementStartedEpochMs: 105, + operationOutcomes: [ + { + count: 1, + operation: 'DB_DELETE_XTREAM_CONTENT', + outcome: 'success', + }, + ], + playlistRefreshWorkerCount: 0, + }, + ...overrides, + }; +} + +afterEach(() => delete target[STATE_KEY]); + +describe('Xtream main capture API', () => { + it('reads live status through a synchronous main evaluator', async () => { + target[STATE_KEY] = { + xtreamStatus: () => ({ + active: true, + activeOperations: [], + captureGeneration: 7, + captureStartedEpochMs: 100, + cutoffPhase: 'active', + databaseWorkerCount: 1, + invalidReasons: [], + lateDatabaseRequestCount: 0, + lateEventCount: 0, + measurementPhase: 'measuring', + measurementStartedEpochMs: 105, + operationOutcomes: [], + playlistRefreshWorkerCount: 0, + }), + }; + + const status = await readXtreamMainCaptureStatus( + electronAppRejectingAsyncEvaluation() + ); + + assert.equal(status.captureGeneration, 7); + }); + + it('returns live generation, exact active DB operation, and progress', async () => { + target[STATE_KEY] = { + xtreamStatus: () => ({ + active: true, + activeOperations: [ + { + operation: 'DB_DELETE_XTREAM_CONTENT', + operationId: 'operation-1', + playlistId: 'playlist-1', + progress: { + current: 250, + epochMs: 150, + phase: 'deleting-content', + status: 'progress', + total: 1_000, + }, + requestId: 'request-1', + }, + ], + captureGeneration: 7, + captureStartedEpochMs: 100, + cutoffPhase: 'active', + databaseWorkerCount: 1, + invalidReasons: [], + lateDatabaseRequestCount: 0, + lateEventCount: 0, + measurementPhase: 'measuring', + measurementStartedEpochMs: 105, + operationOutcomes: [ + { + count: 1, + operation: 'DB_DELETE_XTREAM_CONTENT', + outcome: 'pending', + }, + ], + playlistRefreshWorkerCount: 0, + }), + }; + + const status = await readXtreamMainCaptureStatus(electronApp()); + + assert.equal(status.captureGeneration, 7); + assert.equal( + status.activeOperations[0]?.operation, + 'DB_DELETE_XTREAM_CONTENT' + ); + assert.equal(status.activeOperations[0]?.progress?.current, 250); + assert.deepEqual(status.operationOutcomes, [ + { + count: 1, + operation: 'DB_DELETE_XTREAM_CONTENT', + outcome: 'pending', + }, + ]); + }); + + it('keeps completed generation and cutoff evidence across rollover', async () => { + target[STATE_KEY] = { + stop: () => + transport({ + rollover: { + nextCaptureStarted: true, + nextCaptureUnavailableReason: null, + }, + }), + }; + + const result = await rolloverXtreamMainCapture(electronApp(), { + diagnostic: false, + outputDirectory: '/tmp/synthetic-performance', + rendererWindowIdentity: { + browserWindowId: 2, + webContentsId: 3, + }, + }); + + assert.equal(result.captureGeneration, 7); + assert.equal(result.captureStartedEpochMs, 100); + assert.equal(result.captureCutoffEpochMs, 190); + assert.equal(result.captureStoppedEpochMs, 220); + assert.equal(result.rollover?.nextCaptureStarted, true); + assert.equal(result.databaseWorkerCount, 1); + assert.equal(result.playlistRefreshWorkerCount, 0); + assert.equal(result.quarantinedIpcMarkerCount, 0); + assert.equal(result.workers.length, 1); + assert.equal(result.requests.length, 1); + }); + + it('fails closed when the stop generation is missing or inconsistent', async () => { + for (const invalid of [ + transport({ captureGeneration: 0 }), + transport({ + xtream: { + ...transport().xtream, + captureGeneration: 8, + }, + }), + ]) { + target[STATE_KEY] = { stop: () => invalid }; + await assert.rejects( + stopXtreamMainCapture(electronApp()), + /xtream-main-capture-transport-invalid/ + ); + } + }); + + it('fails closed on unknown IPC and cancel timeline labels', async () => { + for (const invalid of [ + transport({ + xtream: { + ...transport().xtream, + ipcCapture: { + invalidReasons: [], + quarantinedKeyCount: 0, + timeline: [{ epochMs: 120, type: 'unknown-ipc-label' }], + } as never, + }, + }), + transport({ + xtream: { + ...transport().xtream, + cancelCapture: { + invalidReasons: [], + timeline: [ + { epochMs: 130, type: 'unknown-cancel-label' }, + ], + } as never, + }, + }), + ]) { + target[STATE_KEY] = { stop: () => invalid }; + await assert.rejects( + stopXtreamMainCapture(electronApp()), + /xtream-main-capture-transport-invalid/ + ); + } + }); + + it('fails closed when operation outcomes disagree with request evidence', async () => { + target[STATE_KEY] = { + stop: () => + transport({ + xtream: { + ...transport().xtream, + operationOutcomes: [ + { + count: 2, + operation: 'DB_DELETE_XTREAM_CONTENT', + outcome: 'success', + }, + ], + }, + }), + }; + + await assert.rejects( + stopXtreamMainCapture(electronApp()), + /xtream-main-capture-transport-invalid/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-capture-integration.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-main-capture-integration.spec.ts new file mode 100644 index 000000000..a5f7701ce --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-capture-integration.spec.ts @@ -0,0 +1,235 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; + +const source = readFileSync( + join(import.meta.dirname, 'm3u-refresh-main-capture.ts'), + 'utf8' +); + +describe('Xtream main capture integration', () => { + it('installs the strict preload and diagnostics correlators in the existing capture generation', () => { + for (const required of [ + 'createDatabaseWorkerCancelCapture', + 'createDiagnosticsPerformancePhaseEventParser', + 'createPerformanceTimelineMergeApi', + 'createXtreamIpcMarkerCapture', + 'createXtreamIpcMarkerProtocol', + 'createXtreamMainLifecycleValidator', + 'XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL', + 'XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL', + 'xtreamIpcMarkerCapture.acceptPreload', + 'xtreamIpcMarkerCapture.acceptMainPhase', + 'xtreamIpcMarkerCapture.start', + 'xtreamIpcMarkerCapture.stop', + 'databaseWorkerCancelCapture.acceptReceipt', + 'databaseWorkerCancelCapture.acceptTerminal', + 'databaseWorkerCancelCapture.start', + 'databaseWorkerCancelCapture.stop', + ]) { + assert.ok(source.includes(required), `missing ${required}`); + } + }); + + it('restores argument-taking capture functions without invoking them', () => { + assert.match( + source, + /const restoreFunction = \(source: string\): T =>/ + ); + assert.match( + source, + /const factory = restoreFunction<\(\) => T>\(source\);\s*return factory\(\);/ + ); + + for (const factoryName of [ + 'diagnosticsPerformancePhaseEventParserFactory', + 'xtreamIpcMarkerProtocolFactory', + 'xtreamIpcMarkerCaptureFactory', + 'xtreamMainLifecycleValidatorFactory', + ]) { + assert.match( + source, + new RegExp( + `const ${factoryName} = restoreFunction<[\\s\\S]*?>\\(` + ), + `${factoryName} must be restored without an eager zero-argument call` + ); + } + }); + + it('fences Xtream events with an epoch captured before activation', () => { + const captureStart = source.indexOf( + 'const captureStartedEpochMs = nowEpochMs()' + ); + const active = source.indexOf('state.active = true', captureStart); + assert.ok(captureStart >= 0); + assert.ok(active > captureStart); + assert.match( + source, + /xtreamIpcMarkerCapture\.start\(\s*options\.rendererWindowIdentity\.webContentsId,\s*captureStartedEpochMs\s*\)/ + ); + }); + + it('arms attribution before starting deferred operation-window metrics exactly once', () => { + const measurement = section( + 'const beginMeasurement = async', + 'const startCapture = async' + ); + const arm = section('const startCapture = async', 'const api ='); + + assert.doesNotMatch(arm, /threadCpuUsage|monitorEventLoopDelay/); + assert.doesNotMatch(arm, /setInterval\(sampleMain/); + assert.doesNotMatch(arm, /'Profiler\.start'/); + assert.match(measurement, /state\.measurementStartedEpochMs/); + assert.match(measurement, /runtimeProcess\.threadCpuUsage\(\)/); + assert.match(measurement, /monitorEventLoopDelay/); + assert.match(measurement, /setInterval\(sampleMain, 20\)/); + assert.match(measurement, /'Profiler\.start'/); + assert.match( + source, + /if \(!options\.deferMeasurement\) \{\s*await beginMeasurement\(\);\s*\}/ + ); + assert.match( + source, + /beginMeasurement: async \(\): Promise =>\s*beginMeasurement\(\)/ + ); + assert.match( + source, + /export async function beginXtreamMainMeasurement/ + ); + }); + + it('uses Electron main-thread CPU accounting instead of process-wide CPU', () => { + assert.match( + source, + /runtimeProcess\.threadCpuUsage\(\s*state\.cpuStart \?\? undefined\s*\)/ + ); + assert.doesNotMatch(source, /\bprocess\.cpuUsage\(/); + assert.doesNotMatch(source, /\bruntimeProcess\.cpuUsage\(/); + }); + + it('correlates DB cancel dispatch before recognizing the worker receipt', () => { + const dispatch = source.indexOf( + 'xtreamIpcMarkerCapture.matchDatabaseCancel' + ); + const receipt = source.indexOf( + 'databaseWorkerCancelCapture.acceptReceipt' + ); + const response = source.indexOf("message['type'] === 'response'"); + assert.ok(dispatch >= 0); + assert.ok(receipt >= 0); + assert.ok(response >= 0); + assert.ok(receipt < response); + assert.match( + source, + /message\['type'\]\s*===\s*'performance-cancel-received'/ + ); + }); + + it('routes Xtream DB request attribution before the legacy M3U identity branch', () => { + const xtreamMatch = source.indexOf( + 'xtreamIpcMarkerCapture.matchDatabaseRequest' + ); + const m3uMatch = source.indexOf( + 'databaseRequestIdentityCapture.matchDatabaseRequest' + ); + assert.ok(xtreamMatch >= 0); + assert.ok(m3uMatch >= 0); + assert.ok(xtreamMatch < m3uMatch); + }); + + it('merges stop-buffered Xtream events before timeline serialization', () => { + const merge = source.indexOf('performanceTimelineMergeApi.merge'); + const serialize = source.indexOf('timeline: state.timeline'); + assert.ok(merge >= 0); + assert.ok(serialize >= 0); + assert.ok(merge < serialize); + assert.equal( + source.includes( + 'state.timeline.push(...xtreamIpcSnapshot.timeline)' + ), + false + ); + }); + + it('adds Xtream status gates without replacing the existing M3U status API', () => { + for (const required of [ + 'status: (): MainCaptureStatus', + 'xtreamStatus: (): XtreamMainCaptureStatus', + 'activeOperations:', + 'captureGeneration: state.captureGeneration', + 'lateDatabaseRequestCount: cutoff.lateRequestCount', + 'operationOutcomes: readOperationOutcomes()', + "kind === 'database.worker'", + "kind === 'playlist-refresh.worker'", + ]) { + assert.ok(source.includes(required), `missing ${required}`); + } + assert.match( + source, + /progress:\s*request\.progress\s*\?\s*\{ \.\.\.request\.progress \}\s*:\s*null/ + ); + }); + + it('snapshots the completed generation and cutoff before atomically starting the next capture', () => { + const cutoff = source.indexOf( + 'const captureCutoffEpochMs = nowEpochMs()' + ); + const beginStop = source.indexOf( + 'databaseWorkerPostGcCutoffApi.beginStop()', + cutoff + ); + const stopped = source.indexOf( + 'const captureStoppedEpochMs = nowEpochMs()', + beginStop + ); + const rollover = source.indexOf( + 'databaseWorkerPostGcCutoffApi.rolloverCapture()', + stopped + ); + const nextStart = source.indexOf( + 'await startCapture(nextOptions)', + rollover + ); + const completedReturn = source.indexOf( + 'return { ...transport, rollover, xtream }', + nextStart + ); + + assert.ok(cutoff >= 0); + assert.ok(cutoff < beginStop); + assert.ok(beginStop < stopped); + assert.ok(stopped < rollover); + assert.ok(rollover < nextStart); + assert.ok(nextStart < completedReturn); + assert.match(source, /captureGeneration: completedCaptureGeneration/); + assert.match( + source, + /captureStartedEpochMs: completedCaptureStartedEpochMs/ + ); + }); + + it('strictly allowlists Xtream IPC and cancel timeline labels at the public boundary', () => { + assert.match( + source, + /XTREAM_IPC_TIMELINE_TYPES = new Set\(\[\s*'xtream-main-phase',\s*'xtream-preload-marker'/ + ); + assert.match( + source, + /XTREAM_CANCEL_TIMELINE_TYPES = new Set\(\[\s*'db-cancel-dispatched',\s*'db-cancel-received',\s*'db-cancel-terminal-received'/ + ); + assert.match( + source, + /throw new Error\(`xtream-main-capture-\$\{kind\}-invalid`\)/ + ); + }); +}); + +function section(startMarker: string, endMarker: string): string { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start); + assert.ok(start >= 0, `missing start marker: ${startMarker}`); + assert.ok(end > start, `missing end marker: ${endMarker}`); + return source.slice(start, end); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-lifecycle.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-main-lifecycle.spec.ts new file mode 100644 index 000000000..6ee28c826 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-lifecycle.spec.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { createXtreamMainLifecycleValidator } from './xtream-main-lifecycle'; + +describe('Xtream request-scoped main lifecycle validator', () => { + it('reconstructs without module closures and accepts legal producer paths', () => { + const restore = (source: string): T => + new Function(`"use strict"; return (${source});`)() as T; + const factory = restore( + createXtreamMainLifecycleValidator.toString() + ); + const validate = factory().validate; + const network = phase('xtream.network.total', 100, 120); + const json = phase('xtream.json.transform', 105, 110); + const response = phase('xtream.response.ready', 121, 122); + + assert.equal( + validate({ + method: 'xtreamRequest', + outcome: 'success', + phases: [network, json, response], + }), + null + ); + assert.equal( + validate({ + method: 'xtreamRequest', + outcome: 'error', + phases: [network], + }), + null + ); + assert.equal( + validate({ + method: 'xtreamRequest', + outcome: 'error', + phases: [network, json], + }), + null + ); + assert.equal( + validate({ + method: 'xtreamCancelSession', + outcome: 'success', + phases: [phase('xtream.cancel-session', 100, 101)], + }), + null + ); + }); + + it('rejects incomplete and impossible request lifecycles', () => { + const validate = createXtreamMainLifecycleValidator().validate; + const network = phase('xtream.network.total', 100, 120); + const json = phase('xtream.json.transform', 105, 110); + + assert.equal( + validate({ + method: 'xtreamRequest', + outcome: 'success', + phases: [network, json], + }), + 'main-phase-inventory-incomplete' + ); + assert.equal( + validate({ + method: 'xtreamRequest', + outcome: 'success', + phases: [ + network, + json, + phase('xtream.response.ready', 119, 121), + ], + }), + 'main-phase-topology-invalid' + ); + assert.equal( + validate({ + method: 'xtreamRequest', + outcome: 'error', + phases: [network, phase('xtream.response.ready', 121, 122)], + }), + 'main-phase-topology-invalid' + ); + }); +}); + +function phase(phaseName: string, startEpochMs: number, endEpochMs: number) { + return { + endEpochMs, + phase: phaseName, + startEpochMs, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-lifecycle.ts b/apps/electron-backend-e2e/src/performance/xtream-main-lifecycle.ts new file mode 100644 index 000000000..49b1fde6d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-lifecycle.ts @@ -0,0 +1,111 @@ +export interface XtreamMainLifecyclePhase { + readonly endEpochMs: number | null; + readonly phase: string; + readonly startEpochMs: number; +} + +export interface XtreamMainLifecycleInput { + readonly method: string; + readonly outcome: 'error' | 'success' | null; + readonly phases: readonly XtreamMainLifecyclePhase[]; +} + +export interface XtreamMainLifecycleValidator { + validate(input: XtreamMainLifecycleInput): string | null; +} + +export function createXtreamMainLifecycleValidator(): XtreamMainLifecycleValidator { + const helpers = { + closedPhase( + phases: readonly XtreamMainLifecyclePhase[], + phase: string + ): XtreamMainLifecyclePhase | null { + const matches = phases.filter( + (candidate) => candidate.phase === phase + ); + const match = matches[0]; + return matches.length === 1 && match && match.endEpochMs !== null + ? match + : null; + }, + isEpoch(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isFinite(value) && value > 0 + ); + }, + }; + return { + validate(input): string | null { + const phaseNames = input.phases.map(({ phase }) => phase); + if ( + new Set(phaseNames).size !== phaseNames.length || + input.phases.some( + ({ endEpochMs, startEpochMs }) => + !helpers.isEpoch(startEpochMs) || + (endEpochMs !== null && + (!helpers.isEpoch(endEpochMs) || + endEpochMs < startEpochMs)) + ) + ) { + return 'main-phase-inventory-incomplete'; + } + if (input.method === 'xtreamCancelSession') { + return helpers.closedPhase( + input.phases, + 'xtream.cancel-session' + ) && + phaseNames.every( + (phase) => phase === 'xtream.cancel-session' + ) + ? null + : 'main-phase-inventory-incomplete'; + } + if ( + input.method !== 'xtreamRequest' || + (input.outcome !== 'success' && input.outcome !== 'error') + ) { + return 'main-phase-inventory-incomplete'; + } + + const network = helpers.closedPhase( + input.phases, + 'xtream.network.total' + ); + const json = helpers.closedPhase( + input.phases, + 'xtream.json.transform' + ); + const response = helpers.closedPhase( + input.phases, + 'xtream.response.ready' + ); + if ( + !network || + phaseNames.some( + (phase) => + phase !== 'xtream.network.total' && + phase !== 'xtream.json.transform' && + phase !== 'xtream.response.ready' + ) || + (phaseNames.includes('xtream.json.transform') && !json) || + (input.outcome === 'success' && (!json || !response)) + ) { + return 'main-phase-inventory-incomplete'; + } + if ( + (json && + (json.startEpochMs < network.startEpochMs || + Number(json.endEpochMs) > + Number(network.endEpochMs))) || + (input.outcome === 'success' && + response !== null && + response.startEpochMs < Number(network.endEpochMs)) || + (input.outcome === 'error' && + phaseNames.includes('xtream.response.ready')) + ) { + return 'main-phase-topology-invalid'; + } + return null; + }, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-phase-duration.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-main-phase-duration.spec.ts new file mode 100644 index 000000000..fcd71b3eb --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-phase-duration.spec.ts @@ -0,0 +1,71 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { XTREAM_MAIN_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; + +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { pairXtreamPhaseEvents } from './xtream-phase-event-pairing'; +import type { + XtreamAttributionPhaseEvent, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; + +test('accepts producer-shaped main output when separately read clocks disagree on the phase span', () => { + const events = [event('start', null, 101), event('end', 1.6115, 102)]; + const paired = pairXtreamPhaseEvents(input(events)); + assert.equal(paired[0]?.durationMs, 1.6115); + assert.equal( + Number(paired[0]?.endEpochMs) - Number(paired[0]?.startEpochMs), + 1 + ); +}); + +test('rejects a main phase duration outside the operation envelope', () => { + const events: XtreamAttributionPhaseEvent[] = [ + event('start', null, 101), + event('end', 999, 102), + ]; + + assert.throws( + () => pairXtreamPhaseEvents(input(events)), + /invalid Xtream phase event pairing/ + ); +}); + +function event( + boundary: 'end' | 'start', + durationMs: number | null, + epochMs: number +): XtreamAttributionPhaseEvent { + return { + boundary, + categoryType: null, + contentType: null, + correlationId: 'xtream-mabc1000-1', + durationMs, + epochMs, + itemCount: null, + phase: XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION, + providerAction: null, + semanticType: null, + }; +} + +function input( + events: readonly XtreamAttributionPhaseEvent[], + scenarioId: XtreamScenarioId = XTREAM_SCENARIO_ID.CANCEL_IMPORT +): XtreamPhaseAttributionInput { + return { + backgroundSearchMarkerObserved: false, + events, + ipcSpans: [], + operationStartEpochMs: 100, + scenarioId, + terminalEpochMs: 120, + uiPaintEpochMs: 120, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-phase-events.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-main-phase-events.spec.ts new file mode 100644 index 000000000..39bd02f15 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-phase-events.spec.ts @@ -0,0 +1,378 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + XTREAM_MAIN_PERFORMANCE_PHASE, + XTREAM_PRELOAD_PERFORMANCE_METHOD, + XtreamCodeActions, +} from '@iptvnator/shared/interfaces'; + +import { + createXtreamIpcMarkerCapture, + deriveXtreamAcquisitionDurationMs, + type XtreamIpcMarkerCaptureOptions, +} from './xtream-ipc-marker-events'; + +const options: XtreamIpcMarkerCaptureOptions = { + actions: Object.values(XtreamCodeActions), + mainPhases: Object.values(XTREAM_MAIN_PERFORMANCE_PHASE), + methods: Object.values(XTREAM_PRELOAD_PERFORMANCE_METHOD), + schemaVersion: 1, +}; + +function marker(overrides: Record = {}) { + return { + schemaVersion: 1, + sourceEpochMs: 100, + ipcCallId: 1, + method: 'dbSaveContent', + boundary: 'start', + outcome: null, + playlistId: 'playlist-1', + operationId: 'operation-1', + sessionId: null, + action: null, + categoryType: null, + contentType: 'live', + itemCount: 60_000, + ...overrides, + }; +} + +function mainPhase(overrides: Record = {}) { + return { + boundary: 'start', + durationMs: null, + epochMs: 110, + phase: 'xtream.network.total', + requestId: 'xtream-mock1-1', + ...overrides, + }; +} + +describe('Xtream main phase correlation', () => { + it('binds network phases to preload calls in FIFO order without inventing handler entry time', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + assert.equal( + capture.acceptPreload( + 42, + 100, + marker({ + action: XtreamCodeActions.GetLiveCategories, + contentType: null, + ipcCallId: 1, + itemCount: null, + method: 'xtreamRequest', + operationId: null, + playlistId: null, + sessionId: 'session-1', + }) + ), + true + ); + assert.equal( + capture.acceptMainPhase( + mainPhase({ epochMs: 101, requestId: 'xtream-mock1-1' }) + ), + true + ); + assert.equal( + capture.acceptMainPhase( + mainPhase({ + boundary: 'end', + durationMs: 9, + epochMs: 110, + requestId: 'xtream-mock1-1', + }) + ), + true + ); + + const mainEvents = capture + .stop() + .timeline.filter(({ type }) => type === 'xtream-main-phase'); + assert.deepEqual( + mainEvents.map(({ action, ipcCallId, requestId }) => ({ + action, + ipcCallId, + requestId, + })), + [ + { + action: XtreamCodeActions.GetLiveCategories, + ipcCallId: 1, + requestId: 'xtream-mock1-1', + }, + { + action: XtreamCodeActions.GetLiveCategories, + ipcCallId: 1, + requestId: 'xtream-mock1-1', + }, + ] + ); + assert.equal( + JSON.stringify(mainEvents).includes('handler-entry'), + false + ); + }); + + it('invalidates a successful preload request without its required main linkage and phase inventory', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + const start = marker({ + action: XtreamCodeActions.GetLiveCategories, + contentType: null, + ipcCallId: 1, + itemCount: null, + method: 'xtreamRequest', + operationId: null, + playlistId: null, + sessionId: 'session-1', + }); + assert.equal(capture.acceptPreload(42, 100, start), true); + assert.equal( + capture.acceptPreload( + 42, + 120, + marker({ + ...start, + boundary: 'end', + outcome: 'success', + sourceEpochMs: 119, + }) + ), + true + ); + + assert.ok( + capture + .stop() + .invalidReasons.includes('preload-main-linkage-missing') + ); + }); + + it('invalidates an errored preload request when no main lifecycle was captured', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + const start = xtreamRequestMarker(); + assert.equal(capture.acceptPreload(42, 100, start), true); + assert.equal( + capture.acceptPreload( + 42, + 120, + marker({ + ...start, + boundary: 'end', + outcome: 'error', + sourceEpochMs: 119, + }) + ), + true + ); + + assert.ok( + capture + .stop() + .invalidReasons.includes('preload-main-linkage-missing') + ); + }); + + it('rejects response-ready before network completion for one request', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + const start = xtreamRequestMarker(); + assert.equal(capture.acceptPreload(42, 100, start), true); + acceptMainBoundary(capture, 'xtream.network.total', 'start', 101); + acceptMainPair(capture, 'xtream.json.transform', 102, 104); + acceptMainPair(capture, 'xtream.response.ready', 105, 106); + acceptMainBoundary(capture, 'xtream.network.total', 'end', 110, 9); + assert.equal( + capture.acceptPreload( + 42, + 120, + marker({ + ...start, + boundary: 'end', + outcome: 'success', + sourceEpochMs: 119, + }) + ), + true + ); + + assert.ok( + capture + .stop() + .invalidReasons.includes('main-phase-topology-invalid') + ); + }); + + it('accepts the legal success and error main lifecycles', () => { + const success = createXtreamIpcMarkerCapture(options); + success.start(42, 90); + const successStart = xtreamRequestMarker(); + assert.equal(success.acceptPreload(42, 100, successStart), true); + acceptMainBoundary(success, 'xtream.network.total', 'start', 101); + acceptMainPair(success, 'xtream.json.transform', 103, 106); + acceptMainBoundary(success, 'xtream.network.total', 'end', 110, 9); + acceptMainPair(success, 'xtream.response.ready', 111, 112); + assert.equal( + success.acceptPreload( + 42, + 120, + marker({ + ...successStart, + boundary: 'end', + outcome: 'success', + sourceEpochMs: 119, + }) + ), + true + ); + assert.deepEqual(success.stop().invalidReasons, []); + + const error = createXtreamIpcMarkerCapture(options); + error.start(42, 90); + const errorStart = xtreamRequestMarker(); + assert.equal(error.acceptPreload(42, 100, errorStart), true); + acceptMainPair(error, 'xtream.network.total', 101, 110); + assert.equal( + error.acceptPreload( + 42, + 120, + marker({ + ...errorStart, + boundary: 'end', + outcome: 'error', + sourceEpochMs: 119, + }) + ), + true + ); + assert.deepEqual(error.stop().invalidReasons, []); + }); + + it('rejects duplicate, unclosed, or non-monotonic main phase boundaries', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + assert.equal(capture.acceptMainPhase(mainPhase()), true); + assert.equal(capture.acceptMainPhase(mainPhase()), false); + const snapshot = capture.stop(); + assert.ok( + snapshot.invalidReasons.includes('main-phase-duplicate-boundary') + ); + assert.ok(snapshot.invalidReasons.includes('main-phase-unclosed')); + }); + + it('rejects delayed main phases from a completed capture generation', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + capture.stop(); + capture.start(42, 200); + + assert.equal( + capture.acceptMainPhase(mainPhase({ epochMs: 150 })), + false + ); + assert.equal( + capture.acceptMainPhase( + mainPhase({ + boundary: 'end', + durationMs: 10, + epochMs: 160, + }) + ), + false + ); + assert.ok( + capture + .stop() + .invalidReasons.includes('main-phase-before-capture-start') + ); + }); + + it('fails neutrally when a phase or correlation payload is a revoked proxy', () => { + const capture = createXtreamIpcMarkerCapture(options); + capture.start(42, 90); + const revoked = Proxy.revocable({}, {}); + revoked.revoke(); + + assert.doesNotThrow(() => capture.acceptMainPhase(revoked.proxy)); + assert.equal(capture.acceptMainPhase(revoked.proxy), false); + assert.doesNotThrow(() => capture.matchDatabaseCancel(revoked.proxy)); + assert.doesNotThrow(() => capture.matchDatabaseRequest(revoked.proxy)); + }); + + it('subtracts JSON transform from network total and rejects negative acquisition', () => { + assert.equal(deriveXtreamAcquisitionDurationMs(150, 40), 110); + assert.throws( + () => deriveXtreamAcquisitionDurationMs(40, 41), + /xtream-acquisition-duration-invalid/ + ); + assert.throws( + () => deriveXtreamAcquisitionDurationMs(-1, 0), + /xtream-acquisition-duration-invalid/ + ); + }); +}); + +function xtreamRequestMarker() { + return marker({ + action: XtreamCodeActions.GetLiveCategories, + contentType: null, + ipcCallId: 1, + itemCount: null, + method: 'xtreamRequest', + operationId: null, + playlistId: null, + sessionId: 'session-1', + }); +} + +function acceptMainPair( + capture: ReturnType, + phase: string, + startedEpochMs: number, + endedEpochMs: number +): void { + assert.equal( + capture.acceptMainPhase( + mainPhase({ + epochMs: startedEpochMs, + phase, + }) + ), + true + ); + assert.equal( + capture.acceptMainPhase( + mainPhase({ + boundary: 'end', + durationMs: endedEpochMs - startedEpochMs, + epochMs: endedEpochMs, + phase, + }) + ), + true + ); +} + +function acceptMainBoundary( + capture: ReturnType, + phase: string, + boundary: 'end' | 'start', + epochMs: number, + durationMs: number | null = null +): void { + assert.equal( + capture.acceptMainPhase( + mainPhase({ + boundary, + durationMs, + epochMs, + phase, + }) + ), + true + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-settlement.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-main-settlement.spec.ts new file mode 100644 index 000000000..50891055a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-settlement.spec.ts @@ -0,0 +1,285 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import type { XtreamMainCaptureStatus } from './m3u-refresh-main-capture'; +import { isXtreamMainCaptureSettled } from './xtream-main-settlement'; + +describe('Xtream main capture settlement', () => { + for (const [scenarioId, expectedCount] of [ + [XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, 30], + [XTREAM_SCENARIO_ID.REFRESH_LARGE, 51], + [XTREAM_SCENARIO_ID.DELETE_LARGE, 2], + [XTREAM_SCENARIO_ID.CANCEL_IMPORT, 24], + [XTREAM_SCENARIO_ID.BACKGROUND_UI, 54], + ] as const) { + it(`accepts the exact terminal inventory for ${scenarioId}`, () => { + const status = settledStatus(scenarioId); + + assert.equal( + status.operationOutcomes.reduce( + (total, entry) => total + entry.count, + 0 + ), + expectedCount + ); + assert.equal(isXtreamMainCaptureSettled(status, scenarioId), true); + }); + } + + it('reports unsettled while a known request remains pending', () => { + const status = settledStatus(XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE); + const first = status.operationOutcomes[0]; + assert.ok(first); + + assert.equal( + isXtreamMainCaptureSettled( + { + ...status, + activeOperations: [ + { + operation: first.operation, + operationId: 'operation-1', + playlistId: 'playlist-1', + progress: null, + requestId: 'request-1', + }, + ], + operationOutcomes: [ + ...status.operationOutcomes.slice(1), + { + count: 1, + operation: first.operation, + outcome: 'pending', + }, + ...(first.count > 1 + ? [{ ...first, count: first.count - 1 }] + : []), + ], + }, + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ), + false + ); + }); + + it('reports unsettled through the measured refresh route-content tail', () => { + const status = settledStatus(XTREAM_SCENARIO_ID.REFRESH_LARGE); + const content = status.operationOutcomes.find( + ({ operation }) => operation === 'DB_GET_CONTENT' + ); + assert.deepEqual(content, { + count: 9, + operation: 'DB_GET_CONTENT', + outcome: 'success', + }); + + assert.equal( + isXtreamMainCaptureSettled( + { + ...status, + activeOperations: [ + { + operation: 'DB_GET_CONTENT', + operationId: null, + playlistId: 'playlist-1', + progress: null, + requestId: 'route-live-read', + }, + ], + operationOutcomes: [ + ...status.operationOutcomes.filter( + (entry) => entry !== content + ), + { + count: 6, + operation: 'DB_GET_CONTENT', + outcome: 'success', + }, + { + count: 1, + operation: 'DB_GET_CONTENT', + outcome: 'pending', + }, + ], + }, + XTREAM_SCENARIO_ID.REFRESH_LARGE + ), + false + ); + }); + + it('fails closed on unexpected operations, excess counts, workers, and invalid capture evidence', () => { + const status = settledStatus(XTREAM_SCENARIO_ID.REFRESH_LARGE); + const invalid: XtreamMainCaptureStatus[] = [ + { + ...status, + operationOutcomes: [ + ...status.operationOutcomes, + { + count: 1, + operation: 'DB_FOREIGN_OPERATION', + outcome: 'success', + }, + ], + }, + { + ...status, + databaseWorkerCount: 2, + }, + { + ...status, + playlistRefreshWorkerCount: 1, + }, + { + ...status, + invalidReasons: ['database-capture-invalid'], + }, + { + ...status, + lateDatabaseRequestCount: 1, + lateEventCount: 1, + }, + ]; + + for (const candidate of invalid) { + assert.throws( + () => + isXtreamMainCaptureSettled( + candidate, + XTREAM_SCENARIO_ID.REFRESH_LARGE + ), + /xtream-main-settlement-invalid/ + ); + } + }); + + it('requires the cancellation save-content request to fail and every other request to succeed', () => { + const status = settledStatus(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const failed = status.operationOutcomes.find( + ({ operation, outcome }) => + operation === 'DB_SAVE_CONTENT' && outcome === 'error' + ); + assert.deepEqual(failed, { + count: 1, + operation: 'DB_SAVE_CONTENT', + outcome: 'error', + }); + + assert.throws( + () => + isXtreamMainCaptureSettled( + { + ...status, + operationOutcomes: status.operationOutcomes.map( + (entry) => + entry === failed + ? { ...entry, outcome: 'success' } + : entry + ), + }, + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ), + /xtream-main-settlement-invalid/ + ); + }); +}); + +function settledStatus(scenarioId: XtreamScenarioId): XtreamMainCaptureStatus { + const expected = expectedOutcomes(scenarioId); + return { + active: true, + activeOperations: [], + captureGeneration: 2, + captureStartedEpochMs: 1_000, + cutoffPhase: 'active', + databaseWorkerCount: 1, + invalidReasons: [], + lateDatabaseRequestCount: 0, + lateEventCount: 0, + measurementPhase: 'measuring', + measurementStartedEpochMs: 1_001, + operationOutcomes: expected, + playlistRefreshWorkerCount: 0, + }; +} + +function expectedOutcomes( + scenarioId: XtreamScenarioId +): XtreamMainCaptureStatus['operationOutcomes'] { + const inventory = { + [XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE]: [ + ['DB_UPSERT_APP_PLAYLIST', 1], + ['DB_GET_PLAYLIST', 1], + ['DB_GET_ALL_PLAYBACK_POSITIONS', 1], + ['DB_GET_APP_STATE', 6], + ['DB_GET_CATEGORIES', 6], + ['DB_SAVE_CATEGORIES', 3], + ['DB_GET_CONTENT', 6], + ['DB_SAVE_CONTENT', 3], + ['DB_SET_APP_STATE', 3], + ], + [XTREAM_SCENARIO_ID.REFRESH_LARGE]: [ + ['DB_DELETE_XTREAM_CONTENT', 1], + ['DB_GET_ALL_PLAYBACK_POSITIONS', 1], + ['DB_UPDATE_PLAYLIST', 1], + ['DB_GET_APP_PLAYLIST', 1], + ['DB_GET_PLAYLIST', 2], + ['DB_UPSERT_APP_PLAYLIST', 1], + ['DB_GET_APP_STATE', 12], + ['DB_GET_CATEGORIES', 9], + ['DB_SAVE_CATEGORIES', 3], + ['DB_GET_CONTENT', 9], + ['DB_SAVE_CONTENT', 3], + ['DB_SET_APP_STATE', 6], + ['DB_RESTORE_XTREAM_USER_DATA', 1], + ['DB_CLEAR_ALL_PLAYBACK_POSITIONS', 1], + ], + [XTREAM_SCENARIO_ID.DELETE_LARGE]: [['DB_DELETE_PLAYLIST', 2]], + [XTREAM_SCENARIO_ID.CANCEL_IMPORT]: [ + ['DB_UPSERT_APP_PLAYLIST', 1], + ['DB_GET_PLAYLIST', 1], + ['DB_GET_ALL_PLAYBACK_POSITIONS', 1], + ['DB_GET_APP_STATE', 4], + ['DB_GET_CATEGORIES', 6], + ['DB_SAVE_CATEGORIES', 3], + ['DB_GET_CONTENT', 1], + ['DB_SAVE_CONTENT', 1], + ['DB_SET_APP_STATE', 3], + ['DB_CLEAR_XTREAM_IMPORT_CACHE', 3], + ], + [XTREAM_SCENARIO_ID.BACKGROUND_UI]: [ + ['DB_DELETE_XTREAM_CONTENT', 1], + ['DB_GET_ALL_PLAYBACK_POSITIONS', 1], + ['DB_UPDATE_PLAYLIST', 1], + ['DB_GET_APP_PLAYLIST', 1], + ['DB_GET_PLAYLIST', 2], + ['DB_UPSERT_APP_PLAYLIST', 1], + ['DB_GET_APP_STATE', 12], + ['DB_GET_CATEGORIES', 9], + ['DB_SAVE_CATEGORIES', 3], + ['DB_GET_CONTENT', 9], + ['DB_SAVE_CONTENT', 3], + ['DB_SET_APP_STATE', 6], + ['DB_RESTORE_XTREAM_USER_DATA', 1], + ['DB_CLEAR_ALL_PLAYBACK_POSITIONS', 1], + ['DB_GET_RECENTLY_VIEWED', 1], + ['DB_GET_ALL_GLOBAL_FAVORITES', 1], + ['DB_GET_GLOBAL_RECENTLY_ADDED', 1], + ], + } as const; + return inventory[scenarioId] + .map(([operation, count]) => ({ + count, + operation, + outcome: + scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT && + operation === 'DB_SAVE_CONTENT' + ? ('error' as const) + : ('success' as const), + })) + .sort((left, right) => left.operation.localeCompare(right.operation)); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-main-settlement.ts b/apps/electron-backend-e2e/src/performance/xtream-main-settlement.ts new file mode 100644 index 000000000..d0744a87c --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-main-settlement.ts @@ -0,0 +1,114 @@ +import { + expectedXtreamWorkerRequestInventory, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import type { + XtreamMainCaptureOperationOutcomeKind, + XtreamMainCaptureStatus, +} from './m3u-refresh-main-capture'; + +interface ExpectedOutcome { + readonly count: number; + readonly outcome: Exclude; +} + +export function isXtreamMainCaptureSettled( + status: XtreamMainCaptureStatus, + scenarioId: XtreamScenarioId +): boolean { + try { + assertCaptureBoundary(status); + const expected = expectedOutcomes(scenarioId); + const observed = new Map< + string, + { error: number; pending: number; success: number } + >(); + for (const entry of status.operationOutcomes) { + const expectation = expected.get(entry.operation); + if (!expectation) invalid(); + const counts = observed.get(entry.operation) ?? { + error: 0, + pending: 0, + success: 0, + }; + if (counts[entry.outcome] !== 0) invalid(); + counts[entry.outcome] = entry.count; + if ( + counts.error + counts.pending + counts.success > + expectation.count || + (expectation.outcome === 'success' && counts.error > 0) || + (expectation.outcome === 'error' && counts.success > 0) + ) { + invalid(); + } + observed.set(entry.operation, counts); + } + for (const active of status.activeOperations) { + if (!expected.has(active.operation)) invalid(); + } + if ( + status.databaseWorkerCount === 0 || + status.activeOperations.length > 0 + ) { + return false; + } + for (const [operation, expectation] of expected) { + const counts = observed.get(operation); + if ( + !counts || + counts.pending !== 0 || + counts[expectation.outcome] !== expectation.count || + counts.error + counts.success !== expectation.count + ) { + return false; + } + } + return observed.size === expected.size; + } catch (error) { + if ( + error instanceof Error && + error.message === 'xtream-main-settlement-invalid' + ) { + throw error; + } + invalid(); + } +} + +function expectedOutcomes( + scenarioId: XtreamScenarioId +): ReadonlyMap { + const expected = new Map(); + for (const entry of expectedXtreamWorkerRequestInventory(scenarioId)) { + if (expected.has(entry.operation)) invalid(); + expected.set(entry.operation, { + count: entry.count, + outcome: entry.success ? 'success' : 'error', + }); + } + return expected; +} + +function assertCaptureBoundary(status: XtreamMainCaptureStatus): void { + if ( + !status.active || + status.cutoffPhase !== 'active' || + status.measurementPhase !== 'measuring' || + typeof status.measurementStartedEpochMs !== 'number' || + !Number.isFinite(status.measurementStartedEpochMs) || + status.measurementStartedEpochMs < status.captureStartedEpochMs || + status.invalidReasons.length !== 0 || + status.lateDatabaseRequestCount !== 0 || + status.lateEventCount !== 0 || + status.databaseWorkerCount > 1 || + status.playlistRefreshWorkerCount !== 0 || + !Number.isSafeInteger(status.captureGeneration) || + status.captureGeneration <= 0 + ) { + invalid(); + } +} + +function invalid(): never { + throw new Error('xtream-main-settlement-invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-memory-validity.ts b/apps/electron-backend-e2e/src/performance/xtream-memory-validity.ts new file mode 100644 index 000000000..624650851 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-memory-validity.ts @@ -0,0 +1,13 @@ +export function assertXtreamWorkerHeapWithinMainRss( + worker: unknown, + main: unknown +): void { + const workerMetrics = worker as Record; + const mainMetrics = main as Record; + if ( + (workerMetrics['peakHeapUsedBytes'] as number) > + (mainMetrics['peakRssBytes'] as number) + ) { + throw new Error('database-worker heap exceeds Electron main RSS'); + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-own-array-snapshot.ts b/apps/electron-backend-e2e/src/performance/xtream-own-array-snapshot.ts new file mode 100644 index 000000000..2674bf419 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-own-array-snapshot.ts @@ -0,0 +1,119 @@ +import type { + XtreamAttributionPhaseEvent, + XtreamIpcAttributionSpan, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; + +const MAX_CAPTURE_ARRAY_LENGTH = 512; +const EVENT_KEYS = [ + 'boundary', + 'categoryType', + 'contentType', + 'correlationId', + 'durationMs', + 'epochMs', + 'itemCount', + 'phase', + 'providerAction', + 'semanticType', +] as const; +const IPC_SPAN_KEYS = [ + 'boundary', + 'correlationId', + 'endEpochMs', + 'ipcCallId', + 'method', + 'outcome', + 'sourceEpochMs', + 'startEpochMs', +] as const; + +export function snapshotXtreamPhaseInput( + input: XtreamPhaseAttributionInput +): XtreamPhaseAttributionInput { + return Object.freeze({ + backgroundSearchMarkerObserved: input.backgroundSearchMarkerObserved, + events: snapshotArray(input.events, (event) => + snapshotRecord(event, EVENT_KEYS) + ), + ipcSpans: snapshotArray(input.ipcSpans, (span) => + snapshotRecord(span, IPC_SPAN_KEYS) + ), + operationStartEpochMs: input.operationStartEpochMs, + scenarioId: input.scenarioId, + terminalEpochMs: input.terminalEpochMs, + uiPaintEpochMs: input.uiPaintEpochMs, + }); +} + +function snapshotArray( + value: unknown, + snapshotElement: (element: unknown) => T +): readonly T[] { + if (!Array.isArray(value)) invalid(); + const lengthDescriptor = Reflect.getOwnPropertyDescriptor(value, 'length'); + const length = lengthDescriptor?.value; + if ( + !Number.isSafeInteger(length) || + Number(length) < 0 || + Number(length) > MAX_CAPTURE_ARRAY_LENGTH + ) { + invalid(); + } + const expectedKeys = new Set(['length']); + const snapshot: T[] = []; + for (let index = 0; index < Number(length); index += 1) { + const key = String(index); + expectedKeys.add(key); + const descriptor = Reflect.getOwnPropertyDescriptor(value, key); + if ( + !descriptor || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + invalid(); + } + snapshot[index] = snapshotElement(descriptor.value); + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== expectedKeys.size || + ownKeys.some((key) => !expectedKeys.has(key)) + ) { + invalid(); + } + return Object.freeze(snapshot); +} + +function snapshotRecord(value: unknown, keys: readonly string[]): T { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + ![Object.prototype, null].includes(Reflect.getPrototypeOf(value)) + ) { + invalid(); + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== keys.length || + ownKeys.some((key) => typeof key !== 'string' || !keys.includes(key)) + ) { + invalid(); + } + const snapshot: Record = {}; + for (const key of keys) { + const descriptor = Reflect.getOwnPropertyDescriptor(value, key); + if ( + !descriptor || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + invalid(); + } + snapshot[key] = descriptor.value; + } + return Object.freeze(snapshot) as T; +} + +function invalid(): never { + throw new Error('invalid Xtream capture array'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-persistence-integrity.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-persistence-integrity.spec.ts new file mode 100644 index 000000000..7c5a9e190 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-persistence-integrity.spec.ts @@ -0,0 +1,95 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, test } from 'node:test'; + +import { + createXtreamBenchmarkSummary, + persistThenValidateXtreamIteration, +} from './xtream-benchmark-report'; +import { MANIFEST, TOKEN, sha256 } from './xtream-benchmark-report.fixtures'; +import { + clearXtreamProvenanceFixtureDirectories, + smokeReportFixture, +} from './xtream-benchmark-report-provenance.fixtures'; +import { toXtreamRawIteration } from './xtream-exact-schema'; +import type { + XtreamRawIterationResult, + XtreamValidatedIterationResult, +} from './xtream-summary-contract'; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all([ + ...directories + .splice(0) + .map((directory) => + rm(directory, { force: true, recursive: true }) + ), + clearXtreamProvenanceFixtureDirectories(), + ]); +}); + +test('rejects a schedule whose validated raw iterations reuse one persistence path', async () => { + const { diagnosticEvidence, iterations } = await smokeReportFixture(); + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-xtream-shared-raw-path-') + ); + directories.push(directory); + const absolutePath = join(directory, 'raw.json'); + const first = await persistAt( + toXtreamRawIteration(required(iterations, 0)), + absolutePath + ); + const second = await persistAt( + toXtreamRawIteration(required(iterations, 1)), + absolutePath + ); + const reused = [...iterations]; + reused[0] = first; + reused[1] = second; + + assert.equal(first.persistence.pathSha256, second.persistence.pathSha256); + assert.throws( + () => + createXtreamBenchmarkSummary( + { ...MANIFEST, mode: 'smoke' }, + reused, + diagnosticEvidence + ), + /persistence path/ + ); +}); + +async function persistAt( + raw: XtreamRawIterationResult, + absolutePath: string +): Promise { + return persistThenValidateXtreamIteration( + raw, + { + controlToken: TOKEN, + syntheticCredentials: { + password: 'performance', + username: 'performance', + }, + }, + async (serialized) => { + await writeFile(absolutePath, serialized); + return { + absolutePath, + bytes: Buffer.byteLength(serialized), + sha256: sha256(serialized), + }; + } + ); +} + +function required(items: readonly T[], index: number): T { + const item = items[index]; + assert.ok(item); + return item; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-persistence-schedule.ts b/apps/electron-backend-e2e/src/performance/xtream-persistence-schedule.ts new file mode 100644 index 000000000..bbd4dcf4d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-persistence-schedule.ts @@ -0,0 +1,16 @@ +import type { XtreamValidatedIterationResult } from './xtream-summary-contract'; + +export function assertUniqueXtreamPersistencePaths( + iterations: readonly XtreamValidatedIterationResult[] +): void { + const paths = new Set(); + for (const iteration of iterations) { + const path = iteration.persistence.pathSha256; + if (paths.has(path)) { + throw new Error( + 'Xtream benchmark requires a unique raw persistence path per iteration' + ); + } + paths.add(path); + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-attribution-fixture-builder.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution-fixture-builder.ts new file mode 100644 index 000000000..b7e8c907e --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution-fixture-builder.ts @@ -0,0 +1,189 @@ +import type { + XtreamAttributionPhaseEvent, + XtreamIpcAttributionSpan, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import type { XtreamScenarioId } from './xtream-benchmark-contract'; +import { + XTREAM_ATTRIBUTION_PHASE as PHASE, + type XtreamPhaseSemanticType, +} from './xtream-phase-inventory'; +import { + semanticTypeFromXtreamPhaseSource, + xtreamDataSemanticSource, + xtreamProviderSemanticSource, + type XtreamPhaseSemanticSource, +} from './xtream-phase-semantic-source'; +import { + XTREAM_IPC_PRODUCER_NAMESPACE, + xtreamIpcProducerNamespaceForMethod, +} from './xtream-ipc-producer-identity'; + +export interface XtreamFixtureInventoryBuilder { + readonly cursor: number; + add( + phase: string, + id: string, + itemCount: number | null, + semanticType?: XtreamPhaseSemanticType + ): void; + addAt( + phase: string, + id: string, + startEpochMs: number, + durationMs: number, + itemCount: number | null, + semanticType?: XtreamPhaseSemanticType + ): void; + addProvider(id: string, semanticType: XtreamPhaseSemanticType): void; + capture( + scenarioId: XtreamScenarioId, + ipcSpans: readonly XtreamIpcAttributionSpan[], + uiPaintEpochMs?: number + ): XtreamPhaseAttributionInput; +} + +export function createXtreamFixtureInventoryBuilder(): XtreamFixtureInventoryBuilder { + let cursor = 104; + const events: XtreamAttributionPhaseEvent[] = []; + return { + get cursor() { + return cursor; + }, + add(phase, id, itemCount, semanticType) { + events.push( + ...pair( + phase, + id, + cursor, + 1, + itemCount, + xtreamDataSemanticSource(semanticType ?? null) + ) + ); + cursor += 2; + }, + addAt(phase, id, startEpochMs, durationMs, itemCount, semanticType) { + events.push( + ...pair( + phase, + id, + startEpochMs, + durationMs, + itemCount, + xtreamDataSemanticSource(semanticType ?? null) + ) + ); + cursor = Math.max(cursor, startEpochMs + durationMs + 1); + }, + addProvider(id, semanticType) { + const source = xtreamProviderSemanticSource(semanticType); + events.push(...pair(PHASE.NETWORK_TOTAL, id, cursor, 4, 1, source)); + events.push( + ...pair(PHASE.JSON_TRANSFORM, id, cursor + 1, 1, 1, source) + ); + events.push( + ...pair(PHASE.RESPONSE_READY, id, cursor + 5, 1, 1, source) + ); + cursor += 7; + }, + capture(scenarioId, ipcSpans, paint = cursor + 2) { + return { + backgroundSearchMarkerObserved: false, + events, + ipcSpans, + operationStartEpochMs: 100, + scenarioId, + terminalEpochMs: cursor + 5, + uiPaintEpochMs: paint, + }; + }, + }; +} + +export function createXtreamFixtureIpcInventory( + counts: Readonly> +): XtreamIpcAttributionSpan[] { + const spans: XtreamIpcAttributionSpan[] = []; + const nextIpcCallId = new Map( + Object.values(XTREAM_IPC_PRODUCER_NAMESPACE).map((namespace) => [ + namespace, + 100, + ]) + ); + for (const [method, count] of Object.entries(counts)) { + const namespace = xtreamIpcProducerNamespaceForMethod(method); + if (namespace === null) { + throw new Error('invalid fixture IPC producer method'); + } + for (let index = 0; index < Math.abs(count); index += 1) { + const ipcCallId = Number(nextIpcCallId.get(namespace)) + 1; + nextIpcCallId.set(namespace, ipcCallId); + const correlationId = `${method}-${index}`; + const outcome = count < 0 ? 'error' : 'success'; + spans.push( + { + boundary: 'request', + correlationId, + endEpochMs: 101.25, + ipcCallId, + method, + outcome, + sourceEpochMs: 100.5, + startEpochMs: 101, + }, + { + boundary: 'response', + correlationId, + endEpochMs: 101.75, + ipcCallId, + method, + outcome, + sourceEpochMs: 100.5, + startEpochMs: 101.5, + } + ); + } + } + return spans; +} + +export function pair( + phase: string, + correlationId: string, + startEpochMs: number, + durationMs: number, + itemCount: number | null, + source: XtreamPhaseSemanticSource = xtreamDataSemanticSource(null) +): XtreamAttributionPhaseEvent[] { + const semanticType = semanticTypeFromXtreamPhaseSource(source); + if (semanticType === undefined) { + throw new Error('invalid fixture semantic source'); + } + return [ + { + boundary: 'start', + categoryType: source.categoryType, + contentType: source.contentType, + correlationId, + durationMs: null, + epochMs: startEpochMs, + itemCount: null, + phase, + providerAction: source.providerAction, + semanticType, + }, + { + boundary: 'end', + categoryType: source.categoryType, + contentType: source.contentType, + correlationId, + durationMs, + epochMs: startEpochMs + durationMs, + itemCount, + phase, + providerAction: source.providerAction, + semanticType, + }, + ]; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-attribution-topology.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution-topology.spec.ts new file mode 100644 index 000000000..5360a0e64 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution-topology.spec.ts @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + attributeXtreamPhases, + XTREAM_ATTRIBUTION_PHASE as PHASE, +} from './xtream-phase-attribution'; +import { scenarioCapture } from './xtream-phase-attribution.fixtures'; +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; + +describe('Xtream phase attribution topology', () => { + it('rejects impossible store, refresh, cancel-cleanup, and double-delete topology', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const refresh = scenarioCapture(XTREAM_SCENARIO_ID.REFRESH_LARGE); + const cancel = scenarioCapture(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const deletion = scenarioCapture(XTREAM_SCENARIO_ID.DELETE_LARGE); + const phaseStart = ( + capture: typeof initial, + phase: string, + correlationId: string + ) => + Number( + capture.events.find( + (event) => + event.boundary === 'start' && + event.phase === phase && + event.correlationId === correlationId + )?.epochMs + ); + const candidates = [ + { + ...initial, + events: movePair( + initial.events, + PHASE.STORE_IMPORT_TERMINAL, + 'store-terminal', + 102 + ), + }, + { + ...initial, + events: movePair( + initial.events, + PHASE.STORE_PUBLISH_LIVE, + 'store-live', + phaseStart(initial, PHASE.STORE_PUBLISH_VOD, 'store-vod') + + 2 + ), + }, + { + ...refresh, + events: movePair( + refresh.events, + PHASE.STORE_REFRESH_META, + 'refresh-meta', + refresh.terminalEpochMs - 3 + ), + uiPaintEpochMs: refresh.terminalEpochMs - 1, + }, + { + ...refresh, + events: movePair( + refresh.events, + PHASE.STORE_REFRESH_META, + 'refresh-meta', + phaseStart( + refresh, + PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + 'refresh-delete' + ) + ), + }, + { + ...refresh, + events: movePair( + refresh.events, + PHASE.STORE_PUBLISH_CATEGORIES, + 'store-categories-route', + phaseStart( + refresh, + PHASE.STORE_IMPORT_TERMINAL, + 'store-terminal' + ) - 2 + ), + }, + { + ...cancel, + events: movePair( + cancel.events, + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + 'cache-clear-live', + 102 + ), + }, + { + ...cancel, + events: movePair( + cancel.events, + PHASE.CANCEL_SESSION, + 'cancel-session', + phaseStart( + cancel, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + 'content-save-live' + ) - 2 + ), + }, + { + ...deletion, + events: movePair( + deletion.events, + PHASE.STORE_DELETE_ROW, + 'delete-store', + 102 + ), + uiPaintEpochMs: deletion.terminalEpochMs - 1, + }, + ]; + + for (const candidate of candidates) { + assert.throws( + () => attributeXtreamPhases(candidate), + /invalid Xtream phase capture/ + ); + } + }); +}); + +function movePair( + events: ReturnType['events'], + phase: string, + correlationId: string, + startEpochMs: number +) { + return events.map((event) => + event.phase === phase && event.correlationId === correlationId + ? { + ...event, + epochMs: + startEpochMs + + (event.boundary === 'end' ? Number(event.durationMs) : 0), + } + : event + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.fixtures.ts new file mode 100644 index 000000000..70ffc40de --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.fixtures.ts @@ -0,0 +1,351 @@ +import { + XTREAM_ATTRIBUTION_PHASE as PHASE, + type XtreamAttributionPhaseEvent, + type XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import { + createXtreamFixtureInventoryBuilder as createInventoryBuilder, + createXtreamFixtureIpcInventory as ipcInventory, + type XtreamFixtureInventoryBuilder as InventoryBuilder, +} from './xtream-phase-attribution-fixture-builder'; +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { XTREAM_PHASE_SEMANTIC_TYPE as SEMANTIC } from './xtream-phase-inventory'; + +export { pair } from './xtream-phase-attribution-fixture-builder'; + +const CATEGORY_COUNTS = [60, 20, 20] as const; +const CONTENT_COUNTS = [60_000, 20_000, 20_000] as const; +const CATEGORY_SEMANTICS = [ + SEMANTIC.LIVE_CATEGORIES, + SEMANTIC.VOD_CATEGORIES, + SEMANTIC.SERIES_CATEGORIES, +] as const; +const CONTENT_SEMANTICS = [ + SEMANTIC.LIVE_CONTENT, + SEMANTIC.VOD_CONTENT, + SEMANTIC.SERIES_CONTENT, +] as const; + +export function scenarioCapture( + scenarioId: XtreamScenarioId +): XtreamPhaseAttributionInput { + const builder = createInventoryBuilder(); + + if (scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) { + builder.add( + PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + 'delete-request-1', + 100_100 + ); + builder.add( + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + 'delete-request-1', + 100_101 + ); + builder.add(PHASE.STORE_DELETE_ROW, 'delete-store', 1); + const paint = builder.cursor + 2; + builder.add( + PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + 'delete-request-2', + 0 + ); + builder.add( + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + 'delete-request-2', + 1 + ); + return builder.capture( + scenarioId, + ipcInventory({ dbDeletePlaylist: 2 }), + paint + ); + } + + if ( + scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE || + scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI + ) { + builder.add( + PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + 'refresh-delete', + 100_100 + ); + builder.add( + PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + 'refresh-delete', + 100_100 + ); + builder.add(PHASE.STORE_REFRESH_META, 'refresh-meta', 1); + addPlaylistRead(builder, 'refresh-effect'); + addPlaylistUpsert(builder); + builder.addProvider('provider-account', SEMANTIC.ACCOUNT); + addImportFlow(builder); + addRouteHydration(builder); + return builder.capture( + scenarioId, + ipcInventory({ + dbDeleteXtreamContent: 1, + dbGetAppPlaylist: 1, + dbGetCategories: 9, + dbGetContent: 9, + dbRestoreXtreamUserData: 1, + dbSaveCategories: 3, + dbSaveContent: 3, + dbUpsertAppPlaylist: 1, + xtreamRequest: 8, + }) + ); + } + + addPlaylistUpsert(builder); + builder.addProvider('provider-account', SEMANTIC.ACCOUNT); + builder.addProvider('provider-account-route', SEMANTIC.ACCOUNT); + addCategories(builder); + if (scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT) { + builder.add(PHASE.STORE_PUBLISH_CATEGORIES, 'store-categories', 100); + builder.add( + PHASE.SQLITE_CONTENT_READ, + 'content-read-live-pre', + 0, + SEMANTIC.LIVE_CONTENT + ); + builder.addProvider('provider-content-0', SEMANTIC.LIVE_CONTENT); + builder.add( + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + 'content-save-live', + 60, + SEMANTIC.LIVE_CONTENT + ); + builder.add( + PHASE.NORMALIZE_CONTENT, + 'content-save-live', + 60_000, + SEMANTIC.LIVE_CONTENT + ); + const liveWriteStart = builder.cursor; + builder.addAt( + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + 'content-save-live', + liveWriteStart, + 6, + null, + SEMANTIC.LIVE_CONTENT + ); + builder.addAt( + PHASE.CANCEL_SESSION, + 'cancel-session', + liveWriteStart + 2, + 1, + 0 + ); + builder.add( + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + 'cache-clear-live', + 6_060 + ); + builder.add( + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + 'cache-clear-movie', + 20 + ); + builder.add( + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + 'cache-clear-series', + 20 + ); + builder.add(PHASE.STORE_IMPORT_TERMINAL, 'store-terminal', 0); + return builder.capture( + scenarioId, + ipcInventory({ + dbCancelOperation: 1, + dbGetCategories: 6, + dbGetContent: 1, + dbSaveCategories: 3, + dbSaveContent: -1, + dbUpsertAppPlaylist: 1, + xtreamCancelSession: 1, + xtreamRequest: 6, + }) + ); + } + + builder.add(PHASE.STORE_PUBLISH_CATEGORIES, 'store-categories', 100); + addContentAndStores(builder); + return builder.capture( + scenarioId, + ipcInventory({ + dbGetCategories: 6, + dbGetContent: 6, + dbSaveCategories: 3, + dbSaveContent: 3, + dbUpsertAppPlaylist: 1, + xtreamRequest: 8, + }) + ); +} + +function addImportFlow(builder: InventoryBuilder): void { + addCategories(builder); + builder.add(PHASE.STORE_PUBLISH_CATEGORIES, 'store-categories', 100); + addContentAndStores(builder); +} + +function addRouteHydration(builder: InventoryBuilder): void { + builder.addProvider('provider-account-route', SEMANTIC.ACCOUNT); + CATEGORY_COUNTS.forEach((count, index) => { + builder.add( + PHASE.SQLITE_CATEGORIES_READ, + `category-read-${index}-route`, + count, + CATEGORY_SEMANTICS[index] + ); + }); + builder.add(PHASE.STORE_PUBLISH_CATEGORIES, 'store-categories-route', 100); + const storePhases = [ + PHASE.STORE_PUBLISH_LIVE, + PHASE.STORE_PUBLISH_VOD, + PHASE.STORE_PUBLISH_SERIES, + ] as const; + CONTENT_COUNTS.forEach((count, index) => { + builder.add( + PHASE.SQLITE_CONTENT_READ, + `content-read-${index}-route`, + count, + CONTENT_SEMANTICS[index] + ); + builder.add( + storePhases[index], + `store-${CONTENT_SEMANTICS[index]}-route`, + count, + CONTENT_SEMANTICS[index] + ); + }); + builder.add(PHASE.STORE_IMPORT_TERMINAL, 'store-terminal-route', 100_000); +} + +function addPlaylistRead(builder: InventoryBuilder, id: string): void { + const requestId = `playlist-get-${id}`; + builder.add(PHASE.SQLITE_GENERIC_READ, requestId, 1); + builder.add(PHASE.DESERIALIZE_PLAYLIST, requestId, 1); +} + +function addPlaylistUpsert(builder: InventoryBuilder): void { + builder.add(PHASE.SERIALIZE_PLAYLIST, 'playlist-upsert', 1); + builder.add(PHASE.SQLITE_GENERIC_WRITE, 'playlist-upsert', 1); +} + +function addCategories(builder: InventoryBuilder): void { + CATEGORY_COUNTS.forEach((_count, index) => { + builder.add( + PHASE.SQLITE_CATEGORIES_READ, + `category-read-${index}-pre`, + 0, + CATEGORY_SEMANTICS[index] + ); + }); + CATEGORY_COUNTS.forEach((_count, index) => { + builder.addProvider( + `provider-category-${index}`, + CATEGORY_SEMANTICS[index] + ); + }); + CATEGORY_COUNTS.forEach((count, index) => { + builder.add( + PHASE.NORMALIZE_CATEGORIES, + `category-save-${index}`, + count, + CATEGORY_SEMANTICS[index] + ); + builder.add( + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + `category-save-${index}`, + count, + CATEGORY_SEMANTICS[index] + ); + builder.add( + PHASE.SQLITE_CATEGORIES_READ, + `category-read-${index}-post`, + count, + CATEGORY_SEMANTICS[index] + ); + }); +} + +function addContentAndStores(builder: InventoryBuilder): void { + const storePhases = [ + PHASE.STORE_PUBLISH_LIVE, + PHASE.STORE_PUBLISH_VOD, + PHASE.STORE_PUBLISH_SERIES, + ] as const; + const storeIds = ['store-live', 'store-vod', 'store-series'] as const; + CONTENT_COUNTS.forEach((count, index) => { + builder.add( + PHASE.SQLITE_CONTENT_READ, + `content-read-${index}-pre`, + 0, + CONTENT_SEMANTICS[index] + ); + builder.addProvider( + `provider-content-${index}`, + CONTENT_SEMANTICS[index] + ); + builder.add( + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + `content-save-${index}`, + CATEGORY_COUNTS[index], + CONTENT_SEMANTICS[index] + ); + builder.add( + PHASE.NORMALIZE_CONTENT, + `content-save-${index}`, + count, + CONTENT_SEMANTICS[index] + ); + builder.add( + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + `content-save-${index}`, + count, + CONTENT_SEMANTICS[index] + ); + builder.add( + PHASE.SQLITE_CONTENT_READ, + `content-read-${index}-post`, + count, + CONTENT_SEMANTICS[index] + ); + builder.add( + storePhases[index], + storeIds[index], + count, + CONTENT_SEMANTICS[index] + ); + }); + builder.add(PHASE.STORE_IMPORT_TERMINAL, 'store-terminal', 100_000); +} + +export function removePair( + events: readonly XtreamAttributionPhaseEvent[], + phase: string, + correlationId: string +): XtreamAttributionPhaseEvent[] { + return events.filter( + (event) => + event.phase !== phase || event.correlationId !== correlationId + ); +} + +export function lastStoreEnd( + events: readonly XtreamAttributionPhaseEvent[] +): number { + return Math.max( + ...events + .filter( + (event) => + event.boundary === 'end' && event.phase.startsWith('store.') + ) + .map((event) => event.epochMs) + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.spec.ts new file mode 100644 index 000000000..b2e284896 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.spec.ts @@ -0,0 +1,277 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + attributeXtreamPhases, + XTREAM_ATTRIBUTION_PHASE as PHASE, +} from './xtream-phase-attribution'; +import { + lastStoreEnd, + pair, + removePair, + scenarioCapture, +} from './xtream-phase-attribution.fixtures'; +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; + +describe('Xtream phase attribution', () => { + it('requires the exact scenario-specific worker, store, and IPC inventory', () => { + for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) { + const input = scenarioCapture(scenarioId); + const result = attributeXtreamPhases(input); + + assert.equal( + result.structuredCloneAttribution, + 'selected-ipc-proxy-includes-queueing-and-scheduling' + ); + assert.equal( + result.angularRenderingMs, + input.uiPaintEpochMs - lastStoreEnd(input.events) + ); + assert.ok(result.totalMs > 0); + if (scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE) { + assert.equal(result.dataAcquisitionMs, 0); + assert.equal(result.jsonParsingMs, 0); + assert.equal(result.normalizationMs, 0); + } else { + assert.ok(result.dataAcquisitionMs >= 0); + assert.ok(result.jsonParsingMs >= 0); + assert.ok(result.normalizationMs >= 0); + } + if ( + scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE || + scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI + ) { + assert.equal( + input.events.filter( + ({ boundary, phase }) => + boundary === 'end' && + phase === PHASE.SQLITE_CONTENT_READ + ).length, + 9 + ); + for (const phase of [ + PHASE.STORE_PUBLISH_LIVE, + PHASE.STORE_PUBLISH_VOD, + PHASE.STORE_PUBLISH_SERIES, + ]) { + assert.equal( + input.events.filter( + (event) => + event.boundary === 'end' && + event.phase === phase + ).length, + 2 + ); + } + } + } + }); + + it('accepts parallel category completion as a multiset rather than temporal type order', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const originalStarts = new Map( + initial.events + .filter(({ boundary }) => boundary === 'start') + .map(({ correlationId, epochMs, phase }) => [ + `${phase}\0${correlationId}`, + epochMs, + ]) + ); + const swappedRequests = new Map([ + ['category-save-0', 'category-save-1'], + ['category-save-1', 'category-save-0'], + ['category-read-0-post', 'category-read-1-post'], + ['category-read-1-post', 'category-read-0-post'], + ]); + const events = initial.events.map((event) => { + const swappedRequest = swappedRequests.get(event.correlationId); + const start = + swappedRequest === undefined + ? undefined + : originalStarts.get(`${event.phase}\0${swappedRequest}`); + return start === undefined + ? event + : { + ...event, + epochMs: + start + + (event.boundary === 'end' + ? Number(event.durationMs) + : 0), + }; + }); + + assert.doesNotThrow(() => + attributeXtreamPhases({ ...initial, events }) + ); + }); + + it('includes only performance-marked app-playlist reads in IPC inventory', () => { + const expectedReads = new Map([ + [XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, 0], + [XTREAM_SCENARIO_ID.REFRESH_LARGE, 1], + [XTREAM_SCENARIO_ID.DELETE_LARGE, 0], + [XTREAM_SCENARIO_ID.CANCEL_IMPORT, 0], + [XTREAM_SCENARIO_ID.BACKGROUND_UI, 1], + ]); + for (const [scenarioId, expected] of expectedReads) { + const requests = scenarioCapture(scenarioId).ipcSpans.filter( + ({ boundary, method }) => + boundary === 'request' && method === 'dbGetAppPlaylist' + ); + assert.equal(requests.length, expected); + } + }); + + it('rejects missing, extra, or mislabeled phases and IPC pairs', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const missingNormalization = removePair( + initial.events, + PHASE.NORMALIZE_CONTENT, + 'content-save-2' + ); + const missingIpcResponse = initial.ipcSpans.slice(0, -1); + const wrongIpcMethod = initial.ipcSpans.map((span, index) => + index === 0 ? { ...span, method: 'dbSearchContent' } : span + ); + const extraStore = [ + ...initial.events, + ...pair(PHASE.STORE_PUBLISH_LIVE, 'extra-live', 900, 1, 1), + ]; + const numericPhaseCorrelation = initial.events.map((event) => + event.phase === PHASE.NORMALIZE_CONTENT && + event.correlationId === 'content-save-2' + ? { ...event, correlationId: 7 as unknown as string } + : event + ); + const numericIpcCorrelation = initial.ipcSpans.map((span) => + span.correlationId === 'dbGetCategories-0' + ? { ...span, correlationId: 7 as unknown as string } + : span + ); + + for (const candidate of [ + { ...initial, events: missingNormalization }, + { ...initial, ipcSpans: missingIpcResponse }, + { ...initial, ipcSpans: wrongIpcMethod }, + { ...initial, events: extraStore }, + { ...initial, events: numericPhaseCorrelation }, + { ...initial, ipcSpans: numericIpcCorrelation }, + { + ...initial, + uiPaintEpochMs: initial.terminalEpochMs + 1, + }, + { + ...initial, + scenarioId: 'xtream-unknown' as typeof initial.scenarioId, + }, + { + ...initial, + backgroundSearchMarkerObserved: 'false' as unknown as boolean, + }, + ]) { + assert.throws( + () => attributeXtreamPhases(candidate), + /invalid Xtream phase capture/ + ); + } + }); + + it('requires the exact cancel prefix, cleanup, and unsuccessful live write', () => { + const cancel = scenarioCapture(XTREAM_SCENARIO_ID.CANCEL_IMPORT); + const liveWrite = cancel.events.findIndex( + (event) => + event.phase === PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS && + event.boundary === 'end' + ); + const withSuccessfulWrite = cancel.events.map((event, index) => + index === liveWrite ? { ...event, itemCount: 60_000 } : event + ); + const missingCacheClear = removePair( + cancel.events, + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + 'cache-clear-live' + ); + const misorderedCacheClear = cancel.events.map((event) => { + if (event.boundary !== 'end') return event; + if (event.correlationId === 'cache-clear-live') { + return { ...event, itemCount: 20 }; + } + if (event.correlationId === 'cache-clear-movie') { + return { ...event, itemCount: 6_060 }; + } + return event; + }); + + assert.throws( + () => + attributeXtreamPhases({ + ...cancel, + events: withSuccessfulWrite, + }), + /invalid Xtream phase capture/ + ); + assert.throws( + () => + attributeXtreamPhases({ + ...cancel, + events: missingCacheClear, + }), + /invalid Xtream phase capture/ + ); + assert.throws( + () => + attributeXtreamPhases({ + ...cancel, + events: misorderedCacheClear, + }), + /invalid Xtream phase capture/ + ); + }); + + it('keeps first visible store-to-paint separate from later delete settlement', () => { + const deletion = scenarioCapture(XTREAM_SCENARIO_ID.DELETE_LARGE); + + assert.ok(deletion.uiPaintEpochMs < deletion.terminalEpochMs); + const result = attributeXtreamPhases(deletion); + assert.equal( + result.angularRenderingMs, + deletion.uiPaintEpochMs - lastStoreEnd(deletion.events) + ); + assert.ok(result.totalMs > result.angularRenderingMs); + }); + + it('fails closed on unexpected Sources search instrumentation', () => { + const background = scenarioCapture(XTREAM_SCENARIO_ID.BACKGROUND_UI); + + assert.throws( + () => + attributeXtreamPhases({ + ...background, + backgroundSearchMarkerObserved: true, + }), + /unexpected background search marker/ + ); + assert.throws( + () => + attributeXtreamPhases({ + ...background, + events: [ + ...background.events, + ...pair( + PHASE.SQLITE_SEARCH_QUERY, + 'unexpected-search', + 900, + 1, + 1 + ), + ], + }), + /invalid Xtream phase capture/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.ts new file mode 100644 index 000000000..a4570cf9b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-attribution.ts @@ -0,0 +1,376 @@ +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { + isExactRecord, + isFiniteNonNegative, + isPositiveSafeInteger, + sameMaps, + sameValues, +} from './xtream-exact-schema'; +import { pairXtreamPhaseEvents } from './xtream-phase-event-pairing'; +import { + expectedXtreamIpc, + expectedXtreamPhases, + matchesXtreamPhaseItemCounts, + XTREAM_ATTRIBUTION_PHASE, + XTREAM_NORMALIZATION_PHASES, + XTREAM_SQLITE_READ_PHASES, + XTREAM_SQLITE_WRITE_PHASES, + type XtreamInventoryExpectation, + type XtreamPhaseSemanticType, +} from './xtream-phase-inventory'; +import type { + XtreamPhaseCategoryType, + XtreamPhaseContentType, + XtreamPhaseProviderAction, +} from './xtream-phase-semantic-source'; +import { snapshotXtreamPhaseInput } from './xtream-own-array-snapshot'; +import { + assertXtreamPhaseTopology, + type XtreamTopologyPhaseSpan, +} from './xtream-phase-topology'; +import { + hasDistinctMonotonicXtreamIpcProducerIdentities, + xtreamIpcProducerNamespaceForMethod, +} from './xtream-ipc-producer-identity'; + +export { XTREAM_ATTRIBUTION_PHASE } from './xtream-phase-inventory'; + +export const XTREAM_PHASE_NA_REASON = { + COMMIT_INCLUDED_IN_WRITE: 'included-in-sqlite-write-transaction-spans', + NO_USER_DATA: 'no-user-data', + NOT_INVOKED: 'not-invoked-by-scenario', + SCHEMA_INDEXES_PRE_EXIST: + 'schema-indexes-pre-exist; operation-creates-none', + SOURCES_SEARCH_HAS_NO_XTREAM_MARKER: + 'sources-search-does-not-publish-xtream-search-results', +} as const; + +export interface XtreamAttributionPhaseEvent { + readonly boundary: 'end' | 'start'; + readonly categoryType: XtreamPhaseCategoryType | null; + readonly contentType: XtreamPhaseContentType | null; + readonly correlationId: string; + readonly durationMs: number | null; + readonly epochMs: number; + readonly itemCount: number | null; + readonly phase: string; + readonly providerAction: XtreamPhaseProviderAction | null; + readonly semanticType: XtreamPhaseSemanticType | null; +} + +export interface XtreamIpcAttributionSpan { + readonly boundary: 'request' | 'response'; + readonly correlationId: string; + readonly endEpochMs: number; + readonly ipcCallId: number; + readonly method: string; + readonly outcome: 'error' | 'success'; + readonly sourceEpochMs: number; + readonly startEpochMs: number; +} + +export interface XtreamPhaseAttributionInput { + readonly backgroundSearchMarkerObserved: boolean; + readonly events: readonly XtreamAttributionPhaseEvent[]; + readonly ipcSpans: readonly XtreamIpcAttributionSpan[]; + readonly operationStartEpochMs: number; + readonly scenarioId: XtreamScenarioId; + readonly terminalEpochMs: number; + readonly uiPaintEpochMs: number; +} + +export interface XtreamPhaseAttribution { + readonly angularRenderingMs: number; + readonly backgroundSearchMarkerMs: null; + readonly backgroundSearchMarkerReason: + | typeof XTREAM_PHASE_NA_REASON.SOURCES_SEARCH_HAS_NO_XTREAM_MARKER + | null; + readonly dataAcquisitionMs: number; + readonly deserializationMs: number; + readonly indexesMs: null; + readonly indexesReason: typeof XTREAM_PHASE_NA_REASON.SCHEMA_INDEXES_PRE_EXIST; + readonly jsonParsingMs: number; + readonly normalizationMs: number; + readonly restoreUserDataMs: null; + readonly restoreUserDataReason: + | typeof XTREAM_PHASE_NA_REASON.NO_USER_DATA + | typeof XTREAM_PHASE_NA_REASON.NOT_INVOKED; + readonly serializationMs: number; + readonly sqliteReadMs: number; + readonly sqliteWriteMs: number; + readonly storeUpdateMs: number; + readonly structuredCloneAttribution: 'selected-ipc-proxy-includes-queueing-and-scheduling'; + readonly structuredCloneProxyMs: number; + readonly totalMs: number; + readonly transactionCommitMs: null; + readonly transactionCommitReason: typeof XTREAM_PHASE_NA_REASON.COMMIT_INCLUDED_IN_WRITE; +} + +const PHASE = XTREAM_ATTRIBUTION_PHASE; +type PhaseSpan = XtreamTopologyPhaseSpan; + +export function attributeXtreamPhases( + input: XtreamPhaseAttributionInput +): XtreamPhaseAttribution { + try { + input = snapshotXtreamPhaseInput(input); + assertEnvelope(input); + const spans = pairXtreamPhaseEvents(input); + const expected = expectedXtreamPhases(input.scenarioId); + assertInventory(spans, expected, input.scenarioId); + assertMainPhases(spans); + assertXtreamPhaseTopology(input.scenarioId, spans); + const store = spans.filter(({ phase }) => phase.startsWith('store.')); + const storeEnd = Math.max(...store.map(({ endEpochMs }) => endEpochMs)); + if (input.uiPaintEpochMs < storeEnd) invalid(); + if ( + input.scenarioId === 'xtream-background-ui' && + input.backgroundSearchMarkerObserved + ) { + throw new Error('unexpected background search marker'); + } + const network = select(spans, PHASE.NETWORK_TOTAL); + const json = select(spans, PHASE.JSON_TRANSFORM); + const jsonById = new Map( + json.map((span) => [span.correlationId, span]) + ); + const restores = + input.scenarioId === 'xtream-refresh-large' || + input.scenarioId === 'xtream-background-ui'; + return { + totalMs: input.terminalEpochMs - input.operationStartEpochMs, + dataAcquisitionMs: + sum(network) - + sum( + network.map(({ correlationId }) => { + const nested = jsonById.get(correlationId); + if (!nested) invalid(); + return nested; + }) + ), + jsonParsingMs: sum(json), + normalizationMs: sum(filter(spans, XTREAM_NORMALIZATION_PHASES)), + serializationMs: sum(select(spans, PHASE.SERIALIZE_PLAYLIST)), + deserializationMs: sum(select(spans, PHASE.DESERIALIZE_PLAYLIST)), + structuredCloneProxyMs: assertIpcInventory(input), + sqliteReadMs: sum(filter(spans, XTREAM_SQLITE_READ_PHASES)), + sqliteWriteMs: sum(filter(spans, XTREAM_SQLITE_WRITE_PHASES)), + storeUpdateMs: sum(store), + angularRenderingMs: input.uiPaintEpochMs - storeEnd, + indexesMs: null, + indexesReason: XTREAM_PHASE_NA_REASON.SCHEMA_INDEXES_PRE_EXIST, + transactionCommitMs: null, + transactionCommitReason: + XTREAM_PHASE_NA_REASON.COMMIT_INCLUDED_IN_WRITE, + structuredCloneAttribution: + 'selected-ipc-proxy-includes-queueing-and-scheduling', + restoreUserDataMs: null, + restoreUserDataReason: restores + ? XTREAM_PHASE_NA_REASON.NO_USER_DATA + : XTREAM_PHASE_NA_REASON.NOT_INVOKED, + backgroundSearchMarkerMs: null, + backgroundSearchMarkerReason: + input.scenarioId === 'xtream-background-ui' + ? XTREAM_PHASE_NA_REASON.SOURCES_SEARCH_HAS_NO_XTREAM_MARKER + : null, + }; + } catch (error) { + if ( + error instanceof Error && + error.message === 'unexpected background search marker' + ) { + throw error; + } + throw new Error('invalid Xtream phase capture'); + } +} + +function assertInventory( + spans: readonly PhaseSpan[], + expected: ReadonlyMap, + scenarioId: XtreamScenarioId +): void { + if (spans.length !== sumExpectations(expected)) invalid(); + for (const [phase, expectation] of expected) { + const actual = select(spans, phase).sort( + (left, right) => left.startEpochMs - right.startEpochMs + ); + if (actual.length !== expectation.occurrences) invalid(); + if ( + expectation.itemCounts && + !matchesXtreamPhaseItemCounts( + phase, + actual.map(({ itemCount }) => itemCount), + expectation.itemCounts + ) + ) { + invalid(); + } + } + if (spans.some(({ phase }) => !expected.has(phase))) invalid(); + if (scenarioId === 'xtream-cancel-import') { + const counts = select( + spans, + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS + ) + .sort((left, right) => left.startEpochMs - right.startEpochMs) + .map(({ itemCount }) => itemCount); + if ( + !isFiniteNonNegative(counts[0]) || + counts[0] < 6_060 || + counts[0] >= 60_060 || + !sameValues(counts.slice(1), [20, 20]) + ) { + invalid(); + } + } +} + +function assertMainPhases(spans: readonly PhaseSpan[]): void { + const network = select(spans, PHASE.NETWORK_TOTAL); + const json = new Map( + select(spans, PHASE.JSON_TRANSFORM).map((span) => [ + span.correlationId, + span, + ]) + ); + const response = new Map( + select(spans, PHASE.RESPONSE_READY).map((span) => [ + span.correlationId, + span, + ]) + ); + for (const span of network) { + const nested = json.get(span.correlationId); + const ready = response.get(span.correlationId); + if ( + !nested || + !ready || + nested.semanticType !== span.semanticType || + ready.semanticType !== span.semanticType || + nested.startEpochMs < span.startEpochMs || + nested.endEpochMs > span.endEpochMs || + ready.startEpochMs < span.endEpochMs + ) { + invalid(); + } + } +} + +function assertIpcInventory(input: XtreamPhaseAttributionInput): number { + const expected = expectedXtreamIpc(input.scenarioId); + const groups = new Map(); + let total = 0; + for (const span of input.ipcSpans) { + if ( + !isExactRecord(span, [ + 'boundary', + 'correlationId', + 'endEpochMs', + 'ipcCallId', + 'method', + 'outcome', + 'sourceEpochMs', + 'startEpochMs', + ]) || + !['request', 'response'].includes(span.boundary) || + typeof span.correlationId !== 'string' || + span.correlationId.length === 0 || + typeof span.method !== 'string' || + span.method.length === 0 || + !['error', 'success'].includes(span.outcome) || + !isFiniteNonNegative(span.startEpochMs) || + !isFiniteNonNegative(span.endEpochMs) || + !isPositiveSafeInteger(span.ipcCallId) || + !isFiniteNonNegative(span.sourceEpochMs) || + span.sourceEpochMs < input.operationStartEpochMs || + span.sourceEpochMs > span.startEpochMs || + span.startEpochMs < input.operationStartEpochMs || + span.endEpochMs < span.startEpochMs || + span.endEpochMs > input.terminalEpochMs + ) { + invalid(); + } + total += span.endEpochMs - span.startEpochMs; + groups.set(span.correlationId, [ + ...(groups.get(span.correlationId) ?? []), + span, + ]); + } + const actual = new Map(); + const producerCalls: XtreamIpcAttributionSpan[] = []; + for (const spans of groups.values()) { + const request = spans.find(({ boundary }) => boundary === 'request'); + const response = spans.find(({ boundary }) => boundary === 'response'); + if ( + spans.length !== 2 || + !request || + !response || + request.method !== response.method || + request.outcome !== response.outcome || + request.ipcCallId !== response.ipcCallId || + request.sourceEpochMs !== response.sourceEpochMs || + request.endEpochMs > response.startEpochMs + ) { + invalid(); + } + producerCalls.push(request); + const counts = actual.get(request.method) ?? { error: 0, success: 0 }; + counts[request.outcome] += 1; + actual.set(request.method, counts); + } + const producerIdentities = producerCalls.map( + ({ ipcCallId, method, sourceEpochMs }) => { + const namespace = xtreamIpcProducerNamespaceForMethod(method); + if (namespace === null) invalid(); + return { ipcCallId, namespace, sourceEpochMs }; + } + ); + if (!hasDistinctMonotonicXtreamIpcProducerIdentities(producerIdentities)) + invalid(); + if (!sameMaps(actual, expected)) invalid(); + return total; +} + +function assertEnvelope(input: XtreamPhaseAttributionInput): void { + if ( + !isFiniteNonNegative(input.operationStartEpochMs) || + !isFiniteNonNegative(input.terminalEpochMs) || + !isFiniteNonNegative(input.uiPaintEpochMs) || + !Object.values(XTREAM_SCENARIO_ID).includes(input.scenarioId) || + typeof input.backgroundSearchMarkerObserved !== 'boolean' || + input.terminalEpochMs < input.operationStartEpochMs || + input.uiPaintEpochMs < input.operationStartEpochMs || + input.uiPaintEpochMs > input.terminalEpochMs + ) { + invalid(); + } +} + +function filter(spans: readonly PhaseSpan[], phases: ReadonlySet) { + return spans.filter(({ phase }) => phases.has(phase)); +} + +function select(spans: readonly PhaseSpan[], phase: string): PhaseSpan[] { + return spans.filter((span) => span.phase === phase); +} + +function sum(spans: readonly PhaseSpan[]): number { + return spans.reduce((total, span) => total + span.durationMs, 0); +} + +function sumExpectations( + values: ReadonlyMap +) { + return [...values.values()].reduce( + (total, { occurrences }) => total + occurrences, + 0 + ); +} + +function invalid(): never { + throw new Error('invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-event-pairing.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-event-pairing.ts new file mode 100644 index 000000000..32c824803 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-event-pairing.ts @@ -0,0 +1,100 @@ +import { + isExactRecord, + isFiniteNonNegative, + isSafeNonNegativeInteger, +} from './xtream-exact-schema'; +import type { + XtreamAttributionPhaseEvent, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import { fitsPerformanceDurationEnvelope } from './performance-phase-duration-envelope'; +import { XTREAM_ATTRIBUTION_PHASE } from './xtream-phase-inventory'; +import { semanticTypeFromXtreamPhaseSource } from './xtream-phase-semantic-source'; +import type { XtreamTopologyPhaseSpan } from './xtream-phase-topology'; + +const PHASES = new Set(Object.values(XTREAM_ATTRIBUTION_PHASE)); + +export function pairXtreamPhaseEvents( + input: XtreamPhaseAttributionInput +): readonly XtreamTopologyPhaseSpan[] { + const groups = new Map(); + for (const event of input.events) { + assertCanonicalEvent(event); + const key = `${event.phase}\0${event.correlationId}`; + groups.set(key, [...(groups.get(key) ?? []), event]); + } + return [...groups.values()].map((events) => pairGroup(input, events)); +} + +function assertCanonicalEvent(event: XtreamAttributionPhaseEvent): void { + if ( + !isExactRecord(event, [ + 'boundary', + 'categoryType', + 'contentType', + 'correlationId', + 'durationMs', + 'epochMs', + 'itemCount', + 'phase', + 'providerAction', + 'semanticType', + ]) || + !PHASES.has(event.phase) || + !['start', 'end'].includes(event.boundary) || + typeof event.correlationId !== 'string' || + event.correlationId.length === 0 || + semanticTypeFromXtreamPhaseSource(event) !== event.semanticType || + !isFiniteNonNegative(event.epochMs) + ) { + invalid(); + } +} + +function pairGroup( + input: XtreamPhaseAttributionInput, + events: readonly XtreamAttributionPhaseEvent[] +): XtreamTopologyPhaseSpan { + const [start, end] = events; + if ( + events.length !== 2 || + !start || + !end || + start.boundary !== 'start' || + end.boundary !== 'end' || + start.durationMs !== null || + start.itemCount !== null || + start.providerAction !== end.providerAction || + start.categoryType !== end.categoryType || + start.contentType !== end.contentType || + start.semanticType !== end.semanticType || + !isFiniteNonNegative(end.durationMs) || + (end.itemCount !== null && !isSafeNonNegativeInteger(end.itemCount)) || + start.epochMs < input.operationStartEpochMs || + end.epochMs < start.epochMs || + end.epochMs > input.terminalEpochMs || + !fitsPerformanceDurationEnvelope( + end.durationMs, + input.operationStartEpochMs, + input.terminalEpochMs + ) + ) { + invalid(); + } + return { + categoryType: start.categoryType, + contentType: start.contentType, + correlationId: start.correlationId, + durationMs: end.durationMs, + endEpochMs: end.epochMs, + itemCount: end.itemCount, + phase: start.phase, + providerAction: start.providerAction, + semanticType: start.semanticType, + startEpochMs: start.epochMs, + }; +} + +function invalid(): never { + throw new Error('invalid Xtream phase event pairing'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-inventory.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-inventory.ts new file mode 100644 index 000000000..9ba722cbe --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-inventory.ts @@ -0,0 +1,285 @@ +import type { XtreamScenarioId } from './xtream-benchmark-contract'; + +export const XTREAM_ATTRIBUTION_PHASE = { + CANCEL_SESSION: 'xtream.cancel-session', + DESERIALIZE_PLAYLIST: 'deserialize.playlist', + JSON_TRANSFORM: 'xtream.json.transform', + NETWORK_TOTAL: 'xtream.network.total', + NORMALIZE_CATEGORIES: 'normalize.categories', + NORMALIZE_CONTENT: 'normalize.content', + NORMALIZE_SEARCH_RANK: 'normalize.search-rank', + RESPONSE_READY: 'xtream.response.ready', + SERIALIZE_PLAYLIST: 'serialize.playlist', + SQLITE_CATEGORIES_READ: 'sqlite.categories.read', + SQLITE_CATEGORIES_WRITE_TRANSACTIONS: + 'sqlite.categories.write-transactions', + SQLITE_CONTENT_CATEGORY_MAP_READ: 'sqlite.content.category-map-read', + SQLITE_CONTENT_READ: 'sqlite.content.read', + SQLITE_CONTENT_WRITE_TRANSACTIONS: 'sqlite.content.write-transactions', + SQLITE_GENERIC_READ: 'sqlite.read', + SQLITE_GENERIC_WRITE: 'sqlite.write', + SQLITE_PLAYLIST_DELETE_COLLECT_IDS: 'sqlite.playlist-delete.collect-ids', + SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS: + 'sqlite.playlist-delete.write-transactions', + SQLITE_SEARCH_QUERY: 'sqlite.search.query', + SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS: + 'sqlite.xtream-cache-clear.write-transactions', + SQLITE_XTREAM_DELETE_COLLECT_USER_DATA: + 'sqlite.xtream-delete.collect-user-data', + SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS: + 'sqlite.xtream-delete.write-transactions', + STORE_DELETE_ROW: 'store.xtream-delete-row', + STORE_IMPORT_TERMINAL: 'store.xtream-import-terminal', + STORE_PUBLISH_CATEGORIES: 'store.xtream-publish-categories', + STORE_PUBLISH_LIVE: 'store.xtream-publish-live', + STORE_PUBLISH_SERIES: 'store.xtream-publish-series', + STORE_PUBLISH_VOD: 'store.xtream-publish-vod', + STORE_REFRESH_META: 'store.xtream-refresh-meta', + STORE_SEARCH_RESULTS: 'store.xtream-search-results', +} as const; + +export const XTREAM_PHASE_SEMANTIC_TYPE = { + ACCOUNT: 'account', + LIVE_CATEGORIES: 'live-categories', + LIVE_CONTENT: 'live-content', + SERIES_CATEGORIES: 'series-categories', + SERIES_CONTENT: 'series-content', + VOD_CATEGORIES: 'vod-categories', + VOD_CONTENT: 'vod-content', +} as const; + +export type XtreamPhaseSemanticType = + (typeof XTREAM_PHASE_SEMANTIC_TYPE)[keyof typeof XTREAM_PHASE_SEMANTIC_TYPE]; + +export interface XtreamInventoryExpectation { + readonly itemCounts?: readonly (number | null)[]; + readonly occurrences: number; +} + +export interface XtreamIpcOutcomeCounts { + readonly error: number; + readonly success: number; +} + +type AddExpectation = ( + phase: string, + occurrences: number, + itemCounts?: readonly (number | null)[] +) => void; + +const PHASE = XTREAM_ATTRIBUTION_PHASE; + +export const XTREAM_NORMALIZATION_PHASES = new Set([ + PHASE.NORMALIZE_CATEGORIES, + PHASE.NORMALIZE_CONTENT, + PHASE.NORMALIZE_SEARCH_RANK, +]); +export const XTREAM_SQLITE_READ_PHASES = new Set([ + PHASE.SQLITE_CATEGORIES_READ, + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.SQLITE_CONTENT_READ, + PHASE.SQLITE_GENERIC_READ, + PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + PHASE.SQLITE_SEARCH_QUERY, + PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, +]); +export const XTREAM_SQLITE_WRITE_PHASES = new Set([ + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + PHASE.SQLITE_GENERIC_WRITE, + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, +]); + +export function matchesXtreamPhaseItemCounts( + phase: string, + actual: readonly (number | null)[], + expected: readonly (number | null)[] +): boolean { + const categories = new Set([ + PHASE.NORMALIZE_CATEGORIES, + PHASE.SQLITE_CATEGORIES_READ, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + ]); + if (!categories.has(phase)) { + return actual.every((value, index) => value === expected[index]); + } + const ordered = (values: readonly (number | null)[]) => + [...values].sort( + (left, right) => + (left === null ? -1 : left) - (right === null ? -1 : right) + ); + return ( + actual.length === expected.length && + ordered(actual).every( + (value, index) => value === ordered(expected)[index] + ) + ); +} + +export function expectedXtreamPhases( + scenarioId: XtreamScenarioId +): ReadonlyMap { + const result = new Map(); + const add: AddExpectation = (phase, occurrences, itemCounts) => + result.set(phase, { + occurrences, + ...(itemCounts ? { itemCounts } : {}), + }); + const provider = + scenarioId === 'xtream-delete-large' + ? 0 + : scenarioId === 'xtream-cancel-import' + ? 6 + : 8; + for (const phase of [ + PHASE.NETWORK_TOTAL, + PHASE.JSON_TRANSFORM, + PHASE.RESPONSE_READY, + ]) { + add(phase, provider); + } + if (scenarioId === 'xtream-delete-large') { + add(PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, 2, [100_100, 0]); + add(PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, 2, [100_101, 1]); + add(PHASE.STORE_DELETE_ROW, 1, [1]); + return result; + } + add(PHASE.SERIALIZE_PLAYLIST, 1); + add(PHASE.SQLITE_GENERIC_WRITE, 1); + const playlistReads = + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui' + ? 1 + : 0; + add(PHASE.SQLITE_GENERIC_READ, playlistReads); + add(PHASE.DESERIALIZE_PLAYLIST, playlistReads); + const routeHydratesAfterImport = + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui'; + addFullCategories(add, routeHydratesAfterImport); + if (scenarioId === 'xtream-cancel-import') { + add(PHASE.CANCEL_SESSION, 1); + add(PHASE.SQLITE_CONTENT_READ, 1, [0]); + add(PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, 1, [60]); + add(PHASE.NORMALIZE_CONTENT, 1, [60_000]); + add(PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, 1, [null]); + add(PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, 3); + add(PHASE.STORE_PUBLISH_CATEGORIES, 1, [100]); + add(PHASE.STORE_IMPORT_TERMINAL, 1, [0]); + return result; + } + if ( + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui' + ) { + add(PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, 1, [100_100]); + add(PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, 1, [100_100]); + add(PHASE.STORE_REFRESH_META, 1, [1]); + } + addFullContent(add, routeHydratesAfterImport); + add( + PHASE.STORE_PUBLISH_CATEGORIES, + routeHydratesAfterImport ? 2 : 1, + routeHydratesAfterImport ? [100, 100] : [100] + ); + add( + PHASE.STORE_PUBLISH_LIVE, + routeHydratesAfterImport ? 2 : 1, + routeHydratesAfterImport ? [60_000, 60_000] : [60_000] + ); + add( + PHASE.STORE_PUBLISH_VOD, + routeHydratesAfterImport ? 2 : 1, + routeHydratesAfterImport ? [20_000, 20_000] : [20_000] + ); + add( + PHASE.STORE_PUBLISH_SERIES, + routeHydratesAfterImport ? 2 : 1, + routeHydratesAfterImport ? [20_000, 20_000] : [20_000] + ); + add( + PHASE.STORE_IMPORT_TERMINAL, + routeHydratesAfterImport ? 2 : 1, + routeHydratesAfterImport ? [100_000, 100_000] : [100_000] + ); + return result; +} + +export function expectedXtreamIpc( + scenarioId: XtreamScenarioId +): ReadonlyMap { + const counts = + scenarioId === 'xtream-delete-large' + ? { dbDeletePlaylist: 2 } + : scenarioId === 'xtream-cancel-import' + ? { + dbCancelOperation: 1, + dbGetCategories: 6, + dbGetContent: 1, + dbSaveCategories: 3, + dbSaveContent: -1, + dbUpsertAppPlaylist: 1, + xtreamCancelSession: 1, + xtreamRequest: 6, + } + : scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui' + ? { + dbDeleteXtreamContent: 1, + dbGetAppPlaylist: 1, + dbGetCategories: 9, + dbGetContent: 9, + dbRestoreXtreamUserData: 1, + dbSaveCategories: 3, + dbSaveContent: 3, + dbUpsertAppPlaylist: 1, + xtreamRequest: 8, + } + : { + dbGetCategories: 6, + dbGetContent: 6, + dbSaveCategories: 3, + dbSaveContent: 3, + dbUpsertAppPlaylist: 1, + xtreamRequest: 8, + }; + return new Map( + Object.entries(counts).map(([method, count]) => [ + method, + { error: count < 0 ? -count : 0, success: Math.max(0, count) }, + ]) + ); +} + +function addFullCategories( + add: AddExpectation, + routeHydratesAfterImport: boolean +): void { + add( + PHASE.SQLITE_CATEGORIES_READ, + routeHydratesAfterImport ? 9 : 6, + routeHydratesAfterImport + ? [0, 0, 0, 60, 20, 20, 60, 20, 20] + : [0, 0, 0, 60, 20, 20] + ); + add(PHASE.NORMALIZE_CATEGORIES, 3, [60, 20, 20]); + add(PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, 3, [60, 20, 20]); +} + +function addFullContent( + add: AddExpectation, + routeHydratesAfterImport: boolean +): void { + add( + PHASE.SQLITE_CONTENT_READ, + routeHydratesAfterImport ? 9 : 6, + routeHydratesAfterImport + ? [0, 60_000, 0, 20_000, 0, 20_000, 60_000, 20_000, 20_000] + : [0, 60_000, 0, 20_000, 0, 20_000] + ); + add(PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, 3, [60, 20, 20]); + add(PHASE.NORMALIZE_CONTENT, 3, [60_000, 20_000, 20_000]); + add(PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, 3, [60_000, 20_000, 20_000]); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-semantic-source.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-semantic-source.ts new file mode 100644 index 000000000..23ce5285e --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-semantic-source.ts @@ -0,0 +1,109 @@ +import { XtreamCodeActions } from '@iptvnator/shared/interfaces'; + +import { + XTREAM_PHASE_SEMANTIC_TYPE as SEMANTIC, + type XtreamPhaseSemanticType, +} from './xtream-phase-inventory'; + +export type XtreamPhaseCategoryType = 'live' | 'movies' | 'series'; +export type XtreamPhaseContentType = 'live' | 'movie' | 'series'; +export type XtreamPhaseProviderAction = + | XtreamCodeActions.GetAccountInfo + | XtreamCodeActions.GetLiveCategories + | XtreamCodeActions.GetLiveStreams + | XtreamCodeActions.GetSeries + | XtreamCodeActions.GetSeriesCategories + | XtreamCodeActions.GetVodCategories + | XtreamCodeActions.GetVodStreams; + +export interface XtreamPhaseSemanticSource { + readonly categoryType: XtreamPhaseCategoryType | null; + readonly contentType: XtreamPhaseContentType | null; + readonly providerAction: XtreamPhaseProviderAction | null; +} + +const ACTION_SEMANTICS = new Map([ + [XtreamCodeActions.GetAccountInfo, SEMANTIC.ACCOUNT], + [XtreamCodeActions.GetLiveCategories, SEMANTIC.LIVE_CATEGORIES], + [XtreamCodeActions.GetVodCategories, SEMANTIC.VOD_CATEGORIES], + [XtreamCodeActions.GetSeriesCategories, SEMANTIC.SERIES_CATEGORIES], + [XtreamCodeActions.GetLiveStreams, SEMANTIC.LIVE_CONTENT], + [XtreamCodeActions.GetVodStreams, SEMANTIC.VOD_CONTENT], + [XtreamCodeActions.GetSeries, SEMANTIC.SERIES_CONTENT], +]); +const CATEGORY_SEMANTICS = new Map([ + ['live', SEMANTIC.LIVE_CATEGORIES], + ['movies', SEMANTIC.VOD_CATEGORIES], + ['series', SEMANTIC.SERIES_CATEGORIES], +]); +const CONTENT_SEMANTICS = new Map([ + ['live', SEMANTIC.LIVE_CONTENT], + ['movie', SEMANTIC.VOD_CONTENT], + ['series', SEMANTIC.SERIES_CONTENT], +]); + +export function semanticTypeFromXtreamPhaseSource( + source: Readonly> +): XtreamPhaseSemanticType | null | undefined { + const populated = [ + source.providerAction, + source.categoryType, + source.contentType, + ].filter((value) => value !== null); + if (populated.length === 0) return null; + if (populated.length !== 1) return undefined; + if (source.providerAction !== null) { + return typeof source.providerAction === 'string' + ? ACTION_SEMANTICS.get(source.providerAction) + : undefined; + } + if (source.categoryType !== null) { + return typeof source.categoryType === 'string' + ? CATEGORY_SEMANTICS.get(source.categoryType) + : undefined; + } + return typeof source.contentType === 'string' + ? CONTENT_SEMANTICS.get(source.contentType) + : undefined; +} + +export function xtreamDataSemanticSource( + semanticType: XtreamPhaseSemanticType | null +): XtreamPhaseSemanticSource { + switch (semanticType) { + case SEMANTIC.LIVE_CATEGORIES: + return source(null, 'live', null); + case SEMANTIC.VOD_CATEGORIES: + return source(null, 'movies', null); + case SEMANTIC.SERIES_CATEGORIES: + return source(null, 'series', null); + case SEMANTIC.LIVE_CONTENT: + return source(null, null, 'live'); + case SEMANTIC.VOD_CONTENT: + return source(null, null, 'movie'); + case SEMANTIC.SERIES_CONTENT: + return source(null, null, 'series'); + case SEMANTIC.ACCOUNT: + throw new Error('account has no database semantic source'); + case null: + return source(null, null, null); + } +} + +export function xtreamProviderSemanticSource( + semanticType: XtreamPhaseSemanticType +): XtreamPhaseSemanticSource { + const action = [...ACTION_SEMANTICS].find( + ([, mapped]) => mapped === semanticType + )?.[0]; + if (!action) throw new Error('invalid provider semantic type'); + return source(action as XtreamPhaseProviderAction, null, null); +} + +function source( + providerAction: XtreamPhaseProviderAction | null, + categoryType: XtreamPhaseCategoryType | null, + contentType: XtreamPhaseContentType | null +): XtreamPhaseSemanticSource { + return Object.freeze({ categoryType, contentType, providerAction }); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-topology.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-topology.fixtures.ts new file mode 100644 index 000000000..d53e219af --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-topology.fixtures.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; + +import { + type XtreamAttributionPhaseEvent, + XTREAM_ATTRIBUTION_PHASE as PHASE, +} from './xtream-phase-attribution'; +import { + XTREAM_PHASE_SEMANTIC_TYPE as SEMANTIC, + type XtreamPhaseSemanticType, +} from './xtream-phase-inventory'; +import { xtreamDataSemanticSource } from './xtream-phase-semantic-source'; + +export function swapProviderTimelines( + events: readonly XtreamAttributionPhaseEvent[], + leftId: string, + rightId: string +): XtreamAttributionPhaseEvent[] { + const epochs = new Map( + events + .filter( + ({ correlationId }) => + correlationId === leftId || correlationId === rightId + ) + .map(({ boundary, correlationId, epochMs, phase }) => [ + `${correlationId}\0${phase}\0${boundary}`, + epochMs, + ]) + ); + return events.map((event) => { + if (event.correlationId !== leftId && event.correlationId !== rightId) { + return event; + } + const sourceId = event.correlationId === leftId ? rightId : leftId; + const epochMs = epochs.get( + `${sourceId}\0${event.phase}\0${event.boundary}` + ); + assert.notEqual(epochMs, undefined); + return { ...event, epochMs: Number(epochMs) }; + }); +} + +export function phaseEpoch( + events: readonly XtreamAttributionPhaseEvent[], + phase: string, + boundary: 'end' | 'start' +): number { + const event = events.find( + (candidate) => + candidate.phase === phase && candidate.boundary === boundary + ); + assert.ok(event); + return event.epochMs; +} + +export function relabelInterleavedCategoryRequests( + events: readonly XtreamAttributionPhaseEvent[] +): XtreamAttributionPhaseEvent[] { + const layout = new Map< + string, + { semanticType: XtreamPhaseSemanticType; startEpochMs: number } + >([ + [ + `category-save-1\0${PHASE.NORMALIZE_CATEGORIES}`, + { semanticType: SEMANTIC.VOD_CATEGORIES, startEpochMs: 148 }, + ], + [ + `category-save-2\0${PHASE.NORMALIZE_CATEGORIES}`, + { semanticType: SEMANTIC.SERIES_CATEGORIES, startEpochMs: 150 }, + ], + [ + `category-save-1\0${PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS}`, + { semanticType: SEMANTIC.SERIES_CATEGORIES, startEpochMs: 152 }, + ], + [ + `category-save-2\0${PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS}`, + { semanticType: SEMANTIC.VOD_CATEGORIES, startEpochMs: 154 }, + ], + [ + `category-read-2-post\0${PHASE.SQLITE_CATEGORIES_READ}`, + { semanticType: SEMANTIC.SERIES_CATEGORIES, startEpochMs: 156 }, + ], + [ + `category-read-1-post\0${PHASE.SQLITE_CATEGORIES_READ}`, + { semanticType: SEMANTIC.VOD_CATEGORIES, startEpochMs: 158 }, + ], + ]); + return events.map((event) => { + const replacement = layout.get( + `${event.correlationId}\0${event.phase}` + ); + if (!replacement) return event; + const source = xtreamDataSemanticSource(replacement.semanticType); + return { + ...event, + ...source, + epochMs: + replacement.startEpochMs + + (event.boundary === 'end' ? Number(event.durationMs) : 0), + semanticType: replacement.semanticType, + }; + }); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-topology.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-topology.spec.ts new file mode 100644 index 000000000..71fa2949e --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-topology.spec.ts @@ -0,0 +1,341 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { XtreamCodeActions } from '@iptvnator/shared/interfaces'; + +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; +import { + attributeXtreamPhases, + XTREAM_ATTRIBUTION_PHASE as PHASE, +} from './xtream-phase-attribution'; +import { scenarioCapture } from './xtream-phase-attribution.fixtures'; +import { + phaseEpoch, + relabelInterleavedCategoryRequests, + swapProviderTimelines, +} from './xtream-phase-topology.fixtures'; +import type { XtreamPhaseProviderAction } from './xtream-phase-semantic-source'; + +describe('Xtream request-level phase topology', () => { + it('never consumes caller-owned event or IPC iterators', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const invoked: string[] = []; + const events: (typeof initial.events)[number][] = []; + const ipcSpans: (typeof initial.ipcSpans)[number][] = []; + Object.defineProperty(events, Symbol.iterator, { + value() { + invoked.push('events'); + return initial.events[Symbol.iterator](); + }, + }); + Object.defineProperty(ipcSpans, Symbol.iterator, { + value() { + invoked.push('ipcSpans'); + return initial.ipcSpans[Symbol.iterator](); + }, + }); + + assert.throws( + () => attributeXtreamPhases({ ...initial, events, ipcSpans }), + /invalid Xtream phase capture/ + ); + assert.deepEqual(invoked, []); + }); + + it('rejects own phase-capture array keys beyond dense indices and length', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const events = [...initial.events]; + const ipcSpans = [...initial.ipcSpans]; + Object.defineProperty(events, 'map', { + value: Array.prototype.map.bind(events), + }); + Object.defineProperty(ipcSpans, Symbol('extra'), { value: true }); + + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + assert.throws( + () => attributeXtreamPhases({ ...initial, ipcSpans }), + /invalid Xtream phase capture/ + ); + }); + + it('rejects accessor-backed event and IPC records without invoking them', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const invoked: string[] = []; + const event = { ...initial.events[0] }; + const ipcSpan = { ...initial.ipcSpans[0] }; + Object.defineProperty(event, 'phase', { + enumerable: true, + get() { + invoked.push('event.phase'); + return initial.events[0]?.phase; + }, + }); + Object.defineProperty(ipcSpan, 'method', { + enumerable: true, + get() { + invoked.push('ipcSpan.method'); + return initial.ipcSpans[0]?.method; + }, + }); + + assert.throws( + () => + attributeXtreamPhases({ + ...initial, + events: [event, ...initial.events.slice(1)], + }), + /invalid Xtream phase capture/ + ); + assert.throws( + () => + attributeXtreamPhases({ + ...initial, + ipcSpans: [ipcSpan, ...initial.ipcSpans.slice(1)], + }), + /invalid Xtream phase capture/ + ); + assert.deepEqual(invoked, []); + }); + + it('requires category phases to preserve request correlation and order', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const write = initial.events.find( + ({ boundary, phase }) => + boundary === 'start' && + phase === PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS + ); + assert.ok(write); + const writeBeforeNormalize = initial.events.map((event) => + event.phase === PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS && + event.correlationId === write.correlationId + ? { + ...event, + epochMs: + initial.operationStartEpochMs + + (event.boundary === 'end' + ? Number(event.durationMs) + : 0), + } + : event + ); + const mismatchedRequest = initial.events.map((event) => + event.phase === PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS && + event.correlationId === write.correlationId + ? { ...event, correlationId: 'mismatched-category-request' } + : event + ); + + for (const events of [writeBeforeNormalize, mismatchedRequest]) { + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + } + }); + + it('rejects one worker request identity reused by different operations', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const chainedReuse = initial.events.map((event) => + event.correlationId === 'content-save-0' && + new Set([ + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.NORMALIZE_CONTENT, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + ]).has(event.phase) + ? { ...event, correlationId: 'category-save-0' } + : event + ); + const standaloneReuse = initial.events.map((event) => + event.correlationId === 'content-read-0-pre' + ? { ...event, correlationId: 'category-read-0-pre' } + : event + ); + + for (const events of [chainedReuse, standaloneReuse]) { + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + } + }); + + it('rejects equal-count VOD and series category phases relabeled across worker requests', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const events = relabelInterleavedCategoryRequests(initial.events); + + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + }); + + it('rejects category and content worker requests split across source domains', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + for (const [correlationId, phase, providerAction] of [ + [ + 'category-save-0', + PHASE.NORMALIZE_CATEGORIES, + XtreamCodeActions.GetLiveCategories, + ], + [ + 'content-save-1', + PHASE.NORMALIZE_CONTENT, + XtreamCodeActions.GetVodStreams, + ], + ] as const) { + const events = initial.events.map((event) => + event.correlationId === correlationId && event.phase === phase + ? { + ...event, + categoryType: null, + contentType: null, + providerAction: + providerAction as XtreamPhaseProviderAction, + } + : event + ); + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + } + }); + + it('rejects one IPC identity reused by different methods', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const ipcSpans = initial.ipcSpans.map((span) => + span.correlationId === 'dbGetCategories-0' + ? { ...span, correlationId: 'dbUpsertAppPlaylist-0' } + : span + ); + + assert.throws( + () => attributeXtreamPhases({ ...initial, ipcSpans }), + /invalid Xtream phase capture/ + ); + }); + + it('rejects a provider response that becomes ready after its consumer DB and store stages', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const liveStoreEnd = phaseEpoch( + initial.events, + PHASE.STORE_PUBLISH_LIVE, + 'end' + ); + const lateStart = liveStoreEnd + 1; + const events = initial.events.map((event) => { + if (event.correlationId !== 'provider-content-0') return event; + const phaseOffset = + event.phase === PHASE.NETWORK_TOTAL + ? 0 + : event.phase === PHASE.JSON_TRANSFORM + ? 1 + : 5; + const duration = + event.phase === PHASE.NETWORK_TOTAL + ? 4 + : event.phase === PHASE.JSON_TRANSFORM + ? 1 + : 1; + return { + ...event, + epochMs: + lateStart + + phaseOffset + + (event.boundary === 'end' ? duration : 0), + }; + }); + + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + }); + + it('rejects swapping the account provider lifecycle with a category lifecycle', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const events = swapProviderTimelines( + initial.events, + 'provider-account', + 'provider-category-0' + ); + + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + }); + + it('keeps parallel category completion order independent of category type', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const events = swapProviderTimelines( + initial.events, + 'provider-category-0', + 'provider-category-1' + ); + + assert.doesNotThrow(() => + attributeXtreamPhases({ ...initial, events }) + ); + }); + + it('rejects swapping sequential live and VOD provider lifecycles', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const events = swapProviderTimelines( + initial.events, + 'provider-content-0', + 'provider-content-1' + ); + + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + }); + + it('derives provider semantics from the persisted typed action source', () => { + const initial = scenarioCapture( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ); + const events = initial.events.map((event) => + event.correlationId === 'provider-content-0' + ? { + ...event, + providerAction: + XtreamCodeActions.GetVodStreams as XtreamPhaseProviderAction, + } + : event + ); + + assert.throws( + () => attributeXtreamPhases({ ...initial, events }), + /invalid Xtream phase capture/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-phase-topology.ts b/apps/electron-backend-e2e/src/performance/xtream-phase-topology.ts new file mode 100644 index 000000000..6515e3e2d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-phase-topology.ts @@ -0,0 +1,368 @@ +import type { XtreamScenarioId } from './xtream-benchmark-contract'; +import { + XTREAM_ATTRIBUTION_PHASE as PHASE, + type XtreamPhaseSemanticType, +} from './xtream-phase-inventory'; +import type { + XtreamPhaseCategoryType, + XtreamPhaseContentType, + XtreamPhaseProviderAction, +} from './xtream-phase-semantic-source'; +import { assertXtreamProviderCausality } from './xtream-provider-phase-topology'; +import { assertXtreamRefreshRouteHydration } from './xtream-refresh-route-phase-topology'; +import { assertXtreamWorkerCorrelationOwnership } from './xtream-worker-phase-topology'; + +export interface XtreamTopologyPhaseSpan { + readonly categoryType: XtreamPhaseCategoryType | null; + readonly contentType: XtreamPhaseContentType | null; + readonly correlationId: string; + readonly durationMs: number; + readonly endEpochMs: number; + readonly itemCount: number | null; + readonly phase: string; + readonly providerAction: XtreamPhaseProviderAction | null; + readonly semanticType: XtreamPhaseSemanticType | null; + readonly startEpochMs: number; +} + +const CATEGORY_PHASES = new Set([ + PHASE.NORMALIZE_CATEGORIES, + PHASE.SQLITE_CATEGORIES_READ, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, +]); + +export function assertXtreamPhaseTopology( + scenarioId: XtreamScenarioId, + spans: readonly XtreamTopologyPhaseSpan[] +): void { + assertWorkerRequestChains(scenarioId, spans); + assertCategoryCorrelation(scenarioId, spans); + assertXtreamProviderCausality(scenarioId, spans); + if (scenarioId === 'xtream-delete-large') { + assertDeleteTopology(spans); + return; + } + if ( + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui' + ) { + assertRefreshPrelude(spans); + } + if (scenarioId === 'xtream-cancel-import') { + assertCancelTopology(spans); + return; + } + assertFullImportTopology(scenarioId, spans); +} + +function assertWorkerRequestChains( + scenarioId: XtreamScenarioId, + spans: readonly XtreamTopologyPhaseSpan[] +): void { + assertXtreamWorkerCorrelationOwnership(spans); + assertCorrelatedChains(spans, [ + PHASE.SERIALIZE_PLAYLIST, + PHASE.SQLITE_GENERIC_WRITE, + ]); + assertCorrelatedChains(spans, [ + PHASE.SQLITE_GENERIC_READ, + PHASE.DESERIALIZE_PLAYLIST, + ]); + assertCorrelatedChains( + spans, + [ + PHASE.NORMALIZE_CATEGORIES, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + ], + [[0, 1]] + ); + assertCorrelatedChains( + spans, + [ + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.NORMALIZE_CONTENT, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + ], + scenarioId === 'xtream-cancel-import' ? [] : [[1, 2]] + ); + assertCorrelatedChains(spans, [ + PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + ]); + assertCorrelatedChains(spans, [ + PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + ]); +} + +function assertCorrelatedChains( + spans: readonly XtreamTopologyPhaseSpan[], + phases: readonly string[], + equalItemCountIndexes: readonly (readonly [number, number])[] = [] +): void { + const phaseSet = new Set(phases); + const selected = spans.filter(({ phase }) => phaseSet.has(phase)); + if (selected.length === 0) return; + const groups = new Map(); + for (const span of selected) { + groups.set(span.correlationId, [ + ...(groups.get(span.correlationId) ?? []), + span, + ]); + } + for (const group of groups.values()) { + const chain = phases.map((phase) => { + const matches = group.filter((span) => span.phase === phase); + const match = matches[0]; + if (matches.length !== 1 || !match) invalid(); + return match; + }); + if (chain.length !== group.length) invalid(); + ordered(...chain); + for (const [leftIndex, rightIndex] of equalItemCountIndexes) { + const left = chain[leftIndex]; + const right = chain[rightIndex]; + if (!left || !right || left.itemCount !== right.itemCount) + invalid(); + } + } +} + +function assertCategoryCorrelation( + scenarioId: XtreamScenarioId, + spans: readonly XtreamTopologyPhaseSpan[] +): void { + if (select(spans, PHASE.NORMALIZE_CATEGORIES).length === 0) return; + const completedReads = select(spans, PHASE.SQLITE_CATEGORIES_READ) + .map(({ itemCount }) => itemCount) + .filter((itemCount) => itemCount !== 0); + const normalized = select(spans, PHASE.NORMALIZE_CATEGORIES).map( + ({ itemCount }) => itemCount + ); + const written = select( + spans, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS + ).map(({ itemCount }) => itemCount); + const expectedCompletedReads = + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui' + ? [...normalized, ...normalized] + : normalized; + if ( + !sameCountMultiset(completedReads, expectedCompletedReads) || + !sameCountMultiset(normalized, written) + ) { + invalid(); + } +} + +function assertRefreshPrelude(spans: readonly XtreamTopologyPhaseSpan[]): void { + const collect = one(spans, PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA); + const write = one(spans, PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS); + const refreshMeta = one(spans, PHASE.STORE_REFRESH_META); + ordered(collect, write, refreshMeta); + const importPrelude = spans.filter( + ({ phase }) => + phase === PHASE.NETWORK_TOTAL || + phase === PHASE.JSON_TRANSFORM || + phase === PHASE.RESPONSE_READY || + phase === PHASE.SERIALIZE_PLAYLIST || + phase === PHASE.SQLITE_GENERIC_READ || + phase === PHASE.SQLITE_GENERIC_WRITE || + phase === PHASE.DESERIALIZE_PLAYLIST || + CATEGORY_PHASES.has(phase) + ); + if ( + importPrelude.length === 0 || + refreshMeta.endEpochMs > + Math.min(...importPrelude.map(({ startEpochMs }) => startEpochMs)) + ) { + invalid(); + } +} + +function assertFullImportTopology( + scenarioId: XtreamScenarioId, + spans: readonly XtreamTopologyPhaseSpan[] +): void { + const routeHydratesAfterImport = + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui'; + const categoryStores = select(spans, PHASE.STORE_PUBLISH_CATEGORIES); + if (categoryStores.length !== (routeHydratesAfterImport ? 2 : 1)) { + invalid(); + } + const categoryStore = required(categoryStores, 0); + assertCategoriesBeforeStore(spans, categoryStore); + const storeGroups = [ + select(spans, PHASE.STORE_PUBLISH_LIVE), + select(spans, PHASE.STORE_PUBLISH_VOD), + select(spans, PHASE.STORE_PUBLISH_SERIES), + ]; + const expectedStoreCount = routeHydratesAfterImport ? 2 : 1; + if (storeGroups.some(({ length }) => length !== expectedStoreCount)) { + invalid(); + } + const stores = storeGroups.map((group) => required(group, 0)); + const terminals = select(spans, PHASE.STORE_IMPORT_TERMINAL); + if (terminals.length !== expectedStoreCount) invalid(); + const terminal = required(terminals, 0); + const reads = select(spans, PHASE.SQLITE_CONTENT_READ); + const maps = select(spans, PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ); + const normalizations = select(spans, PHASE.NORMALIZE_CONTENT); + const writes = select(spans, PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS); + if ( + reads.length !== (routeHydratesAfterImport ? 9 : 6) || + maps.length !== 3 || + normalizations.length !== 3 || + writes.length !== 3 + ) { + invalid(); + } + ordered(categoryStore, reads[0]); + for (let index = 0; index < 3; index += 1) { + const preRead = reads[index * 2]; + const postRead = reads[index * 2 + 1]; + ordered( + preRead, + maps[index], + normalizations[index], + writes[index], + postRead, + stores[index] + ); + if (index < 2) ordered(stores[index], reads[(index + 1) * 2]); + } + ordered(stores[2], terminal); + if (routeHydratesAfterImport) { + assertXtreamRefreshRouteHydration( + spans, + terminal, + required(categoryStores, 1), + reads.slice(6), + storeGroups.map((group) => required(group, 1)), + required(terminals, 1) + ); + } else { + assertTerminalLast(spans, terminal); + } +} + +function assertCancelTopology(spans: readonly XtreamTopologyPhaseSpan[]): void { + const categoryStore = one(spans, PHASE.STORE_PUBLISH_CATEGORIES); + assertCategoriesBeforeStore(spans, categoryStore); + const read = one(spans, PHASE.SQLITE_CONTENT_READ); + const map = one(spans, PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ); + const normalization = one(spans, PHASE.NORMALIZE_CONTENT); + const write = one(spans, PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS); + const cancel = one(spans, PHASE.CANCEL_SESSION); + const cleanup = select( + spans, + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS + ); + const terminal = one(spans, PHASE.STORE_IMPORT_TERMINAL); + if (cleanup.length !== 3) invalid(); + ordered(categoryStore, read, map, normalization, write); + if ( + cancel.startEpochMs >= write.endEpochMs || + cancel.endEpochMs <= write.startEpochMs + ) { + invalid(); + } + if ( + write.endEpochMs > cleanup[0].startEpochMs || + cancel.endEpochMs > cleanup[0].startEpochMs + ) { + invalid(); + } + ordered(cleanup[0], cleanup[1], cleanup[2], terminal); + assertTerminalLast(spans, terminal); +} + +function assertDeleteTopology(spans: readonly XtreamTopologyPhaseSpan[]): void { + const collects = select(spans, PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS); + const writes = select( + spans, + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS + ); + const store = one(spans, PHASE.STORE_DELETE_ROW); + if (collects.length !== 2 || writes.length !== 2) invalid(); + ordered(collects[0], writes[0], store, collects[1], writes[1]); +} + +function assertCategoriesBeforeStore( + spans: readonly XtreamTopologyPhaseSpan[], + store: XtreamTopologyPhaseSpan +): void { + const categories = spans.filter( + ({ endEpochMs, phase }) => + CATEGORY_PHASES.has(phase) && endEpochMs <= store.startEpochMs + ); + if ( + categories.length !== 12 || + Math.max(...categories.map(({ endEpochMs }) => endEpochMs)) > + store.startEpochMs + ) { + invalid(); + } +} + +function assertTerminalLast( + spans: readonly XtreamTopologyPhaseSpan[], + terminal: XtreamTopologyPhaseSpan +): void { + if ( + spans.some( + (span) => + span !== terminal && span.endEpochMs > terminal.startEpochMs + ) + ) { + invalid(); + } +} + +function one( + spans: readonly XtreamTopologyPhaseSpan[], + phase: string +): XtreamTopologyPhaseSpan { + const matches = spans.filter((span) => span.phase === phase); + const match = matches[0]; + if (matches.length !== 1 || !match) invalid(); + return match; +} + +function required(values: readonly T[], index: number): T { + const value = values[index]; + if (!value) invalid(); + return value; +} + +function ordered(...spans: XtreamTopologyPhaseSpan[]): void { + for (let index = 1; index < spans.length; index += 1) { + if (spans[index - 1].endEpochMs > spans[index].startEpochMs) invalid(); + } +} + +function select( + spans: readonly XtreamTopologyPhaseSpan[], + phase: string +): XtreamTopologyPhaseSpan[] { + return spans + .filter((span) => span.phase === phase) + .sort((left, right) => left.startEpochMs - right.startEpochMs); +} + +function sameCountMultiset( + left: readonly (number | null)[], + right: readonly (number | null)[] +): boolean { + const sort = (values: readonly (number | null)[]) => + [...values].sort( + (a, b) => (a === null ? -1 : a) - (b === null ? -1 : b) + ); + return JSON.stringify(sort(left)) === JSON.stringify(sort(right)); +} + +function invalid(): never { + throw new Error('invalid phase topology'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-process-evidence.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-process-evidence.spec.ts new file mode 100644 index 000000000..186ee5f7e --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-process-evidence.spec.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; + +import type { ElectronApplication } from '@playwright/test'; + +import { + captureXtreamLaunchIdentity, + createXtreamIterationProcessEvidence, +} from './xtream-process-evidence'; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +describe('Xtream process evidence', () => { + it('proves the Electron child and evaluated main PID are identical and fresh', async () => { + const seen = new Set(); + const first = await captureXtreamLaunchIdentity( + electronApp(41_001), + 41_001, + seen, + () => '00000000-0000-4000-8000-000000000001' + ); + const second = await captureXtreamLaunchIdentity( + electronApp(41_002), + 41_002, + seen, + () => '00000000-0000-4000-8000-000000000002' + ); + + assert.deepEqual(first, { + electronPid: 41_001, + launchId: 'launch-00000000-0000-4000-8000-000000000001', + }); + assert.equal(second.electronPid, 41_002); + assert.deepEqual([...seen], [41_001, 41_002]); + await assert.rejects( + () => + captureXtreamLaunchIdentity(electronApp(41_001), 41_001, seen), + /xtream-electron-process-identity-invalid/ + ); + }); + + it('rejects a launcher PID that differs from the Electron main process', async () => { + await assert.rejects( + () => + captureXtreamLaunchIdentity( + electronApp(41_002), + 41_001, + new Set() + ), + /xtream-electron-process-identity-invalid/ + ); + }); + + it('hashes the canonical iteration directory outside diagnostics', async () => { + const root = await temporaryDirectory(); + const iterationDirectory = join(root, 'run-01'); + await mkdir(iterationDirectory); + + const evidence = await createXtreamIterationProcessEvidence({ + diagnosticDirectorySha256: null, + electronPid: 41_001, + iterationDirectory, + launchId: 'launch-00000000-0000-4000-8000-000000000001', + }); + + assert.equal(evidence.freshProcessVerified, true); + assert.match(evidence.profileDirectorySha256, /^[a-f0-9]{64}$/u); + }); + + it('uses validated diagnostic directory evidence verbatim', async () => { + const root = await temporaryDirectory(); + const iterationDirectory = join(root, 'diagnostic'); + await mkdir(iterationDirectory); + const directorySha256 = 'a'.repeat(64); + + const evidence = await createXtreamIterationProcessEvidence({ + diagnosticDirectorySha256: directorySha256, + electronPid: 41_001, + iterationDirectory, + launchId: 'launch-00000000-0000-4000-8000-000000000001', + }); + + assert.equal(evidence.profileDirectorySha256, directorySha256); + }); +}); + +function electronApp(pid: number): ElectronApplication { + return { + evaluate: async () => pid, + } as unknown as ElectronApplication; +} + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp( + join(tmpdir(), 'iptvnator-xtream-process-evidence-') + ); + directories.push(directory); + return directory; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-process-evidence.ts b/apps/electron-backend-e2e/src/performance/xtream-process-evidence.ts new file mode 100644 index 000000000..3b25a80ec --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-process-evidence.ts @@ -0,0 +1,99 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { lstat, realpath } from 'node:fs/promises'; + +import type { ElectronApplication } from '@playwright/test'; + +import type { XtreamIterationProcessEvidence } from './xtream-iteration-assembler'; + +export interface XtreamLaunchIdentity { + readonly electronPid: number; + readonly launchId: string; +} + +export interface XtreamIterationProcessEvidenceOptions extends XtreamLaunchIdentity { + readonly diagnosticDirectorySha256: string | null; + readonly iterationDirectory: string; +} + +export async function captureXtreamLaunchIdentity( + electronApp: ElectronApplication, + launcherPid: number | undefined, + seenElectronPids: Set, + createUuid: () => string = randomUUID +): Promise { + try { + const evaluatedPid = await electronApp.evaluate( + async () => process.pid + ); + if ( + !Number.isSafeInteger(launcherPid) || + Number(launcherPid) <= 0 || + evaluatedPid !== launcherPid || + seenElectronPids.has(Number(launcherPid)) + ) { + invalidProcess(); + } + const launchId = `launch-${createUuid()}`; + if (!/^launch-[a-f0-9-]{36}$/u.test(launchId)) invalidProcess(); + seenElectronPids.add(Number(launcherPid)); + return Object.freeze({ + electronPid: Number(launcherPid), + launchId, + }); + } catch (error) { + if ( + error instanceof Error && + error.message === 'xtream-electron-process-identity-invalid' + ) { + throw error; + } + invalidProcess(); + } +} + +export async function createXtreamIterationProcessEvidence( + options: XtreamIterationProcessEvidenceOptions +): Promise { + try { + if ( + !Number.isSafeInteger(options.electronPid) || + options.electronPid <= 0 || + !/^launch-[a-f0-9-]{36}$/u.test(options.launchId) + ) { + invalidProcess(); + } + const stats = await lstat(options.iterationDirectory); + if (!stats.isDirectory() || stats.isSymbolicLink()) invalidProcess(); + const canonicalDirectory = await realpath(options.iterationDirectory); + const pathSha256 = sha256(canonicalDirectory); + const profileDirectorySha256 = + options.diagnosticDirectorySha256 === null + ? pathSha256 + : options.diagnosticDirectorySha256; + if (!/^[a-f0-9]{64}$/u.test(profileDirectorySha256)) { + invalidProcess(); + } + return Object.freeze({ + electronPid: options.electronPid, + freshProcessVerified: true, + launchId: options.launchId, + profileDirectorySha256, + }); + } catch (error) { + if ( + error instanceof Error && + error.message === 'xtream-electron-process-identity-invalid' + ) { + throw error; + } + invalidProcess(); + } +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function invalidProcess(): never { + throw new Error('xtream-electron-process-identity-invalid'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-provider-phase-topology.ts b/apps/electron-backend-e2e/src/performance/xtream-provider-phase-topology.ts new file mode 100644 index 000000000..7f1c8d689 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-provider-phase-topology.ts @@ -0,0 +1,372 @@ +import type { XtreamScenarioId } from './xtream-benchmark-contract'; +import { + XTREAM_ATTRIBUTION_PHASE as PHASE, + XTREAM_PHASE_SEMANTIC_TYPE as SEMANTIC, + type XtreamPhaseSemanticType, +} from './xtream-phase-inventory'; +import type { XtreamTopologyPhaseSpan } from './xtream-phase-topology'; + +const CATEGORIES = [ + { count: 60, type: SEMANTIC.LIVE_CATEGORIES }, + { count: 20, type: SEMANTIC.VOD_CATEGORIES }, + { count: 20, type: SEMANTIC.SERIES_CATEGORIES }, +] as const; +const CONTENT = [ + { + categoryCount: 60, + itemCount: 60_000, + storePhase: PHASE.STORE_PUBLISH_LIVE, + type: SEMANTIC.LIVE_CONTENT, + }, + { + categoryCount: 20, + itemCount: 20_000, + storePhase: PHASE.STORE_PUBLISH_VOD, + type: SEMANTIC.VOD_CONTENT, + }, + { + categoryCount: 20, + itemCount: 20_000, + storePhase: PHASE.STORE_PUBLISH_SERIES, + type: SEMANTIC.SERIES_CONTENT, + }, +] as const; +const CATEGORY_PHASES = new Set([ + PHASE.NORMALIZE_CATEGORIES, + PHASE.SQLITE_CATEGORIES_READ, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, +]); +const CATEGORY_TYPES = new Set( + CATEGORIES.map(({ type }) => type) +); +const CONTENT_PHASES = new Set([ + PHASE.NORMALIZE_CONTENT, + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.SQLITE_CONTENT_READ, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, +]); +const CONTENT_TYPES = new Set( + CONTENT.map(({ type }) => type) +); +const PROVIDER_PHASES = new Set([ + PHASE.JSON_TRANSFORM, + PHASE.NETWORK_TOTAL, + PHASE.RESPONSE_READY, +]); + +export function assertXtreamProviderCausality( + scenarioId: XtreamScenarioId, + spans: readonly XtreamTopologyPhaseSpan[] +): void { + assertSemanticOwnership(spans); + if (scenarioId === 'xtream-delete-large') return; + + const routeHydratesAfterImport = + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui'; + const accounts = semanticMany(spans, PHASE.NETWORK_TOTAL, SEMANTIC.ACCOUNT); + if (accounts.length !== 2) invalid(); + if ( + accounts.some( + (account, index) => + index > 0 && + required(accounts, index - 1).startEpochMs >= + account.startEpochMs + ) + ) { + invalid(); + } + const accountResponses = accounts.map((network) => + accountResponse(spans, network) + ); + // Fresh-add windows start the passive Sources check before route bootstrap, + // so the later response gates the import. Refresh windows first run the + // import and only then hydrate the recreated route from SQLite. + const account = required( + accountResponses, + routeHydratesAfterImport ? 0 : accountResponses.length - 1 + ); + const routeAccount = routeHydratesAfterImport + ? required(accountResponses, 1) + : null; + const routeAccountNetwork = routeHydratesAfterImport + ? required(accounts, 1) + : null; + const routeStore = routeHydratesAfterImport + ? required(phaseMany(spans, PHASE.STORE_PUBLISH_CATEGORIES), 1) + : null; + const terminal = routeHydratesAfterImport + ? required(phaseMany(spans, PHASE.STORE_IMPORT_TERMINAL), 0) + : null; + if ( + routeAccount && + routeAccountNetwork && + terminal && + terminal.endEpochMs > routeAccountNetwork.startEpochMs + ) { + invalid(); + } + for (const category of CATEGORIES) { + const preRead = semanticOne( + spans, + PHASE.SQLITE_CATEGORIES_READ, + category.type, + 0 + ); + const response = semanticOne( + spans, + PHASE.RESPONSE_READY, + category.type + ); + const normalization = semanticOne( + spans, + PHASE.NORMALIZE_CATEGORIES, + category.type, + category.count + ); + const write = semanticOne( + spans, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + category.type, + category.count + ); + const completedReads = semanticMany( + spans, + PHASE.SQLITE_CATEGORIES_READ, + category.type + ).filter(({ itemCount }) => itemCount === category.count); + if (completedReads.length !== (routeHydratesAfterImport ? 2 : 1)) { + invalid(); + } + const postRead = required(completedReads, 0); + ordered(account, preRead, response, normalization, write, postRead); + if (routeAccount && routeStore) { + ordered(routeAccount, required(completedReads, 1), routeStore); + } + } + + const expectedContent = + scenarioId === 'xtream-cancel-import' ? CONTENT.slice(0, 1) : CONTENT; + for (const content of expectedContent) { + assertContentCausality(spans, content, scenarioId); + } +} + +function accountResponse( + spans: readonly XtreamTopologyPhaseSpan[], + network: XtreamTopologyPhaseSpan +): XtreamTopologyPhaseSpan { + const transform = correlatedSemanticOne( + spans, + PHASE.JSON_TRANSFORM, + SEMANTIC.ACCOUNT, + network.correlationId + ); + const response = correlatedSemanticOne( + spans, + PHASE.RESPONSE_READY, + SEMANTIC.ACCOUNT, + network.correlationId + ); + if ( + network.startEpochMs > transform.startEpochMs || + transform.endEpochMs > network.endEpochMs || + network.endEpochMs > response.startEpochMs + ) { + invalid(); + } + return response; +} + +function assertContentCausality( + spans: readonly XtreamTopologyPhaseSpan[], + content: (typeof CONTENT)[number], + scenarioId: XtreamScenarioId +): void { + const categoryStore = required( + phaseMany(spans, PHASE.STORE_PUBLISH_CATEGORIES), + 0 + ); + const preRead = semanticOne( + spans, + PHASE.SQLITE_CONTENT_READ, + content.type, + 0 + ); + const response = semanticOne(spans, PHASE.RESPONSE_READY, content.type); + const categoryMap = semanticOne( + spans, + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + content.type, + content.categoryCount + ); + const normalization = semanticOne( + spans, + PHASE.NORMALIZE_CONTENT, + content.type, + content.itemCount + ); + const write = semanticOne( + spans, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + content.type, + scenarioId === 'xtream-cancel-import' ? null : content.itemCount + ); + ordered( + categoryStore, + preRead, + response, + categoryMap, + normalization, + write + ); + if (scenarioId === 'xtream-cancel-import') return; + const routeHydratesAfterImport = + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui'; + const completedReads = semanticMany( + spans, + PHASE.SQLITE_CONTENT_READ, + content.type + ).filter(({ itemCount }) => itemCount === content.itemCount); + const stores = semanticMany(spans, content.storePhase, content.type).filter( + ({ itemCount }) => itemCount === content.itemCount + ); + const expectedCount = routeHydratesAfterImport ? 2 : 1; + if ( + completedReads.length !== expectedCount || + stores.length !== expectedCount + ) { + invalid(); + } + ordered(write, required(completedReads, 0), required(stores, 0)); + if (routeHydratesAfterImport) { + const routeCategoryStore = required( + phaseMany(spans, PHASE.STORE_PUBLISH_CATEGORIES), + 1 + ); + ordered( + routeCategoryStore, + required(completedReads, 1), + required(stores, 1) + ); + } +} + +function assertSemanticOwnership( + spans: readonly XtreamTopologyPhaseSpan[] +): void { + for (const span of spans) { + if (PROVIDER_PHASES.has(span.phase)) { + if (span.semanticType === null) invalid(); + continue; + } + if (CATEGORY_PHASES.has(span.phase)) { + if ( + span.semanticType === null || + !CATEGORY_TYPES.has(span.semanticType) + ) { + invalid(); + } + continue; + } + if (CONTENT_PHASES.has(span.phase)) { + if ( + span.semanticType === null || + !CONTENT_TYPES.has(span.semanticType) + ) { + invalid(); + } + continue; + } + const expectedStoreType = storeSemanticType(span.phase); + if ( + (expectedStoreType === null && span.semanticType !== null) || + (expectedStoreType !== null && + span.semanticType !== expectedStoreType) + ) { + invalid(); + } + } +} + +function storeSemanticType(phase: string): XtreamPhaseSemanticType | null { + if (phase === PHASE.STORE_PUBLISH_LIVE) return SEMANTIC.LIVE_CONTENT; + if (phase === PHASE.STORE_PUBLISH_VOD) return SEMANTIC.VOD_CONTENT; + if (phase === PHASE.STORE_PUBLISH_SERIES) return SEMANTIC.SERIES_CONTENT; + return null; +} + +function semanticOne( + spans: readonly XtreamTopologyPhaseSpan[], + phase: string, + semanticType: XtreamPhaseSemanticType, + itemCount?: number | null +): XtreamTopologyPhaseSpan { + const matches = spans.filter( + (span) => + span.phase === phase && + span.semanticType === semanticType && + (itemCount === undefined || span.itemCount === itemCount) + ); + if (matches.length !== 1) invalid(); + return required(matches, 0); +} + +function semanticMany( + spans: readonly XtreamTopologyPhaseSpan[], + phase: string, + semanticType: XtreamPhaseSemanticType +): XtreamTopologyPhaseSpan[] { + return spans + .filter( + (span) => span.phase === phase && span.semanticType === semanticType + ) + .sort((left, right) => left.startEpochMs - right.startEpochMs); +} + +function phaseMany( + spans: readonly XtreamTopologyPhaseSpan[], + phase: string +): XtreamTopologyPhaseSpan[] { + return spans + .filter((span) => span.phase === phase) + .sort((left, right) => left.startEpochMs - right.startEpochMs); +} + +function correlatedSemanticOne( + spans: readonly XtreamTopologyPhaseSpan[], + phase: string, + semanticType: XtreamPhaseSemanticType, + correlationId: string +): XtreamTopologyPhaseSpan { + const matches = spans.filter( + (span) => + span.phase === phase && + span.semanticType === semanticType && + span.correlationId === correlationId + ); + if (matches.length !== 1) invalid(); + return required(matches, 0); +} + +function ordered(...spans: readonly XtreamTopologyPhaseSpan[]): void { + for (let index = 1; index < spans.length; index += 1) { + if ( + required(spans, index - 1).endEpochMs > + required(spans, index).startEpochMs + ) { + invalid(); + } + } +} + +function required(values: readonly T[], index: number): T { + const value = values[index]; + if (!value) invalid(); + return value; +} + +function invalid(): never { + throw new Error('invalid provider phase topology'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-raw-persistence-receipt.ts b/apps/electron-backend-e2e/src/performance/xtream-raw-persistence-receipt.ts new file mode 100644 index 000000000..04fd6c1cb --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-raw-persistence-receipt.ts @@ -0,0 +1,83 @@ +import { createHash } from 'node:crypto'; +import { isAbsolute } from 'node:path'; + +import { readXtreamSafeRegularFile } from './xtream-safe-file-read'; +import type { + XtreamPersistedRawEvidence, + XtreamPersistenceReceipt, +} from './xtream-summary-contract'; + +export async function verifyXtreamPersistenceReceipt( + serialized: string, + receipt: XtreamPersistenceReceipt +): Promise { + const bytes = Buffer.from(serialized, 'utf8'); + const rawSha256 = sha256(bytes); + try { + if (!hasExactReceiptShape(receipt)) { + invalidReceipt(); + } + const { + absolutePath, + bytes: persistedBytes, + sha256: persistedSha256, + } = receipt; + if ( + typeof absolutePath !== 'string' || + !isAbsolute(absolutePath) || + persistedBytes !== bytes.byteLength || + persistedSha256 !== rawSha256 + ) { + invalidReceipt(); + } + const { content: persisted, realPath: path } = + await readXtreamSafeRegularFile(absolutePath); + if (!persisted.equals(bytes)) invalidReceipt(); + return { + bytes: bytes.byteLength, + pathSha256: sha256(Buffer.from(path, 'utf8')), + rawSha256, + }; + } catch (error) { + if ( + error instanceof Error && + error.message === 'invalid Xtream persistence receipt' + ) { + throw error; + } + invalidReceipt(); + } +} + +function hasExactReceiptShape( + value: unknown +): value is XtreamPersistenceReceipt { + try { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + ![Object.prototype, null].includes(Reflect.getPrototypeOf(value)) + ) { + return false; + } + const expected = ['absolutePath', 'bytes', 'sha256']; + const ownKeys = Reflect.ownKeys(value); + return ( + ownKeys.length === expected.length && + ownKeys.every( + (key) => typeof key === 'string' && expected.includes(key) + ) + ); + } catch { + return false; + } +} + +function sha256(value: Uint8Array): string { + return createHash('sha256').update(value).digest('hex'); +} + +function invalidReceipt(): never { + throw new Error('invalid Xtream persistence receipt'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-refresh-route-phase-topology.ts b/apps/electron-backend-e2e/src/performance/xtream-refresh-route-phase-topology.ts new file mode 100644 index 000000000..4a660a41b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-refresh-route-phase-topology.ts @@ -0,0 +1,122 @@ +import { XTREAM_ATTRIBUTION_PHASE as PHASE } from './xtream-phase-inventory'; +import type { XtreamTopologyPhaseSpan } from './xtream-phase-topology'; + +const EXPECTED_ROUTE_CONTENT = [ + { + count: 60_000, + semanticType: 'live-content', + storePhase: PHASE.STORE_PUBLISH_LIVE, + }, + { + count: 20_000, + semanticType: 'vod-content', + storePhase: PHASE.STORE_PUBLISH_VOD, + }, + { + count: 20_000, + semanticType: 'series-content', + storePhase: PHASE.STORE_PUBLISH_SERIES, + }, +] as const; + +export function assertXtreamRefreshRouteHydration( + spans: readonly XtreamTopologyPhaseSpan[], + terminal: XtreamTopologyPhaseSpan, + routeCategoryStore: XtreamTopologyPhaseSpan, + routeContentReads: readonly XtreamTopologyPhaseSpan[], + routeContentStores: readonly XtreamTopologyPhaseSpan[], + routeTerminal: XtreamTopologyPhaseSpan +): void { + const categoryReads = select(spans, PHASE.SQLITE_CATEGORIES_READ).filter( + ({ startEpochMs }) => startEpochMs >= terminal.endEpochMs + ); + if ( + categoryReads.length !== 3 || + !sameCountMultiset( + categoryReads.map(({ itemCount }) => itemCount), + [60, 20, 20] + ) || + new Set(categoryReads.map(({ semanticType }) => semanticType)).size !== + 3 || + categoryReads.some( + ({ endEpochMs, startEpochMs }) => + terminal.endEpochMs > startEpochMs || + endEpochMs > routeCategoryStore.startEpochMs + ) + ) { + invalid(); + } + if ( + routeContentReads.length !== EXPECTED_ROUTE_CONTENT.length || + routeContentStores.length !== EXPECTED_ROUTE_CONTENT.length + ) { + invalid(); + } + const sequence = [routeCategoryStore]; + for (const [index, item] of EXPECTED_ROUTE_CONTENT.entries()) { + const read = required(routeContentReads, index); + const store = required(routeContentStores, index); + if ( + read.itemCount !== item.count || + read.semanticType !== item.semanticType || + store.phase !== item.storePhase || + store.itemCount !== item.count || + store.semanticType !== item.semanticType + ) { + invalid(); + } + sequence.push(read, store); + } + ordered(...sequence, routeTerminal); + assertTerminalLast(spans, routeTerminal); +} + +function assertTerminalLast( + spans: readonly XtreamTopologyPhaseSpan[], + terminal: XtreamTopologyPhaseSpan +): void { + if ( + spans.some( + (span) => + span !== terminal && span.endEpochMs > terminal.startEpochMs + ) + ) { + invalid(); + } +} + +function required(values: readonly T[], index: number): T { + const value = values[index]; + if (!value) invalid(); + return value; +} + +function ordered(...spans: XtreamTopologyPhaseSpan[]): void { + for (let index = 1; index < spans.length; index += 1) { + if (spans[index - 1].endEpochMs > spans[index].startEpochMs) invalid(); + } +} + +function select( + spans: readonly XtreamTopologyPhaseSpan[], + phase: string +): XtreamTopologyPhaseSpan[] { + return spans + .filter((span) => span.phase === phase) + .sort((left, right) => left.startEpochMs - right.startEpochMs); +} + +function sameCountMultiset( + left: readonly (number | null)[], + right: readonly (number | null)[] +): boolean { + const sort = (values: readonly (number | null)[]) => + [...values].sort( + (a, b) => (a === null ? -1 : a) - (b === null ? -1 : b) + ); + return JSON.stringify(sort(left)) === JSON.stringify(sort(right)); +} + +function invalid(): never { + throw new Error('invalid phase topology'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-capture.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-capture.spec.ts new file mode 100644 index 000000000..3ef5503ec --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-capture.spec.ts @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +interface CaptureModule { + advanceXtreamRendererHeartbeatDeadline?: ( + deadlineEpochMs: number, + nowEpochMs: number + ) => number; + selectXtreamRendererTarget?: (session: FakeSession) => Promise; + startXtreamRendererCapture?: unknown; +} + +interface ArtifactModule { + assertRendererMeasurementWindow?: (input: { + readonly captureCutoffEpochMs: number; + readonly measurementStartEpochMs: number; + readonly operationStartEpochMs: number; + readonly terminalEpochMs: number; + }) => void; + selectRendererPeakHeapUsedBytes?: ( + samples: readonly { + readonly epochMs: number; + readonly usedSize: number; + }[], + measurementStartEpochMs: number, + terminalEpochMs: number + ) => number; +} + +interface TargetInfo { + readonly targetId: string; + readonly title: string; + readonly type: string; + readonly url: string; +} + +class FakeSession { + constructor(private readonly targets: readonly TargetInfo[]) {} + + async send(method: string): Promise> { + assert.equal(method, 'Target.getTargets'); + return { targetInfos: this.targets }; + } +} + +const captureUrl = new URL('./xtream-renderer-capture.ts', import.meta.url); +const artifactUrl = new URL( + './m3u-import-renderer-artifacts.ts', + import.meta.url +); +const modulePromise = import(captureUrl.href) + .then((module) => module as unknown as CaptureModule) + .catch(() => null); +const artifactModulePromise = import(artifactUrl.href) + .then((module) => module as unknown as ArtifactModule) + .catch(() => null); + +test('exports capture and persists the one exact IPTVnator renderer target', async () => { + const module = await modulePromise; + assert.ok(module, 'Xtream renderer capture module must exist'); + assert.equal(typeof module.startXtreamRendererCapture, 'function'); + assert.equal(typeof module.selectXtreamRendererTarget, 'function'); + + const targetId = await module.selectXtreamRendererTarget?.( + new FakeSession([ + { + targetId: 'renderer-7', + title: 'IPTVnator', + type: 'page', + url: 'http://localhost:4200/workspace/sources', + }, + { + targetId: 'devtools', + title: 'DevTools', + type: 'page', + url: 'devtools://devtools/bundled/inspector.html', + }, + ]) + ); + assert.equal(targetId, 'renderer-7'); +}); + +test('fails closed when renderer target identity is missing or ambiguous', async () => { + const module = await modulePromise; + assert.ok(module?.selectXtreamRendererTarget); + const target = { + targetId: 'renderer-1', + title: 'IPTVnator', + type: 'page', + url: 'http://localhost:4200/workspace/sources', + }; + + await assert.rejects( + module.selectXtreamRendererTarget(new FakeSession([])), + /xtream-renderer-target-count:0/ + ); + await assert.rejects( + module.selectXtreamRendererTarget( + new FakeSession([target, { ...target, targetId: 'renderer-2' }]) + ), + /xtream-renderer-target-count:2/ + ); +}); + +test('arms the renderer without sampling heap or starting diagnostics before the operation boundary', () => { + const source = readFileSync(captureUrl, 'utf8'); + const artifacts = readFileSync(artifactUrl, 'utf8'); + const operationBoundary = source.indexOf( + 'await beginXtreamRendererOperation(page)' + ); + const initialHeapSample = source.indexOf('await sampleHeap()'); + const diagnosticStart = source.indexOf( + 'await startRendererDiagnosticCapture(session, artifacts)' + ); + const heapTimerStart = source.indexOf( + 'setInterval(() => void sampleHeap(), 20)' + ); + + assert.ok(operationBoundary >= 0); + assert.ok(initialHeapSample > operationBoundary); + assert.ok(diagnosticStart > operationBoundary); + assert.ok(heapTimerStart > operationBoundary); + assert.equal(source.match(/await sampleHeap\(\)/g)?.length, 1); + assert.match(source, /HEARTBEAT_INTERVAL_MS\s*=\s*50/); + assert.match(source, /Runtime\.getHeapUsage/); + assert.match(source, /HeapProfiler\.collectGarbage/); + assert.match(source, /createRendererArtifactCapture/); + assert.match(source, /startRendererDiagnosticCapture/); + assert.match(source, /stopRendererDiagnosticCapture/); + assert.match(source, /takeRendererHeapSnapshot/); + assert.match(artifacts, /Profiler\.start/); + assert.match(artifacts, /Tracing\.start/); +}); + +test('bounds peak heap to timestamped samples inside the renderer operation window', async () => { + const artifacts = await artifactModulePromise; + assert.ok(artifacts?.selectRendererPeakHeapUsedBytes); + + assert.equal( + artifacts.selectRendererPeakHeapUsedBytes( + [ + { epochMs: 99, usedSize: 900 }, + { epochMs: 100, usedSize: 10 }, + { epochMs: 150, usedSize: 30 }, + { epochMs: 200, usedSize: 20 }, + { epochMs: 201, usedSize: 800 }, + ], + 100, + 200 + ), + 30 + ); + assert.throws( + () => + artifacts.selectRendererPeakHeapUsedBytes?.( + [{ epochMs: 201, usedSize: 800 }], + 100, + 200 + ), + /renderer-heap-window-empty/ + ); +}); + +test('validates and persists an explicit renderer measurement window', async () => { + const artifacts = await artifactModulePromise; + assert.ok(artifacts?.assertRendererMeasurementWindow); + + artifacts.assertRendererMeasurementWindow({ + captureCutoffEpochMs: 200, + measurementStartEpochMs: 110, + operationStartEpochMs: 100, + terminalEpochMs: 190, + }); + assert.throws( + () => + artifacts.assertRendererMeasurementWindow?.({ + captureCutoffEpochMs: 200, + measurementStartEpochMs: 99, + operationStartEpochMs: 100, + terminalEpochMs: 190, + }), + /renderer-measurement-window-invalid/ + ); + assert.throws( + () => + artifacts.assertRendererMeasurementWindow?.({ + captureCutoffEpochMs: 189, + measurementStartEpochMs: 110, + operationStartEpochMs: 100, + terminalEpochMs: 190, + }), + /renderer-measurement-window-invalid/ + ); + + const source = readFileSync(captureUrl, 'utf8'); + assert.match(source, /readonly measurementStartEpochMs: number/); + assert.match(source, /readonly captureCutoffEpochMs: number/); +}); + +test('cuts off scheduling and diagnostics before forced GC and snapshot work', () => { + const source = readFileSync(captureUrl, 'utf8'); + const stopCapture = source.indexOf('const stopCapture'); + const stopScheduling = source.indexOf('stopScheduling()', stopCapture); + const diagnosticStop = source.indexOf( + 'await stopRendererDiagnosticCapture(session, artifacts)', + stopCapture + ); + const forcedGc = source.indexOf( + "await session.send('HeapProfiler.collectGarbage')", + stopCapture + ); + const heapSnapshot = source.indexOf( + 'await takeRendererHeapSnapshot(', + stopCapture + ); + + assert.ok(stopCapture >= 0); + assert.ok(stopScheduling > stopCapture); + assert.ok(diagnosticStop > stopScheduling); + assert.ok(forcedGc > diagnosticStop); + assert.ok(heapSnapshot > forcedGc); +}); + +test('persists only error counts and cleans up listeners/session idempotently', () => { + const source = readFileSync(captureUrl, 'utf8'); + + assert.match(source, /consoleErrorCount/); + assert.match(source, /pageErrorCount/); + assert.doesNotMatch(source, /consoleErrorMessages|pageErrorMessages/); + assert.match(source, /page\.off\('console'/); + assert.match(source, /page\.off\('pageerror'/); + assert.match(source, /disposePromise/); +}); + +test('uses deadline-based heartbeat catch-up without accumulating drift', async () => { + const module = await modulePromise; + assert.ok(module?.advanceXtreamRendererHeartbeatDeadline); + + assert.equal(module.advanceXtreamRendererHeartbeatDeadline(150, 149), 200); + assert.equal(module.advanceXtreamRendererHeartbeatDeadline(150, 251), 300); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-capture.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-capture.ts new file mode 100644 index 000000000..71aa99b8a --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-capture.ts @@ -0,0 +1,384 @@ +import type { CDPSession, ConsoleMessage, Page } from '@playwright/test'; + +import type { + NumericDistribution, + XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { + assertRendererMeasurementWindow, + createRendererArtifactCapture, + disposeRendererDiagnosticCapture, + type RendererArtifactCapture, + type RendererHeapUsageSample, + selectRendererPeakHeapUsedBytes, + startRendererDiagnosticCapture, + stopRendererDiagnosticCapture, + takeRendererHeapSnapshot, +} from './m3u-import-renderer-artifacts'; +import { summarizeNumbers } from './performance-statistics'; +import { + assertCompleteXtreamRendererProbe, + beginXtreamRendererOperation, + installXtreamRendererProbe, + recordXtreamRendererHeartbeat, + stopXtreamRendererProbe, + type XtreamRendererProbeMetrics, + waitForXtreamRendererTerminal, +} from './xtream-renderer-probe'; +import { + createXtreamRendererConsoleErrorBreakdown, + recordXtreamRendererConsoleError, + type XtreamRendererConsoleErrorBreakdown, +} from './xtream-renderer-console-errors'; + +const HEARTBEAT_INTERVAL_MS = 50; + +export interface XtreamRendererCaptureOptions { + readonly diagnostic: boolean; + readonly outputDirectory: string; + readonly scenarioId: XtreamScenarioId; + readonly sourceTitle: string; +} + +export interface XtreamRendererCaptureMetrics { + readonly captureCutoffEpochMs: number; + readonly consoleErrorBreakdown: XtreamRendererConsoleErrorBreakdown; + readonly consoleErrorCount: number; + readonly cpuProfilePath: string | null; + readonly frameGap: NumericDistribution; + readonly heapSnapshotPath: string | null; + readonly heartbeatDelay: NumericDistribution; + readonly longTask: NumericDistribution; + readonly measurementStartEpochMs: number; + readonly pageErrorCount: number; + readonly peakHeapUsedBytes: number; + readonly postGcHeapUsedBytes: number; + readonly probe: XtreamRendererProbeMetrics; + readonly storeToPaintMs: number; + readonly targetId: string; + readonly tracePath: string | null; +} + +export interface RunningXtreamRendererCapture { + beginOperation(): Promise; + dispose(): Promise; + readonly session: CDPSession; + stop(): Promise; + readonly targetId: string; + waitForTerminal(): Promise; +} + +interface HeapUsage { + readonly usedSize: number; +} + +interface TargetInfo { + readonly targetId: string; + readonly title: string; + readonly type: string; + readonly url: string; +} + +export async function selectXtreamRendererTarget( + session: Pick +): Promise { + const response = (await session.send('Target.getTargets')) as { + readonly targetInfos?: readonly TargetInfo[]; + }; + const matches = (response.targetInfos ?? []).filter( + ({ title, type, url }) => + type === 'page' && + !url.startsWith('devtools://') && + title.toLocaleLowerCase().includes('iptvnator') && + /\/workspace(?:\/|$)/.test(url) + ); + if (matches.length !== 1 || !matches[0]) { + throw new Error(`xtream-renderer-target-count:${matches.length}`); + } + return matches[0].targetId; +} + +export async function startXtreamRendererCapture( + page: Page, + options: XtreamRendererCaptureOptions +): Promise { + const session = await page.context().newCDPSession(page); + let targetId: string; + try { + targetId = await selectXtreamRendererTarget(session); + } catch (error) { + continueBestEffort(() => session.detach()); + throw error; + } + let consoleErrorCount = 0; + const consoleErrorBreakdown = createXtreamRendererConsoleErrorBreakdown(); + let pageErrorCount = 0; + const onConsole = (message: ConsoleMessage): void => { + if (message.type() !== 'error') return; + consoleErrorCount += 1; + recordXtreamRendererConsoleError( + consoleErrorBreakdown, + options.scenarioId, + message.text() + ); + }; + const onPageError = (): void => { + pageErrorCount += 1; + }; + page.on('console', onConsole); + page.on('pageerror', onPageError); + + let artifacts: RendererArtifactCapture | null = null; + let heapTimer: NodeJS.Timeout | null = null; + let heapInFlight: Promise | null = null; + let heapError: unknown = null; + let probeInstalled = false; + const heapSamples: RendererHeapUsageSample[] = []; + const sampleHeap = (): Promise => { + heapInFlight ??= (async () => { + try { + const sample = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsage; + if (Number.isFinite(sample.usedSize) && sample.usedSize >= 0) { + heapSamples.push({ + epochMs: Date.now(), + usedSize: sample.usedSize, + }); + } + } catch (error) { + heapError ??= error; + } finally { + heapInFlight = null; + } + })(); + return heapInFlight; + }; + + try { + await session.send('Runtime.enable'); + await session.send('Performance.enable'); + await installXtreamRendererProbe(page, options); + probeInstalled = true; + artifacts = createRendererArtifactCapture(options); + } catch (error) { + if (heapTimer) clearInterval(heapTimer); + if (artifacts && options.diagnostic) { + await disposeRendererDiagnosticCapture(session, artifacts).catch( + () => undefined + ); + } + if (probeInstalled) { + continueBestEffort(() => stopXtreamRendererProbe(page)); + } + removePageErrorListeners(page, onConsole, onPageError); + continueBestEffort(() => session.detach()); + throw error; + } + if (!artifacts) { + throw new Error('xtream-renderer-capture-initialization-failed'); + } + + let stopped = false; + let operationStarted = false; + let measurementStartEpochMs: number | null = null; + let heartbeatDeadlineEpochMs: number | null = null; + let heartbeatError: unknown = null; + let heartbeatInFlight: Promise | null = null; + let heartbeatTimer: NodeJS.Timeout | null = null; + let stopPromise: Promise | null = null; + let disposePromise: Promise | null = null; + let detachPromise: Promise | null = null; + const detach = (): Promise => { + detachPromise ??= session.detach(); + return detachPromise; + }; + const stopScheduling = (): void => { + stopped = true; + if (heartbeatTimer) { + clearTimeout(heartbeatTimer); + heartbeatTimer = null; + } + if (heapTimer) { + clearInterval(heapTimer); + heapTimer = null; + } + }; + const scheduleHeartbeat = (): void => { + if (stopped || heartbeatDeadlineEpochMs === null) return; + const deadline = heartbeatDeadlineEpochMs; + heartbeatTimer = setTimeout( + () => { + heartbeatTimer = null; + heartbeatInFlight = recordXtreamRendererHeartbeat( + page, + deadline + ) + .catch((error: unknown) => { + heartbeatError ??= error; + }) + .finally(() => { + heartbeatInFlight = null; + if (stopped) return; + heartbeatDeadlineEpochMs = + advanceXtreamRendererHeartbeatDeadline( + deadline, + Date.now() + ); + scheduleHeartbeat(); + }); + }, + Math.max(0, deadline - Date.now()) + ); + }; + const stopCapture = async (): Promise => { + try { + stopScheduling(); + const captureCutoffEpochMs = Date.now(); + if (measurementStartEpochMs === null) { + throw new Error('xtream-renderer-operation-not-started'); + } + if (options.diagnostic) { + await stopRendererDiagnosticCapture(session, artifacts); + } + await heartbeatInFlight; + await heapInFlight; + const probe = await stopXtreamRendererProbe(page); + await session.send('HeapProfiler.enable'); + await session.send('HeapProfiler.collectGarbage'); + const postGc = (await session.send( + 'Runtime.getHeapUsage' + )) as HeapUsage; + if (options.diagnostic) { + await takeRendererHeapSnapshot( + session, + artifacts.heapSnapshotPath + ); + } + await detach(); + removePageErrorListeners(page, onConsole, onPageError); + throwIfError(heapError, 'xtream-renderer-heap-sampling-failed'); + throwIfError(heartbeatError, 'xtream-renderer-heartbeat-failed'); + assertCompleteXtreamRendererProbe(probe); + assertRendererMeasurementWindow({ + captureCutoffEpochMs, + measurementStartEpochMs, + operationStartEpochMs: probe.operationStartEpochMs, + terminalEpochMs: probe.terminalEpochMs, + }); + return Object.freeze({ + captureCutoffEpochMs, + consoleErrorBreakdown: Object.freeze({ + ...consoleErrorBreakdown, + }), + consoleErrorCount, + cpuProfilePath: artifacts.cpuProfilePath, + frameGap: summarizeNumbers(probe.frameGapsMs), + heapSnapshotPath: artifacts.heapSnapshotPath, + heartbeatDelay: summarizeNumbers(probe.heartbeatDelaysMs), + longTask: summarizeNumbers(probe.longTasksMs), + measurementStartEpochMs, + pageErrorCount, + peakHeapUsedBytes: selectRendererPeakHeapUsedBytes( + heapSamples, + measurementStartEpochMs, + probe.terminalEpochMs + ), + postGcHeapUsedBytes: postGc.usedSize, + probe, + storeToPaintMs: + probe.uiPaintedEpochMs - probe.storeTerminalEpochMs, + targetId, + tracePath: artifacts.tracePath, + }); + } catch (error) { + await dispose(); + throw error; + } + }; + const dispose = (): Promise => { + disposePromise ??= (async () => { + stopScheduling(); + removePageErrorListeners(page, onConsole, onPageError); + continueBestEffort(() => heartbeatInFlight ?? Promise.resolve()); + continueBestEffort(() => heapInFlight ?? Promise.resolve()); + continueBestEffort(() => stopXtreamRendererProbe(page)); + if (options.diagnostic) { + await disposeRendererDiagnosticCapture( + session, + artifacts + ).catch(() => undefined); + } + continueBestEffort(detach); + })(); + return disposePromise; + }; + + return Object.freeze({ + beginOperation: async () => { + if (stopped || operationStarted) { + throw new Error('xtream-renderer-operation-already-started'); + } + operationStarted = true; + const startedAt = await beginXtreamRendererOperation(page); + measurementStartEpochMs = startedAt; + if (options.diagnostic) { + await startRendererDiagnosticCapture(session, artifacts); + } + await sampleHeap(); + throwIfError(heapError, 'xtream-renderer-heap-sampling-failed'); + heapTimer = setInterval(() => void sampleHeap(), 20); + heartbeatDeadlineEpochMs = startedAt + HEARTBEAT_INTERVAL_MS; + scheduleHeartbeat(); + return startedAt; + }, + dispose, + session, + stop: () => { + if (disposePromise && !stopPromise) { + return Promise.reject( + new Error('xtream-renderer-capture-disposed') + ); + } + stopPromise ??= stopCapture(); + return stopPromise; + }, + targetId, + waitForTerminal: () => waitForXtreamRendererTerminal(page), + }); +} + +export function advanceXtreamRendererHeartbeatDeadline( + deadlineEpochMs: number, + nowEpochMs: number +): number { + const next = deadlineEpochMs + HEARTBEAT_INTERVAL_MS; + if (next > nowEpochMs) return next; + return ( + next + + (Math.floor((nowEpochMs - next) / HEARTBEAT_INTERVAL_MS) + 1) * + HEARTBEAT_INTERVAL_MS + ); +} + +function removePageErrorListeners( + page: Page, + onConsole: (message: ConsoleMessage) => void, + onPageError: () => void +): void { + page.off('console', onConsole); + page.off('pageerror', onPageError); +} + +function throwIfError(error: unknown, label: string): void { + if (error instanceof Error) throw error; + if (error !== null) throw new Error(`${label}:${String(error)}`); +} + +function continueBestEffort(work: () => Promise): void { + try { + void work().catch(() => undefined); + } catch { + // The owning Electron process is closed after failed-iteration cleanup. + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-console-errors.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-console-errors.spec.ts new file mode 100644 index 000000000..3c696fee7 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-console-errors.spec.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; +import { + createXtreamRendererConsoleErrorBreakdown, + recordXtreamRendererConsoleError, + validXtreamRendererConsoleErrorEvidence, +} from './xtream-renderer-console-errors'; + +const cancelledInvoke = + 'Error invoking remote method DB_SAVE_CONTENT: AbortError: ' + + 'Operation "save-content" was cancelled'; + +describe('Xtream renderer console error evidence', () => { + it('accepts only the exact three current IPC cancellation errors', () => { + const exact = createXtreamRendererConsoleErrorBreakdown(); + for (const message of [ + `Error saving Xtream content: ${cancelledInvoke}`, + `[withContent] Error fetching content ${cancelledInvoke}`, + `[withContent] Error initializing content ${cancelledInvoke}`, + ]) { + recordXtreamRendererConsoleError( + exact, + XTREAM_SCENARIO_ID.CANCEL_IMPORT, + message + ); + } + + assert.equal( + validXtreamRendererConsoleErrorEvidence( + 3, + exact, + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ), + true + ); + assert.equal( + validXtreamRendererConsoleErrorEvidence( + 3, + exact, + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ), + false + ); + + for (const messages of [ + ['generic error', 'generic error', 'generic error'], + [ + `Error saving Xtream content: ${cancelledInvoke}`, + `[withContent] Error fetching content ${cancelledInvoke}`, + ], + [ + `Error saving Xtream content: ${cancelledInvoke}`, + `[withContent] Error fetching content ${cancelledInvoke}`, + `[withContent] Error fetching content ${cancelledInvoke}`, + ], + [ + 'Error saving Xtream content: request failed', + '[withContent] Error fetching content request failed', + '[withContent] Error initializing content request failed', + ], + [ + `Error saving Xtream content: ${cancelledInvoke}`, + `[withContent] Error fetching content ${cancelledInvoke}`, + `[withContent] Error initializing content ${cancelledInvoke}`, + 'unexpected fourth error', + ], + ]) { + const candidate = createXtreamRendererConsoleErrorBreakdown(); + for (const message of messages) { + recordXtreamRendererConsoleError( + candidate, + XTREAM_SCENARIO_ID.CANCEL_IMPORT, + message + ); + } + assert.equal( + validXtreamRendererConsoleErrorEvidence( + messages.length, + candidate, + XTREAM_SCENARIO_ID.CANCEL_IMPORT + ), + false + ); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-console-errors.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-console-errors.ts new file mode 100644 index 000000000..f9bdcf75b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-console-errors.ts @@ -0,0 +1,98 @@ +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; + +export interface XtreamRendererConsoleErrorBreakdown { + databaseSaveCancellation: number; + fetchContentCancellation: number; + initializationCancellation: number; + unexpected: number; +} + +type XtreamRendererConsoleErrorKind = keyof XtreamRendererConsoleErrorBreakdown; + +const CANCELLATION_SIGNATURE = 'Operation "save-content" was cancelled'; + +export function createXtreamRendererConsoleErrorBreakdown(): XtreamRendererConsoleErrorBreakdown { + return { + databaseSaveCancellation: 0, + fetchContentCancellation: 0, + initializationCancellation: 0, + unexpected: 0, + }; +} + +export function recordXtreamRendererConsoleError( + breakdown: XtreamRendererConsoleErrorBreakdown, + scenarioId: XtreamScenarioId, + message: string +): void { + const kind = classifyXtreamRendererConsoleError(scenarioId, message); + breakdown[kind] += 1; +} + +export function validXtreamRendererConsoleErrorEvidence( + total: number, + breakdown: XtreamRendererConsoleErrorBreakdown, + scenarioId: XtreamScenarioId +): boolean { + if ( + !Number.isSafeInteger(total) || + total < 0 || + !validBreakdownCounts(breakdown) || + total !== totalBreakdownCount(breakdown) + ) { + return false; + } + if (scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT) { + return ( + breakdown.databaseSaveCancellation === 1 && + breakdown.fetchContentCancellation === 1 && + breakdown.initializationCancellation === 1 && + breakdown.unexpected === 0 + ); + } + return total === 0; +} + +function classifyXtreamRendererConsoleError( + scenarioId: XtreamScenarioId, + message: string +): XtreamRendererConsoleErrorKind { + if ( + scenarioId !== XTREAM_SCENARIO_ID.CANCEL_IMPORT || + !message.includes(CANCELLATION_SIGNATURE) + ) { + return 'unexpected'; + } + if (message.startsWith('Error saving Xtream content:')) { + return 'databaseSaveCancellation'; + } + if (message.startsWith('[withContent] Error fetching content')) { + return 'fetchContentCancellation'; + } + if (message.startsWith('[withContent] Error initializing content')) { + return 'initializationCancellation'; + } + return 'unexpected'; +} + +function totalBreakdownCount( + breakdown: XtreamRendererConsoleErrorBreakdown +): number { + return ( + breakdown.databaseSaveCancellation + + breakdown.fetchContentCancellation + + breakdown.initializationCancellation + + breakdown.unexpected + ); +} + +function validBreakdownCounts( + breakdown: XtreamRendererConsoleErrorBreakdown +): boolean { + return Object.values(breakdown).every( + (value) => Number.isSafeInteger(value) && value >= 0 + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe-contract.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe-contract.ts new file mode 100644 index 000000000..a78c75cfe --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe-contract.ts @@ -0,0 +1,43 @@ +import type { RendererPerformancePhaseEvent } from '@iptvnator/shared/logging'; + +import type { XtreamScenarioId } from './xtream-benchmark-contract'; + +export const XTREAM_RENDERER_STATE_KEY = '__iptvnatorXtreamRendererCapture'; +export const XTREAM_RENDERER_STOPPED_KEY = + '__iptvnatorXtreamRendererCaptureStopped'; + +export interface XtreamRendererDbEvent { + readonly current: number | null; + readonly epochMs: number; + readonly increment: number | null; + readonly operation: string; + readonly operationId: string | null; + readonly phase: string | null; + readonly playlistId: string | null; + readonly status: string; + readonly total: number | null; +} + +export interface XtreamRendererProbeMetrics { + readonly cancelEligibleProgressEpochMs: number | null; + readonly cancellationClickEpochMs: number | null; + readonly dbCancellationTerminalEpochMs: number | null; + readonly dbOperationEvents: readonly XtreamRendererDbEvent[]; + readonly domReadyEpochMs: number | null; + readonly frameGapsMs: readonly number[]; + readonly heartbeatDelaysMs: readonly number[]; + readonly invalidReason: string | null; + readonly longTasksMs: readonly number[]; + readonly operationStartEpochMs: number; + readonly performancePhaseEvents: readonly RendererPerformancePhaseEvent[]; + readonly routeReadyEpochMs: number | null; + readonly scenarioId: XtreamScenarioId; + readonly storeTerminalEpochMs: number | null; + readonly terminalEpochMs: number | null; + readonly uiPaintedEpochMs: number | null; +} + +export interface XtreamRendererProbeOptions { + readonly scenarioId: XtreamScenarioId; + readonly sourceTitle: string; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe-control.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe-control.ts new file mode 100644 index 000000000..5e6b73cfb --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe-control.ts @@ -0,0 +1,288 @@ +import type { Page } from '@playwright/test'; +import { RENDERER_PERFORMANCE_PHASE_HOOK_KEY } from '@iptvnator/shared/logging'; + +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; +import { + XTREAM_RENDERER_STATE_KEY, + XTREAM_RENDERER_STOPPED_KEY, + type XtreamRendererProbeMetrics, +} from './xtream-renderer-probe-contract'; + +export async function beginXtreamRendererOperation( + page: Page +): Promise { + return page.evaluate((stateKey) => { + const state = (globalThis as unknown as Record)[ + stateKey + ] as + | { + operationStartEpochMs: number; + operationStartMonotonicMs: number; + } + | undefined; + if (!state || state.operationStartEpochMs > 0) { + throw new Error('xtream-renderer-probe-unavailable'); + } + state.operationStartMonotonicMs = performance.now(); + state.operationStartEpochMs = + performance.timeOrigin + state.operationStartMonotonicMs; + return state.operationStartEpochMs; + }, XTREAM_RENDERER_STATE_KEY); +} + +export async function recordXtreamRendererHeartbeat( + page: Page, + deadlineEpochMs: number +): Promise { + await page.evaluate( + ({ deadlineEpochMs, stateKey }) => { + const state = (globalThis as unknown as Record)[ + stateKey + ] as + | { + heartbeatDelaysMs: number[]; + operationStartEpochMs: number; + terminalEpochMs: number | null; + } + | undefined; + if (!state || state.operationStartEpochMs <= 0) { + throw new Error('xtream-renderer-probe-unavailable'); + } + if ( + deadlineEpochMs >= state.operationStartEpochMs && + (state.terminalEpochMs === null || + deadlineEpochMs <= state.terminalEpochMs) + ) { + const received = performance.timeOrigin + performance.now(); + state.heartbeatDelaysMs.push( + Math.max( + 0, + Math.min(received, state.terminalEpochMs ?? received) - + deadlineEpochMs + ) + ); + } + }, + { deadlineEpochMs, stateKey: XTREAM_RENDERER_STATE_KEY } + ); +} + +export async function assertXtreamBackgroundDeleteActive( + page: Page +): Promise { + await page.evaluate((stateKey) => { + const state = (globalThis as unknown as Record)[ + stateKey + ] as + | { + activeDeleteOperationId: string | null; + invalidReason: string | null; + } + | undefined; + if ( + !state || + state.invalidReason !== null || + typeof state.activeDeleteOperationId !== 'string' || + state.activeDeleteOperationId.length === 0 + ) { + throw new Error('xtream-background-delete-not-active'); + } + }, XTREAM_RENDERER_STATE_KEY); +} + +export async function assertXtreamBackgroundRefreshActive( + page: Page, + identity: { + readonly operationId: string; + readonly playlistId: string; + } +): Promise { + await page.evaluate( + ({ identity, stateKey }) => { + const state = (globalThis as unknown as Record)[ + stateKey + ] as + | { + dbOperationEvents: { + operation: string; + operationId: string | null; + playlistId: string | null; + status: string; + }[]; + invalidReason: string | null; + operationStartEpochMs: number; + scenarioId: string; + storeTerminalEpochMs: number | null; + terminalEpochMs: number | null; + } + | undefined; + const deleteEvents = + state?.dbOperationEvents.filter( + ({ operation }) => operation === 'delete-xtream-content' + ) ?? []; + const started = deleteEvents.filter( + ({ status }) => status === 'started' + ); + const terminal = deleteEvents.filter(({ status }) => + ['cancelled', 'completed', 'error'].includes(status) + ); + if ( + !state || + state.scenarioId !== 'xtream-background-ui' || + state.invalidReason !== null || + state.operationStartEpochMs <= 0 || + state.storeTerminalEpochMs !== null || + state.terminalEpochMs !== null || + started.length !== 1 || + terminal.length > 1 || + terminal.some(({ status }) => status !== 'completed') || + deleteEvents.some( + ({ operationId, playlistId }) => + operationId !== identity.operationId || + playlistId !== identity.playlistId + ) + ) { + throw new Error('xtream-background-refresh-not-active'); + } + }, + { identity, stateKey: XTREAM_RENDERER_STATE_KEY } + ); +} + +export async function waitForXtreamRendererTerminal(page: Page): Promise { + await page.waitForFunction( + (stateKey) => { + const state = (globalThis as unknown as Record)[ + stateKey + ] as + | { + invalidReason: string | null; + terminalEpochMs: number | null; + } + | undefined; + return ( + typeof state?.terminalEpochMs === 'number' || + typeof state?.invalidReason === 'string' + ); + }, + XTREAM_RENDERER_STATE_KEY, + { timeout: 120_000 } + ); + await page.evaluate((stateKey) => { + const state = (globalThis as unknown as Record)[ + stateKey + ] as { invalidReason: string | null } | undefined; + if (!state) throw new Error('xtream-renderer-probe-unavailable'); + if (state.invalidReason) throw new Error(state.invalidReason); + }, XTREAM_RENDERER_STATE_KEY); +} + +export async function stopXtreamRendererProbe( + page: Page +): Promise { + return page.evaluate( + ({ hookKey, stateKey, stoppedKey }) => { + const target = globalThis as unknown as Record< + PropertyKey, + unknown + >; + const previous = target[stoppedKey] as + XtreamRendererProbeMetrics | undefined; + if (previous) return previous; + const state = target[stateKey] as + | (XtreamRendererProbeMetrics & { + dbUnsubscribe: (() => void) | null; + frameRequestId: number; + longTaskObserver: PerformanceObserver | null; + recordLongTask(entry: PerformanceEntry): void; + removeCancelClickListener: (() => void) | null; + }) + | undefined; + if (!state) { + throw new Error('xtream-renderer-probe-unavailable'); + } + cancelAnimationFrame(state.frameRequestId); + for (const entry of state.longTaskObserver?.takeRecords() ?? []) { + state.recordLongTask(entry); + } + state.longTaskObserver?.disconnect(); + state.dbUnsubscribe?.(); + state.removeCancelClickListener?.(); + const snapshot: XtreamRendererProbeMetrics = { + cancelEligibleProgressEpochMs: + state.cancelEligibleProgressEpochMs, + cancellationClickEpochMs: state.cancellationClickEpochMs, + dbCancellationTerminalEpochMs: + state.dbCancellationTerminalEpochMs, + dbOperationEvents: [...state.dbOperationEvents], + domReadyEpochMs: state.domReadyEpochMs, + frameGapsMs: [...state.frameGapsMs], + heartbeatDelaysMs: [...state.heartbeatDelaysMs], + invalidReason: state.invalidReason, + longTasksMs: [...state.longTasksMs], + operationStartEpochMs: state.operationStartEpochMs, + performancePhaseEvents: [...state.performancePhaseEvents], + routeReadyEpochMs: state.routeReadyEpochMs, + scenarioId: state.scenarioId, + storeTerminalEpochMs: state.storeTerminalEpochMs, + terminalEpochMs: state.terminalEpochMs, + uiPaintedEpochMs: state.uiPaintedEpochMs, + }; + target[stoppedKey] = snapshot; + delete target[stateKey]; + delete target[Symbol.for(hookKey)]; + return snapshot; + }, + { + hookKey: RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + stateKey: XTREAM_RENDERER_STATE_KEY, + stoppedKey: XTREAM_RENDERER_STOPPED_KEY, + } + ); +} + +export function assertCompleteXtreamRendererProbe( + probe: XtreamRendererProbeMetrics | null | undefined +): asserts probe is XtreamRendererProbeMetrics & { + readonly storeTerminalEpochMs: number; + readonly terminalEpochMs: number; + readonly uiPaintedEpochMs: number; +} { + const scenarios = new Set(Object.values(XTREAM_SCENARIO_ID)); + if ( + !probe || + !scenarios.has(probe.scenarioId) || + probe.invalidReason !== null || + !positive(probe.operationStartEpochMs) || + !positive(probe.routeReadyEpochMs) || + !positive(probe.storeTerminalEpochMs) || + !positive(probe.domReadyEpochMs) || + !positive(probe.uiPaintedEpochMs) || + !positive(probe.terminalEpochMs) || + probe.storeTerminalEpochMs < probe.operationStartEpochMs || + probe.routeReadyEpochMs < probe.operationStartEpochMs || + probe.domReadyEpochMs < probe.storeTerminalEpochMs || + probe.uiPaintedEpochMs < probe.storeTerminalEpochMs || + probe.uiPaintedEpochMs < probe.domReadyEpochMs || + probe.terminalEpochMs < probe.uiPaintedEpochMs + ) { + throw new Error('xtream-renderer-probe-incomplete'); + } + if ( + probe.scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT && + (!positive(probe.cancelEligibleProgressEpochMs) || + !positive(probe.cancellationClickEpochMs) || + !positive(probe.dbCancellationTerminalEpochMs) || + probe.cancellationClickEpochMs < + probe.cancelEligibleProgressEpochMs || + probe.dbCancellationTerminalEpochMs < + probe.cancellationClickEpochMs || + probe.uiPaintedEpochMs < probe.dbCancellationTerminalEpochMs) + ) { + throw new Error('xtream-renderer-probe-incomplete'); + } +} + +function positive(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.spec.ts new file mode 100644 index 000000000..99047cbba --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.spec.ts @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; + +interface ProbeModule { + XTREAM_RENDERER_STATE_KEY?: string; + assertCompleteXtreamRendererProbe?: (value: unknown) => void; + assertXtreamBackgroundDeleteActive?: (page: unknown) => Promise; + assertXtreamBackgroundRefreshActive?: ( + page: unknown, + identity: { + readonly operationId: string; + readonly playlistId: string; + } + ) => Promise; + beginXtreamRendererOperation?: (page: unknown) => Promise; + installXtreamRendererProbe?: ( + page: unknown, + options: { + readonly scenarioId: string; + readonly sourceTitle: string; + } + ) => Promise; + recordXtreamRendererHeartbeat?: ( + page: unknown, + deadlineEpochMs: number + ) => Promise; + stopXtreamRendererProbe?: (page: unknown) => Promise; + waitForXtreamRendererTerminal?: (page: unknown) => Promise; +} + +const probeUrl = new URL('./xtream-renderer-probe.ts', import.meta.url); +const modulePromise = import(probeUrl.href) + .then((module) => module as unknown as ProbeModule) + .catch(() => null); + +test('exports the fail-closed Xtream renderer probe lifecycle', async () => { + const module = await modulePromise; + + assert.ok(module, 'Xtream renderer probe module must exist'); + assert.equal(typeof module.XTREAM_RENDERER_STATE_KEY, 'string'); + assert.equal(typeof module.installXtreamRendererProbe, 'function'); + assert.equal(typeof module.beginXtreamRendererOperation, 'function'); + assert.equal(typeof module.recordXtreamRendererHeartbeat, 'function'); + assert.equal(typeof module.waitForXtreamRendererTerminal, 'function'); + assert.equal(typeof module.stopXtreamRendererProbe, 'function'); + assert.equal(typeof module.assertCompleteXtreamRendererProbe, 'function'); + assert.equal(typeof module.assertXtreamBackgroundDeleteActive, 'function'); + assert.equal(typeof module.assertXtreamBackgroundRefreshActive, 'function'); +}); + +test('passively correlates the driver-owned cancel click with live DB progress', () => { + const source = readFileSync(probeUrl, 'utf8'); + + assert.match(source, /RENDERER_PERFORMANCE_PHASE_HOOK_KEY/); + assert.match(source, /onDbOperationEvent/); + assert.doesNotMatch(source, /event\.error/); + assert.match(source, /Math\.max\(\s*100,[\s\S]*Math\.floor/); + assert.match(source, /workspace-loading-overlay__action/); + assert.match(source, /addEventListener\('click'/); + assert.doesNotMatch(source, /\.click\(\)/); + assert.match(source, /Math\.min\(/); + assert.match(source, /requestAnimationFrame/); + assert.match(source, /longtask/); +}); + +test('requires a final store marker, DOM gate, and two rendered frames', () => { + const source = readFileSync(probeUrl, 'utf8'); + + assert.match(source, /store\.xtream-import-terminal/); + assert.match(source, /routeHydratesAfterImport/); + assert.match(source, /requiredStoreTerminalCount/); + assert.match(source, /routeHydratesAfterImport\s*\?\s*2\s*:\s*1/); + assert.match(source, /store\.xtream-delete-row/); + assert.match(source, /workspace-loading-overlay/); + assert.match(source, /xtream-content-gate/); + assert.match(source, /catalog/); + assert.match(source, /paintFrameCount/); + assert.match(source, />=\s*2/); +}); + +test('rejects incomplete and internally inconsistent terminal evidence', async () => { + const module = await modulePromise; + assert.ok(module?.assertCompleteXtreamRendererProbe); + const valid = completeProbe(); + + assert.doesNotThrow(() => + module.assertCompleteXtreamRendererProbe?.(valid) + ); + assert.throws( + () => + module.assertCompleteXtreamRendererProbe?.({ + ...valid, + terminalEpochMs: null, + }), + /xtream-renderer-probe-incomplete/ + ); + assert.throws( + () => + module.assertCompleteXtreamRendererProbe?.({ + ...valid, + uiPaintedEpochMs: 90, + }), + /xtream-renderer-probe-incomplete/ + ); +}); + +test('keeps a completed delete inside the identified refresh lifecycle until store terminal', async () => { + const module = await modulePromise; + assert.ok(module?.assertXtreamBackgroundRefreshActive); + assert.ok(module.XTREAM_RENDERER_STATE_KEY); + const stateKey = module.XTREAM_RENDERER_STATE_KEY; + const target = globalThis as unknown as Record; + const identity = { + operationId: 'delete-operation-1', + playlistId: 'playlist-1', + }; + const page = { + evaluate: async ( + callback: (argument: unknown) => unknown, + argument: unknown + ) => callback(argument), + }; + target[stateKey] = backgroundRefreshState(identity); + try { + await assert.doesNotReject(() => + module.assertXtreamBackgroundRefreshActive?.(page, identity) + ); + target[stateKey] = { + ...backgroundRefreshState(identity), + storeTerminalEpochMs: 200, + }; + await assert.rejects( + () => + module.assertXtreamBackgroundRefreshActive?.(page, identity) ?? + Promise.resolve(), + /xtream-background-refresh-not-active/ + ); + target[stateKey] = backgroundRefreshState({ + ...identity, + operationId: 'different-operation', + }); + await assert.rejects( + () => + module.assertXtreamBackgroundRefreshActive?.(page, identity) ?? + Promise.resolve(), + /xtream-background-refresh-not-active/ + ); + } finally { + delete target[stateKey]; + } +}); + +function completeProbe(): Record { + return { + cancellationClickEpochMs: null, + cancelEligibleProgressEpochMs: null, + dbCancellationTerminalEpochMs: null, + dbOperationEvents: [], + domReadyEpochMs: 150, + frameGapsMs: [16], + heartbeatDelaysMs: [2], + invalidReason: null, + longTasksMs: [], + operationStartEpochMs: 100, + performancePhaseEvents: [ + { + boundary: 'end', + epochMs: 140, + monotonicMs: 40, + outcome: 'success', + phase: 'store.xtream-import-terminal', + phaseId: 'terminal-1', + schemaVersion: 1, + }, + ], + routeReadyEpochMs: 120, + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + storeTerminalEpochMs: 140, + terminalEpochMs: 160, + uiPaintedEpochMs: 160, + }; +} + +function backgroundRefreshState(identity: { + readonly operationId: string; + readonly playlistId: string; +}): Record { + return { + dbOperationEvents: [ + { + operation: 'delete-xtream-content', + ...identity, + status: 'started', + }, + { + operation: 'delete-xtream-content', + ...identity, + status: 'completed', + }, + ], + invalidReason: null, + operationStartEpochMs: 100, + scenarioId: XTREAM_SCENARIO_ID.BACKGROUND_UI, + storeTerminalEpochMs: null, + terminalEpochMs: null, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts new file mode 100644 index 000000000..a402b593e --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts @@ -0,0 +1,409 @@ +/* eslint-disable max-lines -- Playwright serializes this self-contained renderer probe; external helpers would be unavailable in page context. */ +import type { Page } from '@playwright/test'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, + type RendererPerformancePhaseHook, +} from '@iptvnator/shared/logging'; + +import type { XtreamScenarioId } from './xtream-benchmark-contract'; +import { + XTREAM_RENDERER_STATE_KEY, + XTREAM_RENDERER_STOPPED_KEY, + type XtreamRendererDbEvent, + type XtreamRendererProbeOptions, +} from './xtream-renderer-probe-contract'; + +export * from './xtream-renderer-probe-contract'; + +export async function installXtreamRendererProbe( + page: Page, + options: XtreamRendererProbeOptions +): Promise { + await page.evaluate( + ({ hookKey, scenarioId, sourceTitle, stateKey, stoppedKey }) => { + interface DbEvent { + current?: number; + increment?: number; + operation?: string; + operationId?: string; + phase?: string; + playlistId?: string; + status?: string; + total?: number; + } + interface State { + activeDeleteOperationId: string | null; + cancelEligibleProgressEpochMs: number | null; + cancellationClickEpochMs: number | null; + dbCancellationTerminalEpochMs: number | null; + dbOperationEvents: XtreamRendererDbEvent[]; + dbUnsubscribe: (() => void) | null; + domReadyEpochMs: number | null; + frameGapsMs: number[]; + frameRequestId: number; + heartbeatDelaysMs: number[]; + invalidReason: string | null; + lastFrameAt: number | null; + longTaskObserver: PerformanceObserver | null; + longTasksMs: number[]; + operationStartEpochMs: number; + operationStartMonotonicMs: number; + paintFrameCount: number; + performancePhaseEvents: RendererPerformancePhaseEvent[]; + recordLongTask(entry: PerformanceEntry): void; + removeCancelClickListener: (() => void) | null; + routeReadyEpochMs: number | null; + saveContentOperationId: string | null; + scenarioId: XtreamScenarioId; + sourceTitle: string; + storeTerminalCount: number; + storeTerminalEpochMs: number | null; + terminalEpochMs: number | null; + uiPaintedEpochMs: number | null; + } + const target = globalThis as unknown as Record< + PropertyKey, + unknown + >; + const hookSymbol = Symbol.for(hookKey); + if ( + target[stateKey] !== undefined || + target[hookSymbol] !== undefined + ) { + throw new Error('xtream-renderer-probe-already-installed'); + } + delete target[stoppedKey]; + const epoch = (): number => + performance.timeOrigin + performance.now(); + const numberOrNull = (value: unknown): number | null => + typeof value === 'number' && + Number.isFinite(value) && + value >= 0 + ? value + : null; + const stringOrNull = (value: unknown): string | null => + typeof value === 'string' && value.length > 0 ? value : null; + const state: State = { + activeDeleteOperationId: null, + cancelEligibleProgressEpochMs: null, + cancellationClickEpochMs: null, + dbCancellationTerminalEpochMs: null, + dbOperationEvents: [], + dbUnsubscribe: null, + domReadyEpochMs: null, + frameGapsMs: [], + frameRequestId: 0, + heartbeatDelaysMs: [], + invalidReason: null, + lastFrameAt: null, + longTaskObserver: null, + longTasksMs: [], + operationStartEpochMs: 0, + operationStartMonotonicMs: 0, + paintFrameCount: 0, + performancePhaseEvents: [], + recordLongTask: (entry) => { + const entryEpochMs = + performance.timeOrigin + entry.startTime; + const measuredStartEpochMs = Math.max( + entryEpochMs, + state.operationStartEpochMs + ); + const measuredEndEpochMs = Math.min( + entryEpochMs + entry.duration, + state.terminalEpochMs ?? Number.POSITIVE_INFINITY + ); + if ( + state.operationStartEpochMs > 0 && + measuredEndEpochMs > measuredStartEpochMs + ) { + state.longTasksMs.push( + measuredEndEpochMs - measuredStartEpochMs + ); + } + }, + removeCancelClickListener: null, + routeReadyEpochMs: null, + saveContentOperationId: null, + scenarioId, + sourceTitle, + storeTerminalCount: 0, + storeTerminalEpochMs: null, + terminalEpochMs: null, + uiPaintedEpochMs: null, + }; + const routeHydratesAfterImport = + scenarioId === 'xtream-refresh-large' || + scenarioId === 'xtream-background-ui'; + const requiredStoreTerminalCount = routeHydratesAfterImport ? 2 : 1; + const isVisible = (element: Element | null): boolean => + element instanceof HTMLElement && + element.getClientRects().length > 0; + const catalogVisible = (): boolean => + isVisible( + document.querySelector( + 'app-workspace-context-panel .category-item, ' + + '.content-card, [data-test-id="channel-item"], mat-card' + ) + ); + const overlayGone = (): boolean => + !isVisible( + document.querySelector('.workspace-loading-overlay') + ); + const sourceRowAbsent = (): boolean => + !Array.from( + document.querySelectorAll('app-playlist-item') + ).some( + (row) => + row + .querySelector('.playlist-title') + ?.textContent?.trim() === state.sourceTitle + ); + const terminalDomGate = (): boolean => { + if ( + state.storeTerminalEpochMs === null || + !overlayGone() || + state.invalidReason !== null + ) { + return false; + } + if (scenarioId === 'xtream-cancel-import') { + return ( + location.pathname.includes('/workspace/xtreams/') && + state.dbCancellationTerminalEpochMs !== null && + isVisible( + document.querySelector('.xtream-content-gate') + ) + ); + } + if (scenarioId === 'xtream-delete-large') { + return ( + sourceRowAbsent() && + !location.pathname.includes('/workspace/xtreams/') + ); + } + return ( + location.pathname.includes('/workspace/xtreams/') && + catalogVisible() + ); + }; + const phaseHook: RendererPerformancePhaseHook = (event) => { + state.performancePhaseEvents.push(event); + const requiredPhase = + scenarioId === 'xtream-delete-large' + ? 'store.xtream-delete-row' + : 'store.xtream-import-terminal'; + if ( + state.operationStartEpochMs > 0 && + event.boundary === 'end' && + event.outcome === 'success' && + event.phase === requiredPhase + ) { + state.storeTerminalCount += 1; + if ( + state.storeTerminalCount === requiredStoreTerminalCount + ) { + state.storeTerminalEpochMs = event.epochMs; + } else if ( + state.storeTerminalCount > requiredStoreTerminalCount + ) { + state.invalidReason ??= 'duplicate-store-terminal'; + } + } + }; + target[hookSymbol] = phaseHook; + const onDbEvent = (event: DbEvent): void => { + const sampledEpochMs = epoch(); + const operation = stringOrNull(event.operation); + const status = stringOrNull(event.status); + if (operation === null || status === null) { + state.invalidReason ??= 'invalid-db-operation-event'; + return; + } + const sanitized: XtreamRendererDbEvent = { + current: numberOrNull(event.current), + epochMs: sampledEpochMs, + increment: numberOrNull(event.increment), + operation, + operationId: stringOrNull(event.operationId), + phase: stringOrNull(event.phase), + playlistId: stringOrNull(event.playlistId), + status, + total: numberOrNull(event.total), + }; + state.dbOperationEvents.push(sanitized); + if (operation === 'delete-xtream-content') { + if (status === 'started' || status === 'progress') { + state.activeDeleteOperationId ??= sanitized.operationId; + if ( + state.activeDeleteOperationId === null || + sanitized.operationId !== + state.activeDeleteOperationId + ) { + state.invalidReason ??= + 'ambiguous-delete-xtream-content'; + } + } else if ( + sanitized.operationId === + state.activeDeleteOperationId && + ['cancelled', 'completed', 'error'].includes(status) + ) { + state.activeDeleteOperationId = null; + } + } + if ( + scenarioId !== 'xtream-cancel-import' || + operation !== 'save-content' || + state.operationStartEpochMs <= 0 + ) { + return; + } + if (status === 'started' || status === 'progress') { + state.saveContentOperationId ??= sanitized.operationId; + if ( + state.saveContentOperationId === null || + sanitized.operationId !== state.saveContentOperationId + ) { + state.invalidReason ??= 'ambiguous-save-content'; + return; + } + } + if ( + state.cancellationClickEpochMs !== null && + (status === 'completed' || status === 'started') + ) { + state.invalidReason ??= 'save-content-after-cancel'; + return; + } + if (status === 'cancelled') { + if ( + sanitized.operationId !== state.saveContentOperationId + ) { + state.invalidReason ??= 'ambiguous-save-content'; + return; + } + state.dbCancellationTerminalEpochMs = sampledEpochMs; + return; + } + const total = sanitized.total; + const current = sanitized.current; + if ( + status !== 'progress' || + total === null || + current === null || + current < Math.max(100, Math.floor(total * 0.1)) || + state.cancelEligibleProgressEpochMs !== null + ) { + return; + } + state.cancelEligibleProgressEpochMs = sampledEpochMs; + }; + const onCancelClick = (event: Event): void => { + const clicked = event.target; + if ( + scenarioId !== 'xtream-cancel-import' || + !(clicked instanceof Element) || + clicked.closest('.workspace-loading-overlay__action') === + null + ) { + return; + } + if ( + state.operationStartEpochMs <= 0 || + state.cancelEligibleProgressEpochMs === null + ) { + state.invalidReason ??= 'cancel-click-before-progress'; + return; + } + if (state.cancellationClickEpochMs !== null) { + state.invalidReason ??= 'duplicate-cancel-click'; + return; + } + state.cancellationClickEpochMs = epoch(); + }; + document.addEventListener('click', onCancelClick, true); + state.removeCancelClickListener = () => + document.removeEventListener('click', onCancelClick, true); + const bridge = ( + globalThis as unknown as { + electron?: { + onDbOperationEvent?: ( + listener: (event: DbEvent) => void + ) => () => void; + }; + } + ).electron; + if (typeof bridge?.onDbOperationEvent !== 'function') { + throw new Error('xtream-db-operation-listener-unavailable'); + } + state.dbUnsubscribe = bridge.onDbOperationEvent(onDbEvent); + const frame = (timestamp: number): void => { + if ( + state.lastFrameAt !== null && + state.operationStartEpochMs > 0 && + state.terminalEpochMs === null + ) { + const start = Math.max( + state.lastFrameAt, + state.operationStartMonotonicMs + ); + if (timestamp > start) + state.frameGapsMs.push(timestamp - start); + } + state.lastFrameAt = timestamp; + const routeReady = + scenarioId === 'xtream-delete-large' + ? location.pathname.endsWith('/workspace/sources') + : location.pathname.includes('/workspace/xtreams/'); + if (state.operationStartEpochMs > 0 && routeReady) { + state.routeReadyEpochMs ??= epoch(); + } + if (state.operationStartEpochMs > 0 && terminalDomGate()) { + state.domReadyEpochMs ??= epoch(); + state.paintFrameCount += 1; + if (state.paintFrameCount >= 2) { + state.uiPaintedEpochMs = epoch(); + state.terminalEpochMs = state.uiPaintedEpochMs; + } + } else { + state.paintFrameCount = 0; + } + state.frameRequestId = + state.terminalEpochMs === null + ? requestAnimationFrame(frame) + : 0; + }; + state.frameRequestId = requestAnimationFrame(frame); + if ( + typeof PerformanceObserver === 'function' && + PerformanceObserver.supportedEntryTypes.includes('longtask') + ) { + state.longTaskObserver = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + state.recordLongTask(entry); + } + }); + state.longTaskObserver.observe({ entryTypes: ['longtask'] }); + } + target[stateKey] = state; + }, + { + hookKey: RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + scenarioId: options.scenarioId, + sourceTitle: options.sourceTitle, + stateKey: XTREAM_RENDERER_STATE_KEY, + stoppedKey: XTREAM_RENDERER_STOPPED_KEY, + } + ); +} + +export { + assertCompleteXtreamRendererProbe, + assertXtreamBackgroundDeleteActive, + assertXtreamBackgroundRefreshActive, + beginXtreamRendererOperation, + recordXtreamRendererHeartbeat, + stopXtreamRendererProbe, + waitForXtreamRendererTerminal, +} from './xtream-renderer-probe-control'; diff --git a/apps/electron-backend-e2e/src/performance/xtream-safe-file-read.ts b/apps/electron-backend-e2e/src/performance/xtream-safe-file-read.ts new file mode 100644 index 000000000..3236e38b5 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-safe-file-read.ts @@ -0,0 +1,88 @@ +import { constants, type BigIntStats } from 'node:fs'; +import { lstat, open, realpath, type FileHandle } from 'node:fs/promises'; + +export interface XtreamSafeFileSnapshot { + readonly content: Buffer; + readonly realPath: string; +} + +const UNSAFE_FILE_ERROR = 'unsafe-xtream-evidence-file'; + +export async function readXtreamSafeRegularFile( + path: string +): Promise { + let handle: FileHandle | undefined; + try { + const pathBefore = await lstat(path, { bigint: true }); + assertSafeRegularFile(pathBefore); + handle = await open( + path, + constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) + ); + const handleBefore = await handle.stat({ bigint: true }); + assertSafeRegularFile(handleBefore); + assertSameFile(pathBefore, handleBefore); + + const content = await handle.readFile(); + const handleAfter = await handle.stat({ bigint: true }); + assertSafeRegularFile(handleAfter); + assertUnchanged(handleBefore, handleAfter); + + const pathAfter = await lstat(path, { bigint: true }); + assertSafeRegularFile(pathAfter); + assertUnchanged(handleBefore, pathAfter); + const canonicalPath = await realpath(path); + const canonicalAfter = await lstat(canonicalPath, { bigint: true }); + assertSafeRegularFile(canonicalAfter); + assertUnchanged(handleBefore, canonicalAfter); + const pathFinal = await lstat(path, { bigint: true }); + assertSafeRegularFile(pathFinal); + assertUnchanged(handleBefore, pathFinal); + + return { content, realPath: canonicalPath }; + } catch { + throw new Error(UNSAFE_FILE_ERROR); + } finally { + await closeHandle(handle); + } +} + +export function isXtreamUnsafeFileError(error: unknown): boolean { + return error instanceof Error && error.message === UNSAFE_FILE_ERROR; +} + +function assertSafeRegularFile(stats: BigIntStats): void { + if ( + !stats.isFile() || + stats.isSymbolicLink() || + stats.nlink !== BigInt(1) + ) { + throw new Error(UNSAFE_FILE_ERROR); + } +} + +function assertSameFile(left: BigIntStats, right: BigIntStats): void { + if (left.dev !== right.dev || left.ino !== right.ino) { + throw new Error(UNSAFE_FILE_ERROR); + } +} + +function assertUnchanged(before: BigIntStats, after: BigIntStats): void { + assertSameFile(before, after); + if ( + before.size !== after.size || + before.mtimeNs !== after.mtimeNs || + before.ctimeNs !== after.ctimeNs + ) { + throw new Error(UNSAFE_FILE_ERROR); + } +} + +async function closeHandle(handle: FileHandle | undefined): Promise { + if (!handle) return; + try { + await handle.close(); + } catch { + throw new Error(UNSAFE_FILE_ERROR); + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-safe-path-identifier.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-safe-path-identifier.spec.ts new file mode 100644 index 000000000..613444345 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-safe-path-identifier.spec.ts @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { XTREAM_MEMORY_ACCOUNTING } from './xtream-summary-contract'; +import { BUILD_IDENTITY } from './xtream-benchmark-report.fixtures'; +import { + assertXtreamBenchmarkManifest, + assertXtreamSafePathIdentifier, +} from './xtream-exact-schema'; + +describe('Xtream safe path identifiers', () => { + it('accepts portable benchmark path segments', () => { + for (const identifier of [ + 'run-01', + 'diagnostic', + 'xtream-refresh-large', + 'baseline.v1', + ]) { + assert.doesNotThrow(() => + assertXtreamSafePathIdentifier(identifier, 'run-id') + ); + } + }); + + it('rejects traversal, trailing dots, and Windows device basenames', () => { + for (const identifier of [ + '', + '.', + '..', + '../../outside', + 'run/02', + 'run\\02', + 'drive:stream', + 'run.', + 'CON', + 'con.json', + 'PRN.log', + 'AUX', + 'nul.txt', + 'COM1', + 'com9.cpuprofile', + 'LPT1', + 'lpt9.trace.json', + ]) { + assert.throws( + () => assertXtreamSafePathIdentifier(identifier, 'run-id'), + /^Error: benchmark requires a safe Xtream run-id$/ + ); + } + }); + + it('applies the same boundary to manifest variants before path use', () => { + assert.throws( + () => + assertXtreamBenchmarkManifest({ + build: BUILD_IDENTITY, + environment: { + cdpAddress: '127.0.0.1', + cdpPort: 9222, + databaseWorkerBatchDelayMs: 0, + exposeGc: true, + freshUserDataPerIteration: true, + perfCapture: '1', + perfWorkerProfiling: '1', + traceRendererConsole: '0', + }, + fixture: { + bytes: 1, + catalogSha256: 'a'.repeat(64), + categories: 100, + items: 100_000, + scenario: 'performance-100k', + seed: 91_001, + }, + memoryAccounting: XTREAM_MEMORY_ACCOUNTING, + mode: 'formal', + runtime: { + arch: 'arm64', + electronVersion: '38.0.0', + harnessNodeVersion: '22.0.0', + platform: 'darwin', + }, + source: { + gitCommit: 'a'.repeat(40), + gitDiffSha256: 'b'.repeat(64), + gitDirty: false, + }, + suite: 'xtream-performance-100k', + variant: 'CON.json', + }), + /^Error: invalid Xtream benchmark manifest$/ + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-cancellation-observer.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-cancellation-observer.ts new file mode 100644 index 000000000..41b9fb348 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-cancellation-observer.ts @@ -0,0 +1,268 @@ +import type { Page } from '@playwright/test'; + +const CANCELLATION_STATE_KEY = '__iptvnatorXtreamScenarioCancellationObserver'; +const OPERATION_TIMEOUT_MS = 120_000; + +export interface XtreamDbOperationEventLike { + readonly current?: number; + readonly operation?: string; + readonly operationId?: string; + readonly status?: string; + readonly total?: number; +} + +export interface XtreamCancellationEvidence { + readonly armedEpochMs: number; + readonly clickEpochMs: number; + readonly current: number; + readonly operationId: string; + readonly progressEpochMs: number; + readonly threshold: number; + readonly total: number; +} + +export interface XtreamCancellationObserver { + dispose(): Promise; + waitForClick(): Promise; +} + +export function xtreamCancellationThreshold(total: number): number { + if (!Number.isFinite(total) || total <= 0) { + throw new Error('xtream-cancellation-total-invalid'); + } + return Math.max(100, Math.floor(total * 0.1)); +} + +export function isXtreamCancellationProgress(event: unknown): boolean { + if (!isRecord(event)) return false; + const current = event['current']; + const operationId = event['operationId']; + const total = event['total']; + return ( + event['operation'] === 'save-content' && + event['status'] === 'progress' && + typeof current === 'number' && + Number.isSafeInteger(current) && + typeof operationId === 'string' && + operationId.length > 0 && + typeof total === 'number' && + Number.isSafeInteger(total) && + total > 0 && + current <= total && + current >= xtreamCancellationThreshold(total) + ); +} + +export async function installXtreamCancellationObserver( + page: Page +): Promise { + await page.evaluate( + ({ command, stateKey }) => { + interface State { + armedEpochMs: number; + clickEpochMs: number | null; + current: number | null; + failure: string | null; + operationId: string | null; + progressEpochMs: number | null; + threshold: number | null; + total: number | null; + unsubscribe: () => void; + } + const target = globalThis as unknown as Record; + if (command !== 'install-cancellation' || target[stateKey]) { + throw new Error('xtream-cancellation-observer-state-invalid'); + } + const electron = target['electron'] as + | { + onDbOperationEvent?: ( + callback: (event: Record) => void + ) => () => void; + } + | undefined; + if (typeof electron?.onDbOperationEvent !== 'function') { + throw new Error('xtream-db-operation-listener-unavailable'); + } + const epoch = (): number => + performance.timeOrigin + performance.now(); + const state: State = { + armedEpochMs: epoch(), + clickEpochMs: null, + current: null, + failure: null, + operationId: null, + progressEpochMs: null, + threshold: null, + total: null, + unsubscribe: () => undefined, + }; + state.unsubscribe = electron.onDbOperationEvent((event) => { + if (state.clickEpochMs !== null || state.failure !== null) { + return; + } + const current = event['current']; + const total = event['total']; + if ( + event['operation'] !== 'save-content' || + event['status'] !== 'progress' || + typeof current !== 'number' || + !Number.isSafeInteger(current) || + typeof total !== 'number' || + !Number.isSafeInteger(total) || + total <= 0 || + current > total + ) { + return; + } + const threshold = Math.max(100, Math.floor(total * 0.1)); + if (current < threshold) return; + state.current = current; + state.total = total; + state.threshold = threshold; + state.progressEpochMs = epoch(); + const operationId = event['operationId']; + if ( + typeof operationId !== 'string' || + operationId.length === 0 + ) { + state.failure = 'xtream-save-content-identity-missing'; + return; + } + state.operationId = operationId; + const button = document.querySelector( + '.workspace-loading-overlay__action' + ); + if ( + !(button instanceof HTMLButtonElement) || + !button.isConnected + ) { + state.failure = 'xtream-cancel-button-missing'; + return; + } + if (button.disabled) { + state.failure = 'xtream-cancel-button-disabled'; + return; + } + state.clickEpochMs = epoch(); + button.click(); + }); + target[stateKey] = state; + }, + { + command: 'install-cancellation', + stateKey: CANCELLATION_STATE_KEY, + } + ); + return { + dispose: () => disposeCancellationState(page), + async waitForClick() { + await page.waitForFunction( + ({ stateKey }) => { + const target = globalThis as unknown as Record< + string, + unknown + >; + const state = target[stateKey] as + | { + clickEpochMs?: number | null; + failure?: string | null; + } + | undefined; + return Boolean( + state && + (typeof state.clickEpochMs === 'number' || + typeof state.failure === 'string') + ); + }, + { + stateKey: CANCELLATION_STATE_KEY, + waitFor: 'cancellation-click', + }, + { timeout: OPERATION_TIMEOUT_MS } + ); + return readCancellationEvidence(page); + }, + }; +} + +async function readCancellationEvidence( + page: Page +): Promise { + const value = await page.evaluate( + ({ stateKey }) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + Record | undefined; + if (!state) throw new Error('xtream-cancellation-observer-missing'); + if (state['failure']) throw new Error(String(state['failure'])); + return { + armedEpochMs: state['armedEpochMs'], + clickEpochMs: state['clickEpochMs'], + current: state['current'], + operationId: state['operationId'], + progressEpochMs: state['progressEpochMs'], + threshold: state['threshold'], + total: state['total'], + }; + }, + { command: 'snapshot-cancellation', stateKey: CANCELLATION_STATE_KEY } + ); + if (!isCancellationEvidence(value)) { + throw new Error('xtream-cancellation-evidence-invalid'); + } + return value; +} + +async function disposeCancellationState(page: Page): Promise { + await page.evaluate( + ({ stateKey }) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + { unsubscribe?: () => void } | undefined; + state?.unsubscribe?.(); + delete target[stateKey]; + }, + { command: 'dispose-observer', stateKey: CANCELLATION_STATE_KEY } + ); +} + +function isCancellationEvidence( + value: unknown +): value is XtreamCancellationEvidence { + if (!isRecord(value)) return false; + const armedEpochMs = value['armedEpochMs']; + const clickEpochMs = value['clickEpochMs']; + const current = value['current']; + const operationId = value['operationId']; + const progressEpochMs = value['progressEpochMs']; + const threshold = value['threshold']; + const total = value['total']; + return ( + finitePositive(armedEpochMs) && + finitePositive(progressEpochMs) && + finitePositive(clickEpochMs) && + armedEpochMs <= progressEpochMs && + progressEpochMs <= clickEpochMs && + typeof operationId === 'string' && + operationId.length > 0 && + typeof total === 'number' && + Number.isSafeInteger(total) && + total > 0 && + typeof current === 'number' && + Number.isSafeInteger(current) && + current >= 0 && + current <= total && + typeof threshold === 'number' && + Number.isSafeInteger(threshold) && + threshold === xtreamCancellationThreshold(total) && + current >= threshold + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function finitePositive(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts new file mode 100644 index 000000000..abaa38811 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver-wiring.spec.ts @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, it } from 'node:test'; + +const source = (name: string): string => + readFileSync(resolve(process.cwd(), 'src/performance', name), 'utf8'); + +describe('Xtream scenario driver source wiring', () => { + it('keeps each scenario module below the repository line limit', () => { + for (const name of [ + 'xtream-scenario-cancellation-observer.ts', + 'xtream-scenario-driver.ts', + 'xtream-scenario-driver.fake-page.ts', + 'xtream-scenario-operation-observer.ts', + 'xtream-scenario-ui.ts', + ]) { + assert.ok( + source(name).split('\n').length <= 400, + `${name} must stay at or below 400 lines` + ); + } + }); + + it('uses exact resilient UI selectors and deterministic terminal gates', () => { + const ui = source('xtream-scenario-ui.ts'); + + for (const selector of [ + "'Add playlist'", + '\'mat-button-toggle[value="xtream"]\'', + "'#title'", + "'#serverUrl'", + "'#username'", + "'#password'", + "'app-playlist-item'", + "'.refresh-btn'", + "'.delete-btn'", + "'Yes'", + "'.busy-state__message'", + "'.action-spinner'", + "'.cancel-btn'", + "'.workspace-loading-overlay'", + ]) { + assert.ok(ui.includes(selector), `missing ${selector}`); + } + assert.match(ui, /xtream.*credentials/i); + assert.match(ui, /name:\s*'Add',\s*exact:\s*true/); + assert.match(ui, /\/workspace\\\/xtreams/); + }); + + it('arms exact operation listeners with no timer-based coordination', () => { + const cancellation = source('xtream-scenario-cancellation-observer.ts'); + const observer = source('xtream-scenario-operation-observer.ts'); + const driver = source('xtream-scenario-driver.ts'); + const combined = `${cancellation}\n${observer}\n${driver}`; + + assert.ok(observer.includes('onDbOperationEvent')); + assert.ok(cancellation.includes('onDbOperationEvent')); + assert.ok(cancellation.includes("'save-content'")); + assert.ok(observer.includes("'delete-xtream-content'")); + assert.ok( + cancellation.includes("'.workspace-loading-overlay__action'") + ); + assert.match( + cancellation, + /Math\.max\(100,\s*Math\.floor\(total \* 0\.1\)\)/ + ); + assert.ok(driver.includes('installXtreamCancellationObserver')); + assert.ok(driver.includes('installXtreamOperationWindow')); + assert.ok(driver.includes('runBackgroundProbe')); + assert.doesNotMatch(combined, /waitForTimeout\s*\(/); + assert.doesNotMatch(combined, /setTimeout\s*\(/); + assert.doesNotMatch(combined, /\bsleep\s*\(/); + }); + + it('finishes add-dialog setup before arming a seed capture', () => { + const support = source('xtream-benchmark-iteration-support.ts'); + const seedStart = support.indexOf( + 'export async function seedXtreamExistingPortal' + ); + const prepare = support.indexOf( + 'await prepareXtreamScenario', + seedStart + ); + const capture = support.indexOf('await startMainCapture', seedStart); + const trigger = support.indexOf('await seed.trigger()', seedStart); + + assert.ok(seedStart >= 0); + assert.ok(prepare > seedStart); + assert.ok(capture > prepare); + assert.ok(trigger > capture); + }); + + it('fences seed settlement on the exact renderer store terminal', () => { + const support = source('xtream-benchmark-iteration-support.ts'); + const seedStart = support.indexOf( + 'export async function seedXtreamExistingPortal' + ); + const install = support.indexOf( + 'await installXtreamRendererProbe', + seedStart + ); + const capture = support.indexOf('await startMainCapture', seedStart); + const begin = support.indexOf( + 'await beginXtreamRendererOperation', + seedStart + ); + const trigger = support.indexOf('await seed.trigger()', seedStart); + const terminal = support.indexOf( + 'await waitForXtreamRendererTerminal', + seedStart + ); + const complete = support.indexOf( + 'assertCompleteXtreamRendererProbe', + terminal + ); + const settlement = support.indexOf( + 'await assertXtreamSettlementAfterTerminal', + terminal + ); + + assert.ok(install > seedStart); + assert.ok(capture > install); + assert.ok(begin > capture); + assert.ok(trigger > begin); + assert.ok(terminal > trigger); + assert.ok(complete > terminal); + assert.ok(settlement > complete); + }); + + it('prearms the persistent database worker outside the measured capture', () => { + const iteration = source('xtream-benchmark-iteration.ts'); + const support = source('xtream-benchmark-iteration-support.ts'); + const install = iteration.indexOf( + 'await installMainCapture(app.electronApp)' + ); + const prearm = iteration.indexOf( + 'await prearmXtreamDatabaseWorker(app)' + ); + const captureOptions = iteration.indexOf( + 'const captureOptions: MainCaptureStartOptions' + ); + + assert.match( + support, + /export async function prearmXtreamDatabaseWorker/ + ); + assert.match(support, /window\.electron\.dbGetAppPlaylists\(\)/); + assert.ok(install >= 0); + assert.ok(prearm > install); + assert.ok(captureOptions > prearm); + }); + + it('subscribes the renderer probe before the cancellation observer is armed', () => { + const iteration = source('xtream-benchmark-iteration.ts'); + const renderer = iteration.indexOf('await startXtreamRendererCapture'); + const operation = iteration.indexOf( + 'await renderer.beginOperation()', + renderer + ); + const trigger = iteration.indexOf( + 'trigger = await prepared.trigger()', + operation + ); + + assert.ok(renderer >= 0); + assert.ok(operation > renderer); + assert.ok(trigger > operation); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.fake-page.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.fake-page.ts new file mode 100644 index 000000000..db793d438 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.fake-page.ts @@ -0,0 +1,131 @@ +import type { Page } from '@playwright/test'; + +export class FakeXtreamScenarioPage { + readonly log: string[] = []; + operationSnapshot: unknown = null; + readonly operationSnapshots: unknown[] = []; + readonly asPageReference: Page; + cancellationSnapshot: Record = { + armedEpochMs: 100, + clickEpochMs: 201, + current: 1_000, + operationId: 'save-content-1', + progressEpochMs: 200, + threshold: 1_000, + total: 10_000, + }; + + constructor() { + this.asPageReference = this.createPage(); + } + + asPage(): Page { + return this.asPageReference; + } + + clicksMatching(fragment: string): number { + return this.log.filter( + (entry) => entry.startsWith('click:') && entry.includes(fragment) + ).length; + } + + private createPage(): Page { + return { + evaluate: async (_callback: unknown, argument: unknown) => { + const command = recordValue(argument, 'command'); + this.log.push(`evaluate:${String(command)}`); + if (command === 'snapshot-operation') { + return ( + this.operationSnapshots.shift() ?? + this.operationSnapshot + ); + } + if (command === 'snapshot-cancellation') { + return this.cancellationSnapshot; + } + return undefined; + }, + getByRole: (role: string, options?: unknown) => + new FakeLocator( + this, + `page.role(${role};${roleOptions(options)})` + ), + locator: (selector: string) => new FakeLocator(this, selector), + waitForFunction: async (_callback: unknown, argument: unknown) => { + this.log.push( + `waitForFunction:${String( + recordValue(argument, 'waitFor') + )}` + ); + }, + waitForURL: async (url: unknown) => { + this.log.push(`waitForURL:${String(url)}`); + }, + } as unknown as Page; + } +} + +class FakeLocator { + constructor( + private readonly page: FakeXtreamScenarioPage, + private readonly path: string + ) {} + + first(): FakeLocator { + return new FakeLocator(this.page, `${this.path}.first`); + } + + last(): FakeLocator { + return new FakeLocator(this.page, `${this.path}.last`); + } + + filter(options: unknown): FakeLocator { + return new FakeLocator( + this.page, + `${this.path}.filter(${String(recordValue(options, 'hasText'))})` + ); + } + + getByRole(role: string, options?: unknown): FakeLocator { + return new FakeLocator( + this.page, + `${this.path}.role(${role};${roleOptions(options)})` + ); + } + + locator(selector: string): FakeLocator { + return new FakeLocator(this.page, `${this.path} >> ${selector}`); + } + + async click(): Promise { + this.page.log.push(`click:${this.path}`); + } + + async count(): Promise { + return 1; + } + + async fill(value: string): Promise { + const selector = this.path.split(' >> ').at(-1) ?? this.path; + this.page.log.push(`fill:${selector}:${value}`); + } + + async isDisabled(): Promise { + return false; + } + + async waitFor(options: { readonly state: string }): Promise { + this.page.log.push(`waitFor:${this.path}:${options.state}`); + } +} + +function recordValue(value: unknown, key: string): unknown { + if (typeof value !== 'object' || value === null) return undefined; + return Reflect.get(value, key); +} + +function roleOptions(value: unknown): string { + const name = recordValue(value, 'name'); + const exact = recordValue(value, 'exact'); + return `name=${name instanceof RegExp ? name.toString() : String(name)};exact=${String(exact)}`; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts new file mode 100644 index 000000000..fdd9df3b2 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts @@ -0,0 +1,342 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + XTREAM_SCENARIO_ID, + expectedXtreamWorkerRequestInventory, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { + XTREAM_SCENARIO_DATABASE_REQUEST_COUNT, + XTREAM_SYNTHETIC_CREDENTIALS, + prepareXtreamScenario, +} from './xtream-scenario-driver'; +import { + installXtreamCancellationObserver, + isXtreamCancellationProgress, + xtreamCancellationThreshold, +} from './xtream-scenario-cancellation-observer'; +import { FakeXtreamScenarioPage as FakePage } from './xtream-scenario-driver.fake-page'; + +const SOURCE_TITLE = 'Synthetic Xtream performance portal'; +const SERVER_URL = 'http://127.0.0.1:43123'; + +describe('Xtream Playwright scenario driver', () => { + it('pins local synthetic credentials and exact database request counts', () => { + assert.deepEqual(XTREAM_SYNTHETIC_CREDENTIALS, { + password: 'performance', + username: 'performance', + }); + assert.deepEqual(XTREAM_SCENARIO_DATABASE_REQUEST_COUNT, { + 'xtream-background-ui': 54, + 'xtream-cancel-import': 24, + 'xtream-delete-large': 2, + 'xtream-initial-import-large': 30, + 'xtream-refresh-large': 51, + }); + + for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) { + const inventoryCount = expectedXtreamWorkerRequestInventory( + scenarioId + ).reduce((sum, entry) => sum + entry.count, 0); + assert.equal( + XTREAM_SCENARIO_DATABASE_REQUEST_COUNT[scenarioId], + inventoryCount + ); + } + }); + + it('accepts only the first qualifying live save-content progress event', () => { + assert.equal(xtreamCancellationThreshold(999), 100); + assert.equal(xtreamCancellationThreshold(10_000), 1_000); + assert.equal( + isXtreamCancellationProgress({ + current: 999, + operation: 'save-content', + status: 'progress', + total: 10_000, + }), + false + ); + assert.equal( + isXtreamCancellationProgress({ + current: 1_000, + operation: 'save-content', + operationId: 'save-content-1', + status: 'progress', + total: 10_000, + }), + true + ); + assert.equal( + isXtreamCancellationProgress({ + current: 10_000, + operation: 'delete-xtream-content', + status: 'progress', + total: 10_000, + }), + false + ); + assert.equal( + isXtreamCancellationProgress({ + current: 10_000, + operation: 'save-content', + status: 'completed', + total: 10_000, + }), + false + ); + }); + + it('retains exact cancellation click identity and rejects anonymous evidence', async () => { + const fake = new FakePage(); + const observer = await installXtreamCancellationObserver(fake.asPage()); + + assert.deepEqual(await observer.waitForClick(), { + armedEpochMs: 100, + clickEpochMs: 201, + current: 1_000, + operationId: 'save-content-1', + progressEpochMs: 200, + threshold: 1_000, + total: 10_000, + }); + + const anonymous = new FakePage(); + anonymous.cancellationSnapshot = { + ...anonymous.cancellationSnapshot, + operationId: null, + }; + const anonymousObserver = await installXtreamCancellationObserver( + anonymous.asPage() + ); + await assert.rejects( + () => anonymousObserver.waitForClick(), + /cancellation-evidence-invalid/ + ); + }); + + it('prepares initial import without clicking the final Add trigger', async () => { + const fake = new FakePage(); + const prepared = await prepareXtreamScenario( + fake.asPage(), + scenarioOptions(XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE) + ); + + assert.equal(prepared.expectedDatabaseRequestCount, 30); + assert.equal(prepared.backgroundOperation, null); + assert.equal(fake.clicksMatching('name=Add;exact=true'), 0); + assert.ok( + fake.log.includes('fill:#title:Synthetic Xtream performance portal') + ); + assert.ok(fake.log.includes('fill:#serverUrl:http://127.0.0.1:43123')); + assert.ok(fake.log.includes('fill:#username:performance')); + assert.ok(fake.log.includes('fill:#password:performance')); + const dashboardReadyIndex = fake.log.indexOf( + 'waitForFunction:empty-dashboard-ready' + ); + const openDialogIndex = fake.log.findIndex( + (entry) => + entry.startsWith('click:') && + entry.includes('name=Add playlist;exact=true') + ); + const openSourcesIndex = fake.log.findIndex( + (entry) => + entry.startsWith('click:') && + entry.includes('name=Sources;exact=true') + ); + assert.ok(dashboardReadyIndex >= 0); + assert.ok(openSourcesIndex > dashboardReadyIndex); + assert.ok(openDialogIndex > openSourcesIndex); + assert.ok(fake.log.includes('waitFor:app-workspace-sources:visible')); + + const evidence = await prepared.trigger(); + assert.ok(Number.isFinite(evidence.triggerEpochMs)); + assert.equal(fake.clicksMatching('name=Add;exact=true'), 1); + await assert.rejects(() => prepared.trigger(), /already-triggered/); + }); + + it('does not require a misleading portal-status badge before refresh or delete', async () => { + for (const [scenarioId, selector] of [ + [XTREAM_SCENARIO_ID.REFRESH_LARGE, '.refresh-btn'], + [XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn'], + ] as const) { + const fake = new FakePage(); + const prepared = await prepareXtreamScenario( + fake.asPage(), + scenarioOptions(scenarioId) + ); + + assert.equal(fake.clicksMatching(selector), 1); + assert.equal(fake.clicksMatching('name=Yes;exact=true'), 0); + const portalReadyIndex = fake.log.findIndex((entry) => + entry.includes('[aria-label="Portal status: active"]:visible') + ); + const sourceActionIndex = fake.log.findIndex( + (entry) => + entry.startsWith('click:') && entry.includes(selector) + ); + assert.equal(portalReadyIndex, -1); + assert.ok(sourceActionIndex >= 0); + await prepared.trigger(); + assert.equal(fake.clicksMatching('name=Yes;exact=true'), 1); + } + }); + + it('arms cancellation after preparation and immediately before the final Add trigger', async () => { + const fake = new FakePage(); + const prepared = await prepareXtreamScenario( + fake.asPage(), + scenarioOptions(XTREAM_SCENARIO_ID.CANCEL_IMPORT) + ); + const openIndex = fake.log.findIndex( + (entry) => + entry.startsWith('click:') && + entry.includes('name=Add playlist;exact=true') + ); + + assert.equal( + fake.log.indexOf('evaluate:install-cancellation'), + -1, + 'the renderer probe must be able to subscribe before cancellation' + ); + await prepared.trigger(); + const installIndex = fake.log.indexOf('evaluate:install-cancellation'); + const triggerIndex = fake.log.findIndex( + (entry) => + entry.startsWith('click:') && + entry.includes('name=Add;exact=true') + ); + assert.ok(installIndex > openIndex); + assert.ok(triggerIndex > installIndex); + assert.equal(prepared.expectedDatabaseRequestCount, 24); + }); + + it('uses the exact delete request as the causal start of the full refresh probe', async () => { + const fake = new FakePage(); + fake.operationSnapshot = { + active: true, + operation: 'delete-xtream-content', + operationId: 'xtream-refresh-1', + playlistId: 'playlist-1', + startedEpochMs: 100, + terminalEpochMs: null, + terminalStatus: null, + }; + const prepared = await prepareXtreamScenario( + fake.asPage(), + scenarioOptions(XTREAM_SCENARIO_ID.BACKGROUND_UI) + ); + await prepared.trigger(); + const calls: string[] = []; + const evidence = await prepared.runBackgroundProbe( + async ({ operation, page, sourceTitle }) => { + calls.push('type-filter'); + calls.push('dashboard'); + calls.push('sources'); + calls.push('name-a-z'); + calls.push('global-search'); + assert.equal(operation, 'delete-xtream-content'); + assert.equal(page, fake.asPageReference); + assert.equal(sourceTitle, SOURCE_TITLE); + return { samples: 5 }; + } + ); + + assert.deepEqual(calls, [ + 'type-filter', + 'dashboard', + 'sources', + 'name-a-z', + 'global-search', + ]); + assert.equal(evidence.operation, 'delete-xtream-content'); + assert.deepEqual(evidence.result, { samples: 5 }); + assert.ok( + fake.log.some( + (entry) => entry === 'waitForFunction:operation-active' + ) + ); + }); + + it('rejects non-loopback origins before touching the page', async () => { + const fake = new FakePage(); + + await assert.rejects( + () => + prepareXtreamScenario(fake.asPage(), { + ...scenarioOptions(XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE), + serverUrl: 'https://provider.example', + }), + /loopback mock origin/ + ); + assert.deepEqual(fake.log, []); + }); + + it('allows the refresh probe to continue after the delete prelude closes', async () => { + const fake = new FakePage(); + const active = { + active: true, + operation: 'delete-xtream-content', + operationId: 'xtream-refresh-1', + playlistId: 'playlist-1', + startedEpochMs: 100, + terminalEpochMs: null, + terminalStatus: null, + } as const; + fake.operationSnapshots.push(active, active, { + ...active, + active: false, + terminalEpochMs: 150, + terminalStatus: 'completed', + }); + const prepared = await prepareXtreamScenario( + fake.asPage(), + scenarioOptions(XTREAM_SCENARIO_ID.BACKGROUND_UI) + ); + await prepared.trigger(); + let probeCalls = 0; + + await prepared.runBackgroundProbe(async () => { + probeCalls += 1; + return null; + }); + assert.equal(probeCalls, 1); + }); + + it('fails closed before the probe when delete request identity is missing', async () => { + const fake = new FakePage(); + fake.operationSnapshot = { + active: true, + operation: 'delete-xtream-content', + operationId: null, + playlistId: null, + startedEpochMs: 100, + terminalEpochMs: null, + terminalStatus: null, + }; + const prepared = await prepareXtreamScenario( + fake.asPage(), + scenarioOptions(XTREAM_SCENARIO_ID.BACKGROUND_UI) + ); + await prepared.trigger(); + let probeCalls = 0; + + await assert.rejects( + () => + prepared.runBackgroundProbe(async () => { + probeCalls += 1; + }), + /operation-window-invalid/ + ); + assert.equal(probeCalls, 0); + }); +}); + +function scenarioOptions(scenarioId: XtreamScenarioId) { + return { + scenarioId, + serverUrl: SERVER_URL, + sourceTitle: SOURCE_TITLE, + }; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts new file mode 100644 index 000000000..2f87c7c7b --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts @@ -0,0 +1,329 @@ +import type { Locator, Page } from '@playwright/test'; + +import { + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import { + installXtreamCancellationObserver, + type XtreamCancellationEvidence, + type XtreamCancellationObserver, +} from './xtream-scenario-cancellation-observer'; +import { + installXtreamOperationWindow, + type XtreamOperationWindow, + type XtreamOperationWindowSnapshot, +} from './xtream-scenario-operation-observer'; +import { + prepareXtreamAddTrigger, + prepareXtreamSourceActionTrigger, + waitForXtreamCancellationTerminal, + waitForXtreamCatalogTerminal, + waitForXtreamDeleteTerminal, +} from './xtream-scenario-ui'; +import { assertXtreamMockOrigin } from './xtream-control-client'; +import { assertXtreamBackgroundRefreshActive } from './xtream-renderer-probe'; + +export const XTREAM_SYNTHETIC_CREDENTIALS = Object.freeze({ + password: 'performance', + username: 'performance', +} as const); + +export const XTREAM_SCENARIO_DATABASE_REQUEST_COUNT: Readonly< + Record +> = Object.freeze({ + [XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE]: 30, + [XTREAM_SCENARIO_ID.REFRESH_LARGE]: 51, + [XTREAM_SCENARIO_ID.DELETE_LARGE]: 2, + [XTREAM_SCENARIO_ID.CANCEL_IMPORT]: 24, + [XTREAM_SCENARIO_ID.BACKGROUND_UI]: 54, +}); + +export interface PrepareXtreamScenarioOptions { + readonly scenarioId: XtreamScenarioId; + readonly serverUrl: string; + readonly sourceTitle: string; +} + +export interface XtreamBackgroundActionContext { + readonly operation: 'delete-xtream-content'; + readonly operationId: string; + readonly page: Page; + readonly playlistId: string; + readonly sourceTitle: string; +} + +export interface XtreamBackgroundProbeEvidence { + readonly completedEpochMs: number; + readonly operation: 'delete-xtream-content'; + readonly operationId: string; + readonly playlistId: string; + readonly result: Result; + readonly startedEpochMs: number; +} + +export type XtreamBackgroundProbe = ( + context: XtreamBackgroundActionContext +) => Promise; + +export interface XtreamScenarioTriggerEvidence { + readonly scenarioId: XtreamScenarioId; + readonly triggerEpochMs: number; +} + +export interface XtreamScenarioTerminalEvidence { + readonly backgroundProbe: XtreamBackgroundProbeEvidence | null; + readonly cancellation: XtreamCancellationEvidence | null; + readonly operation: XtreamOperationWindowSnapshot | null; + readonly scenarioId: XtreamScenarioId; + readonly terminalEpochMs: number; +} + +export interface PreparedXtreamScenario { + readonly backgroundOperation: XtreamOperationWindow | null; + readonly expectedDatabaseRequestCount: number; + readonly scenarioId: XtreamScenarioId; + readonly sourceTitle: string; + dispose(): Promise; + runBackgroundProbe( + probe: XtreamBackgroundProbe + ): Promise>; + trigger(): Promise; + waitForTerminal(): Promise; +} + +export async function prepareXtreamScenario( + page: Page, + options: PrepareXtreamScenarioOptions +): Promise { + validateOptions(options); + const cancellationObserver: XtreamCancellationObserver | null = null; + let backgroundOperation: XtreamOperationWindow | null = null; + try { + if (options.scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI) { + backgroundOperation = await installXtreamOperationWindow(page); + } + const trigger = await prepareFinalTrigger(page, options); + return createPreparedScenario({ + backgroundOperation, + cancellationObserver, + options, + page, + trigger, + }); + } catch (error) { + await Promise.allSettled([ + cancellationObserver?.dispose(), + backgroundOperation?.dispose(), + ]); + throw error; + } +} + +interface PreparedState { + readonly backgroundOperation: XtreamOperationWindow | null; + cancellationObserver: XtreamCancellationObserver | null; + readonly options: PrepareXtreamScenarioOptions; + readonly page: Page; + readonly trigger: Locator; +} + +function createPreparedScenario(state: PreparedState): PreparedXtreamScenario { + let triggered = false; + let disposed = false; + let backgroundProbeStarted = false; + let backgroundProbe: XtreamBackgroundProbeEvidence | null = null; + let terminalPromise: Promise | null = null; + const dispose = async (): Promise => { + if (disposed) return; + disposed = true; + await Promise.allSettled([ + state.cancellationObserver?.dispose(), + state.backgroundOperation?.dispose(), + ]); + }; + const prepared: PreparedXtreamScenario = { + backgroundOperation: state.backgroundOperation, + expectedDatabaseRequestCount: + XTREAM_SCENARIO_DATABASE_REQUEST_COUNT[state.options.scenarioId], + scenarioId: state.options.scenarioId, + sourceTitle: state.options.sourceTitle, + dispose, + async runBackgroundProbe(probe: XtreamBackgroundProbe) { + if (!triggered) { + throw new Error('xtream-scenario-not-triggered'); + } + if (!state.backgroundOperation) { + throw new Error('xtream-background-operation-unavailable'); + } + if (backgroundProbeStarted) { + throw new Error('xtream-background-probe-already-started'); + } + if (typeof probe !== 'function') { + throw new Error('xtream-background-probe-invalid'); + } + backgroundProbeStarted = true; + await state.backgroundOperation.waitForActive(); + const before = await state.backgroundOperation.assertActive(); + const startedEpochMs = Date.now(); + const result = await probe(actionContext(state, before)); + await assertXtreamBackgroundRefreshActive(state.page, { + operationId: before.operationId, + playlistId: before.playlistId, + }); + const completedEpochMs = Date.now(); + const evidence: XtreamBackgroundProbeEvidence = + Object.freeze({ + completedEpochMs, + operation: before.operation, + operationId: before.operationId, + playlistId: before.playlistId, + result, + startedEpochMs, + }); + backgroundProbe = evidence; + return evidence; + }, + async trigger() { + if (triggered) { + throw new Error('xtream-scenario-already-triggered'); + } + triggered = true; + if (state.options.scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT) { + state.cancellationObserver = + await installXtreamCancellationObserver(state.page); + } + const triggerEpochMs = Date.now(); + await state.trigger.click(); + return { + scenarioId: state.options.scenarioId, + triggerEpochMs, + }; + }, + async waitForTerminal() { + if (!triggered) { + throw new Error('xtream-scenario-not-triggered'); + } + terminalPromise ??= waitForScenarioTerminal( + state, + () => backgroundProbe + ).finally(dispose); + return terminalPromise; + }, + }; + return Object.freeze(prepared); +} + +async function waitForScenarioTerminal( + state: PreparedState, + readBackgroundProbe: () => XtreamBackgroundProbeEvidence | null +): Promise { + let cancellation: XtreamCancellationEvidence | null = null; + let operation: XtreamOperationWindowSnapshot | null = null; + switch (state.options.scenarioId) { + case XTREAM_SCENARIO_ID.CANCEL_IMPORT: + if (!state.cancellationObserver) { + throw new Error('xtream-cancellation-observer-unavailable'); + } + cancellation = await state.cancellationObserver.waitForClick(); + await waitForXtreamCancellationTerminal(state.page); + break; + case XTREAM_SCENARIO_ID.DELETE_LARGE: + await waitForXtreamDeleteTerminal( + state.page, + state.options.sourceTitle + ); + break; + case XTREAM_SCENARIO_ID.BACKGROUND_UI: + if (readBackgroundProbe() === null) { + throw new Error('xtream-background-probe-incomplete'); + } + if (!state.backgroundOperation) { + throw new Error('xtream-background-operation-unavailable'); + } + operation = await state.backgroundOperation.waitForTerminal(); + if (operation.terminalStatus !== 'completed') { + throw new Error('xtream-background-delete-did-not-complete'); + } + await waitForXtreamCatalogTerminal(state.page); + break; + case XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE: + case XTREAM_SCENARIO_ID.REFRESH_LARGE: + await waitForXtreamCatalogTerminal(state.page); + break; + } + return { + backgroundProbe: readBackgroundProbe(), + cancellation, + operation, + scenarioId: state.options.scenarioId, + terminalEpochMs: Date.now(), + }; +} + +async function prepareFinalTrigger( + page: Page, + options: PrepareXtreamScenarioOptions +): Promise { + switch (options.scenarioId) { + case XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE: + case XTREAM_SCENARIO_ID.CANCEL_IMPORT: + return prepareXtreamAddTrigger(page, { + ...XTREAM_SYNTHETIC_CREDENTIALS, + serverUrl: options.serverUrl, + sourceTitle: options.sourceTitle, + }); + case XTREAM_SCENARIO_ID.REFRESH_LARGE: + case XTREAM_SCENARIO_ID.BACKGROUND_UI: + return prepareXtreamSourceActionTrigger( + page, + options.sourceTitle, + 'refresh' + ); + case XTREAM_SCENARIO_ID.DELETE_LARGE: + return prepareXtreamSourceActionTrigger( + page, + options.sourceTitle, + 'delete' + ); + } +} + +function actionContext( + state: PreparedState, + snapshot: XtreamOperationWindowSnapshot +): XtreamBackgroundActionContext { + return { + operation: snapshot.operation, + operationId: snapshot.operationId, + page: state.page, + playlistId: snapshot.playlistId, + sourceTitle: state.options.sourceTitle, + }; +} + +function validateOptions(options: PrepareXtreamScenarioOptions): void { + if (!(options.scenarioId in XTREAM_SCENARIO_DATABASE_REQUEST_COUNT)) { + throw new Error('xtream-scenario-id-invalid'); + } + try { + assertXtreamMockOrigin(options.serverUrl); + } catch { + throw new Error('Xtream scenario requires a loopback mock origin'); + } + if ( + options.sourceTitle.length === 0 || + options.sourceTitle.length > 128 || + options.sourceTitle.trim() !== options.sourceTitle || + hasControlCharacter(options.sourceTitle) + ) { + throw new Error('xtream-scenario-source-title-invalid'); + } +} + +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 31 || codePoint === 127; + }); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-operation-observer.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-operation-observer.ts new file mode 100644 index 000000000..e582ab632 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-operation-observer.ts @@ -0,0 +1,239 @@ +import type { Page } from '@playwright/test'; + +const OPERATION_STATE_KEY = '__iptvnatorXtreamScenarioOperationWindow'; +const DELETE_OPERATION = 'delete-xtream-content'; +const OPERATION_TIMEOUT_MS = 120_000; + +export interface XtreamOperationWindowSnapshot { + readonly active: boolean; + readonly operation: typeof DELETE_OPERATION; + readonly operationId: string; + readonly playlistId: string; + readonly startedEpochMs: number; + readonly terminalEpochMs: number | null; + readonly terminalStatus: 'cancelled' | 'completed' | 'error' | null; +} + +export interface XtreamOperationWindow { + assertActive(): Promise; + dispose(): Promise; + waitForActive(): Promise; + waitForTerminal(): Promise; +} + +export async function installXtreamOperationWindow( + page: Page +): Promise { + await page.evaluate( + ({ command, operation, stateKey }) => { + interface State { + active: boolean; + operation: string; + operationId: string | null; + playlistId: string | null; + startedEpochMs: number | null; + terminalEpochMs: number | null; + terminalStatus: string | null; + unsubscribe: () => void; + } + const target = globalThis as unknown as Record; + if (command !== 'install-operation' || target[stateKey]) { + throw new Error('xtream-operation-window-state-invalid'); + } + const electron = target['electron'] as + | { + onDbOperationEvent?: ( + callback: (event: Record) => void + ) => () => void; + } + | undefined; + if (typeof electron?.onDbOperationEvent !== 'function') { + throw new Error('xtream-db-operation-listener-unavailable'); + } + const epoch = (): number => + performance.timeOrigin + performance.now(); + const state: State = { + active: false, + operation, + operationId: null, + playlistId: null, + startedEpochMs: null, + terminalEpochMs: null, + terminalStatus: null, + unsubscribe: () => undefined, + }; + state.unsubscribe = electron.onDbOperationEvent((event) => { + if (event['operation'] !== operation) return; + const status = event['status']; + if (status === 'started' && state.startedEpochMs === null) { + state.active = true; + state.startedEpochMs = epoch(); + state.operationId = + typeof event['operationId'] === 'string' + ? event['operationId'] + : null; + state.playlistId = + typeof event['playlistId'] === 'string' + ? event['playlistId'] + : null; + return; + } + if ( + !state.active || + !['cancelled', 'completed', 'error'].includes( + String(status) + ) + ) { + return; + } + const eventOperationId = + typeof event['operationId'] === 'string' + ? event['operationId'] + : null; + if ( + state.operationId !== null && + eventOperationId !== state.operationId + ) { + return; + } + state.active = false; + state.terminalEpochMs = epoch(); + state.terminalStatus = String(status); + }); + target[stateKey] = state; + }, + { + command: 'install-operation', + operation: DELETE_OPERATION, + stateKey: OPERATION_STATE_KEY, + } + ); + return { + assertActive: () => assertOperationActive(page), + dispose: () => disposeOperationState(page), + async waitForActive() { + await waitForOperationState(page, 'operation-active'); + return assertOperationActive(page); + }, + async waitForTerminal() { + await waitForOperationState(page, 'operation-terminal'); + return readOperationSnapshot(page); + }, + }; +} + +async function waitForOperationState( + page: Page, + waitFor: 'operation-active' | 'operation-terminal' +): Promise { + await page.waitForFunction( + ({ stateKey, waitFor: condition }) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + | { + active?: boolean; + terminalEpochMs?: number | null; + } + | undefined; + return condition === 'operation-active' + ? state?.active === true + : typeof state?.terminalEpochMs === 'number'; + }, + { stateKey: OPERATION_STATE_KEY, waitFor }, + { timeout: OPERATION_TIMEOUT_MS } + ); +} + +async function assertOperationActive( + page: Page +): Promise { + const snapshot = await readOperationSnapshot(page); + if (!snapshot.active || snapshot.terminalEpochMs !== null) { + throw new Error('xtream-background-action-outside-delete-window'); + } + return snapshot; +} + +async function readOperationSnapshot( + page: Page +): Promise { + const value = await page.evaluate( + ({ stateKey }) => { + const target = globalThis as unknown as Record; + const state = target[stateKey]; + if (!state) throw new Error('xtream-operation-window-missing'); + const { + active, + operation, + operationId, + playlistId, + startedEpochMs, + terminalEpochMs, + terminalStatus, + } = state as Record; + return { + active, + operation, + operationId, + playlistId, + startedEpochMs, + terminalEpochMs, + terminalStatus, + }; + }, + { command: 'snapshot-operation', stateKey: OPERATION_STATE_KEY } + ); + if (!isOperationSnapshot(value)) { + throw new Error('xtream-operation-window-invalid'); + } + return value; +} + +async function disposeOperationState(page: Page): Promise { + await page.evaluate( + ({ stateKey }) => { + const target = globalThis as unknown as Record; + const state = target[stateKey] as + { unsubscribe?: () => void } | undefined; + state?.unsubscribe?.(); + delete target[stateKey]; + }, + { command: 'dispose-observer', stateKey: OPERATION_STATE_KEY } + ); +} + +function isOperationSnapshot( + value: unknown +): value is XtreamOperationWindowSnapshot { + if (!isRecord(value)) return false; + const active = value['active']; + const startedEpochMs = value['startedEpochMs']; + const terminalEpochMs = value['terminalEpochMs']; + const terminalStatus = value['terminalStatus']; + return ( + value['operation'] === DELETE_OPERATION && + typeof active === 'boolean' && + finitePositive(startedEpochMs) && + nonEmptyString(value['operationId']) && + nonEmptyString(value['playlistId']) && + (active + ? terminalEpochMs === null && terminalStatus === null + : finitePositive(terminalEpochMs) && + terminalEpochMs >= startedEpochMs && + ['cancelled', 'completed', 'error'].includes( + String(terminalStatus) + )) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function finitePositive(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts b/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts new file mode 100644 index 000000000..457ee6554 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-scenario-ui.ts @@ -0,0 +1,178 @@ +import type { Locator, Page } from '@playwright/test'; + +export interface XtreamSyntheticPortalFields { + readonly password: 'performance'; + readonly serverUrl: string; + readonly sourceTitle: string; + readonly username: 'performance'; +} + +export type XtreamSourceAction = 'delete' | 'refresh'; + +const XTREAM_METHOD_NAME = /xtream(?:\s+credentials)?/i; +const XTREAM_ROUTE = /\/workspace\/xtreams\/[^/]+/; + +export async function prepareXtreamAddTrigger( + page: Page, + fields: XtreamSyntheticPortalFields +): Promise { + await waitForEmptyDashboardReady(page); + await page.getByRole('link', { name: 'Sources', exact: true }).click(); + await page.waitForURL(/\/workspace\/sources$/); + await page.locator('app-workspace-sources').waitFor({ state: 'visible' }); + await page + .getByRole('button', { name: 'Add playlist', exact: true }) + .first() + .click(); + const dialog = page.locator('mat-dialog-container').last(); + await dialog.waitFor({ state: 'visible' }); + await selectXtreamMethod(dialog); + await dialog.locator('#title').fill(fields.sourceTitle); + await dialog.locator('#serverUrl').fill(fields.serverUrl); + await dialog.locator('#username').fill(fields.username); + await dialog.locator('#password').fill(fields.password); + + const exactAdd = dialog + .getByRole('button', { name: 'Add', exact: true }) + .last(); + const add = + (await exactAdd.count()) > 0 + ? exactAdd + : dialog + .getByRole('button', { + name: /^(add|add playlist)$/i, + }) + .last(); + await assertTriggerReady(add, 'xtream-final-add'); + return add; +} + +async function waitForEmptyDashboardReady(page: Page): Promise { + await page.waitForURL(/\/workspace\/dashboard$/); + await page.waitForFunction( + () => + document.querySelector( + 'lib-workspace-dashboard-rails.rails-page-host--empty' + ) !== null, + { waitFor: 'empty-dashboard-ready' } + ); +} + +export async function prepareXtreamSourceActionTrigger( + page: Page, + sourceTitle: string, + action: XtreamSourceAction +): Promise { + await page.getByRole('link', { name: 'Sources', exact: true }).click(); + await page.waitForURL(/\/workspace\/sources$/); + const row = xtreamSourceRow(page, sourceTitle); + await row.waitFor({ state: 'visible' }); + for (const selector of [ + '.busy-state__message', + '.action-spinner', + '.cancel-btn', + ]) { + await row.locator(selector).waitFor({ state: 'detached' }); + } + const actionSelector = + action === 'refresh' ? '.refresh-btn' : '.delete-btn'; + const actionButton = row.locator(actionSelector); + await assertTriggerReady(actionButton, `xtream-source-${action}`); + await actionButton.click(); + + const dialog = page.locator('mat-dialog-container').last(); + await dialog.waitFor({ state: 'visible' }); + const confirmation = dialog + .getByRole('button', { name: 'Yes', exact: true }) + .last(); + await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`); + return confirmation; +} + +export async function waitForXtreamCatalogTerminal(page: Page): Promise { + await page.waitForURL(XTREAM_ROUTE); + await page + .locator('.workspace-loading-overlay') + .waitFor({ state: 'detached' }); + await page + .locator('app-workspace-context-panel .category-item') + .first() + .waitFor({ state: 'visible' }); + await page + .locator('.content-card, [data-test-id="channel-item"], mat-card') + .first() + .waitFor({ state: 'visible' }); +} + +export async function waitForXtreamCancellationTerminal( + page: Page +): Promise { + await page + .locator('.workspace-loading-overlay') + .waitFor({ state: 'detached' }); + await page.waitForFunction( + ({ selector, text }) => + document.querySelector(selector)?.textContent?.trim() === text, + { + selector: '.xtream-content-gate .error-view__title', + text: 'Import cancelled', + waitFor: 'cancellation-terminal', + }, + { timeout: 120_000 } + ); +} + +export async function waitForXtreamDeleteTerminal( + page: Page, + sourceTitle: string +): Promise { + await xtreamSourceRow(page, sourceTitle).waitFor({ state: 'detached' }); +} + +async function selectXtreamMethod(dialog: Locator): Promise { + const radio = dialog + .getByRole('radio', { name: XTREAM_METHOD_NAME }) + .first(); + if ((await radio.count()) > 0) { + await radio.click(); + return; + } + const button = dialog + .getByRole('button', { name: XTREAM_METHOD_NAME }) + .first(); + if ((await button.count()) > 0) { + await button.click(); + return; + } + const fallback = dialog.locator('mat-button-toggle[value="xtream"]'); + if ((await fallback.count()) === 0) { + throw new Error('xtream-method-selector-missing'); + } + await fallback.click(); +} + +function xtreamSourceRow(page: Page, sourceTitle: string): Locator { + const exactTitle = new RegExp(`^\\s*${escapeRegex(sourceTitle)}\\s*$`, 'u'); + return page + .locator('app-playlist-item') + .filter({ + has: page.locator('.playlist-title', { + hasText: exactTitle, + }), + }) + .first(); +} + +async function assertTriggerReady( + trigger: Locator, + label: string +): Promise { + await trigger.waitFor({ state: 'visible' }); + if (await trigger.isDisabled()) { + throw new Error(`${label}-disabled`); + } +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-summary-contract.ts b/apps/electron-backend-e2e/src/performance/xtream-summary-contract.ts new file mode 100644 index 000000000..bcaf428ca --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-summary-contract.ts @@ -0,0 +1,318 @@ +import type { + NumericDistribution, + XtreamBenchmarkManifest, + XtreamIterationDefinition, + XtreamScenarioMetrics, + XtreamScenarioId, +} from './xtream-benchmark-contract'; +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import type { DatabaseWorkerCancelTimelineRecord } from './database-worker-cancel-capture'; +import type { + XtreamPhaseAttribution, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; + +export type { + NumericDistribution, + XtreamBenchmarkManifest, + XtreamScenarioMetrics, +}; + +export const XTREAM_MEMORY_ACCOUNTING = Object.freeze({ + mainRss: 'electron-main-process-including-worker-threads-and-native-memory', + rendererRss: 'separate-renderer-process', + workerJsHeap: 'separate-v8-isolate', + workerRss: 'unavailable-per-worker-thread', +}); + +export const XTREAM_NOT_APPLICABLE = { + COMMIT_INCLUDED_IN_SQLITE_WRITE: + 'included-in-sqlite-write-transaction-spans', + DATABASE_WORKER_RSS: 'unavailable-per-worker-thread', + DEBUG_COPY: 'production-debug-send-disabled', + INDEXES_PRE_EXIST: 'schema-indexes-pre-exist; operation-creates-none', + MAIN_EVENT_LOOP_UTILIZATION: 'electron-main-embedded-event-loop', + NATIVE_SQLITE_MEMORY: 'included-in-main-rss-not-separately-attributable', + PLAYLIST_REFRESH_WORKER: 'not-instantiated-by-xtream-flow', + PROFILE_OUTSIDE_DIAGNOSTIC: 'not-captured-outside-diagnostic', + RESTORE_NOT_INVOKED: 'not-invoked-by-scenario', + RESTORE_NO_USER_DATA: 'no-user-data', + SOURCES_SEARCH_MARKER: + 'sources-search-does-not-publish-xtream-search-results', +} as const; + +export const XTREAM_RENDERER_VISIBILITY = { + VISIBLE_WITHOUT_OVERLAY: 'visible-without-overlay', +} as const; + +export const XTREAM_CANCEL_NETWORK_ABORT_REASON = { + AFTER_NETWORK_COMPLETE: 'cancel-triggered-after-network-complete', +} as const; + +export const XTREAM_WORKER_REQUEST_INVALID_REASON = { + OVERLAPPING_DATABASE_REQUESTS: 'overlapping-database-worker-requests', +} as const; + +export const XTREAM_WORKER_REQUEST_METRIC_SCOPE = { + VALID_REQUEST_WINDOW_PROXY: 'valid-request-window-proxy', +} as const; + +export const XTREAM_MAIN_CPU_METRIC_SCOPE = { + ELECTRON_MAIN_THREAD: 'electron-main-thread', +} as const; + +export const XTREAM_WORKER_CPU_METRIC_SCOPE = { + SUM_VALID_REQUEST_WORK: 'sum-valid-request-thread-cpu', +} as const; + +export const XTREAM_WORKER_WHOLE_METRIC_SCOPE = { + OPERATION_WINDOW: 'whole-worker-operation-window', +} as const; + +export const XTREAM_STRUCTURED_CLONE_ATTRIBUTION = { + SELECTED_IPC_PROXY: 'selected-ipc-proxy-includes-queueing-and-scheduling', +} as const; + +type ProfileUnavailableReason = + typeof XTREAM_NOT_APPLICABLE.PROFILE_OUTSIDE_DIAGNOSTIC | null; + +export interface XtreamProfileCapture { + readonly cpuProfileDurationMicros: number | null; + readonly cpuProfileUnavailableReason: ProfileUnavailableReason; +} + +export interface XtreamRendererMetrics extends XtreamProfileCapture { + readonly actionLatencyMs: number | null; + readonly consoleErrors: readonly string[]; + readonly dashboardPaintVisibility: + typeof XTREAM_RENDERER_VISIBILITY.VISIBLE_WITHOUT_OVERLAY | null; + readonly frameGapMaxMs: number; + readonly frameGapP95Ms: number; + readonly heartbeatMaxMs: number; + readonly heartbeatP95Ms: number; + readonly longTaskCount: number; + readonly longTaskMaxMs: number; + readonly navigationLatencyMs: number | null; + readonly peakHeapUsedBytes: number; + readonly peakRssBytes: number; + readonly postGcHeapUsedBytes: number; + readonly searchLatencyMs: number | null; + readonly storeToPaintMs: number; + readonly targetId: string; +} + +export interface XtreamMainMetrics extends XtreamProfileCapture { + readonly browserWindowId: number; + readonly cpuMetricScope: typeof XTREAM_MAIN_CPU_METRIC_SCOPE.ELECTRON_MAIN_THREAD; + readonly cpuSystemMicros: number; + readonly cpuUserMicros: number; + readonly eventLoopDelayMaxMs: number; + readonly eventLoopDelayP95Ms: number; + readonly eventLoopDelayP99Ms: number; + readonly eventLoopUtilization: null; + readonly eventLoopUtilizationUnavailableReason: typeof XTREAM_NOT_APPLICABLE.MAIN_EVENT_LOOP_UTILIZATION; + readonly peakHeapUsedBytes: number; + readonly peakRssBytes: number; + readonly postGcHeapUsedBytes: number; + readonly responsiveEvents: number; + readonly unresponsiveEvents: number; + readonly webContentsId: number; +} + +export interface XtreamDatabaseWorkerMetrics extends XtreamProfileCapture { + readonly cpuMetricScope: typeof XTREAM_WORKER_CPU_METRIC_SCOPE.SUM_VALID_REQUEST_WORK; + readonly cpuSystemMicros: number; + readonly cpuUserMicros: number; + readonly currentForCapture: boolean; + readonly eventLoopDelayMaxMs: number; + readonly eventLoopDelayMetricScope: typeof XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY; + readonly eventLoopDelayP95Ms: number; + readonly eventLoopDelayP99Ms: number; + readonly eventLoopUtilization: number; + readonly kind: 'database.worker'; + readonly ordinal: number; + readonly peakExternalBytes: number; + readonly peakHeapUsedBytes: number; + readonly postGcHeapUsedBytes: number; + readonly requests: XtreamWorkerRequestAccounting; + readonly rssBytes: null; + readonly rssUnavailableReason: 'unavailable-per-worker-thread'; + readonly wholeWorkerMetricScope: typeof XTREAM_WORKER_WHOLE_METRIC_SCOPE.OPERATION_WINDOW; +} + +export interface XtreamWorkerRequestAccounting { + readonly evidence: readonly WorkerRequestPerformanceMetrics[]; + readonly eventLoopDelayMaxMs: number; + readonly eventLoopDelayP95Ms: number; + readonly eventLoopDelayP99Ms: number; + readonly eventLoopUtilization: number; + readonly invalidReasons: readonly XtreamWorkerRequestInvalidReasonCount[]; + readonly invalidSampleCount: number; + readonly metricScope: typeof XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY; + readonly totalCount: number; + readonly validSampleCount: number; +} + +export interface XtreamWorkerRequestInvalidReasonCount { + readonly count: number; + readonly reason: typeof XTREAM_WORKER_REQUEST_INVALID_REASON.OVERLAPPING_DATABASE_REQUESTS; +} + +export interface XtreamPlaylistRefreshWorkerState { + readonly instantiated: false; + readonly reason: 'not-instantiated-by-xtream-flow'; +} + +export interface XtreamCancellationMetrics { + readonly acknowledgementLatencyMs: number | null; + readonly authoritativeDatabaseAcknowledgementObserved: boolean; + readonly authoritativeTerminalEpochMs: number | null; + readonly clickEpochMs: number | null; + readonly clickToDatabaseDispatchMs: number | null; + readonly clickToPaintMs: number | null; + readonly clickToPreloadReturnMs: number | null; + readonly databaseDispatchEpochMs: number | null; + readonly failedDatabaseRequestId: string | null; + readonly failedOperationId: string | null; + readonly mainNetworkAbortObserved: boolean; + readonly mainNetworkAbortReason: + typeof XTREAM_CANCEL_NETWORK_ABORT_REASON.AFTER_NETWORK_COMPLETE | null; + readonly paintedEpochMs: number | null; + readonly preloadReturnEpochMs: number | null; + readonly preloadStartEpochMs: number | null; + readonly workerReceiptEpochMs: number | null; + readonly workerReceiptIsAuthoritative: false; + readonly workerReceiptLatencyMs: number | null; +} + +export interface XtreamCancellationEvidence { + readonly timeline: readonly DatabaseWorkerCancelTimelineRecord[]; +} + +export interface XtreamCatalogVerification { + readonly databaseStateValid: boolean; + readonly fixtureIdentityValid: boolean; + readonly providerCategoryCount: 100; + readonly providerItemCount: 100_000; + readonly verifiedAfterCapture: boolean; +} + +export interface XtreamIterationValidity { + readonly actionContractValid: boolean; + readonly catalogContractValid: boolean; + readonly databaseWorkerCount: number; + readonly dbPhaseContractValid: boolean; + readonly diagnosticArtifactsValid: boolean | null; + readonly lateDatabaseRequestCount: number; + readonly playlistWorkerCount: number; + readonly rendererTargetCount: number; +} + +export interface XtreamIterationProcessIdentity { + readonly captureGeneration: number; + readonly electronPid: number; + readonly freshProcess: true; + readonly generationIdentitySha256: string; + readonly launchId: string; + readonly profileDirectorySha256: string; +} + +export interface XtreamRawIterationResult extends XtreamIterationDefinition { + readonly cancellation: XtreamCancellationMetrics; + readonly cancellationEvidence: XtreamCancellationEvidence | null; + readonly catalogVerification: XtreamCatalogVerification; + readonly databaseWorker: XtreamDatabaseWorkerMetrics; + readonly main: XtreamMainMetrics; + readonly phaseCapture: XtreamPhaseAttributionInput; + readonly phases: XtreamPhaseAttribution; + readonly playlistRefreshWorker: XtreamPlaylistRefreshWorkerState; + readonly processIdentity: XtreamIterationProcessIdentity; + readonly renderer: XtreamRendererMetrics; + readonly validity: XtreamIterationValidity; +} + +export interface XtreamArtifactSafetyContext { + readonly controlToken: string; + readonly syntheticCredentials: { + readonly password: 'performance'; + readonly username: 'performance'; + }; +} + +export interface XtreamPersistenceReceipt { + readonly absolutePath: string; + readonly bytes: number; + readonly sha256: string; +} + +export interface XtreamPersistedRawEvidence { + readonly bytes: number; + readonly pathSha256: string; + readonly rawSha256: string; +} + +export interface XtreamValidatedIterationResult extends XtreamRawIterationResult { + readonly persistence: XtreamPersistedRawEvidence; + readonly validationStatus: 'valid'; +} + +export interface XtreamNotApplicableMetric { + readonly reason: string; + readonly value: null; +} + +export interface XtreamDiagnosticProfiles { + readonly artifactProvenance: XtreamDiagnosticArtifactProvenance; + readonly databaseWorkerDurationMicros: number | null; + readonly mainDurationMicros: number | null; + readonly rendererDurationMicros: number | null; +} + +export interface XtreamDiagnosticArtifactProvenance { + readonly artifacts: readonly XtreamDiagnosticArtifactReceipt[]; + readonly directorySha256: string; +} + +export interface XtreamDiagnosticArtifactReceipt { + readonly bytes: number; + readonly filename: string; + readonly label: string; + readonly sha256: string; +} + +export interface XtreamScenarioSummary { + readonly databaseWorkerEventLoopDelayMetricScope: typeof XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY; + readonly databaseWorkerRequestInvalidReasons: readonly (typeof XTREAM_WORKER_REQUEST_INVALID_REASON)[keyof typeof XTREAM_WORKER_REQUEST_INVALID_REASON][]; + readonly databaseWorkerRequestMetricScope: typeof XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY; + readonly databaseWorkerWholeWorkerMetricScope: typeof XTREAM_WORKER_WHOLE_METRIC_SCOPE.OPERATION_WINDOW; + readonly dashboardPaintVisibility: + typeof XTREAM_RENDERER_VISIBILITY.VISIBLE_WITHOUT_OVERLAY | null; + readonly diagnosticProfiles: XtreamDiagnosticProfiles; + readonly measuredRuns: number; + readonly memory: { + readonly databaseWorkerRss: XtreamNotApplicableMetric; + readonly nativeAndSqlite: XtreamNotApplicableMetric; + readonly playlistRefreshWorker: XtreamNotApplicableMetric; + }; + readonly mainEventLoopUtilization: XtreamNotApplicableMetric; + readonly metrics: XtreamScenarioMetrics; + readonly phases: { + readonly backgroundSearchMarker: XtreamNotApplicableMetric | null; + readonly debugCopy: XtreamNotApplicableMetric; + readonly indexes: XtreamNotApplicableMetric; + readonly restoreUserData: XtreamNotApplicableMetric; + readonly structuredCloneAttribution: typeof XTREAM_STRUCTURED_CLONE_ATTRIBUTION.SELECTED_IPC_PROXY; + readonly transactionCommit: XtreamNotApplicableMetric; + }; + readonly rendererLongTaskCountTotal: number; + readonly responsiveEventsTotal: number; + readonly scenarioId: XtreamScenarioId; + readonly unresponsiveEventsTotal: number; +} + +export interface XtreamBenchmarkSummary { + readonly iterations: readonly XtreamValidatedIterationResult[]; + readonly manifest: XtreamBenchmarkManifest; + readonly scenarios: readonly XtreamScenarioSummary[]; + readonly validForComparison: boolean; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-terminal-settlement.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-terminal-settlement.spec.ts new file mode 100644 index 000000000..632801dc2 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-terminal-settlement.spec.ts @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + expectedXtreamWorkerRequestInventory, + XTREAM_SCENARIO_ID, +} from './xtream-benchmark-contract'; +import { + assertXtreamSettlementAfterTerminal, + finalizeXtreamCaptureAfterRendererTerminal, +} from './xtream-benchmark-iteration-support'; +import type { XtreamMainCaptureStatus } from './m3u-refresh-main-capture'; + +describe('Xtream terminal settlement ordering', () => { + it('reads main status exactly once and only after terminal evidence resolves', async () => { + const terminal = deferred(); + let readCount = 0; + const settlement = assertXtreamSettlementAfterTerminal({ + captureGeneration: 2, + readStatus: async () => { + readCount += 1; + return settledStatus(); + }, + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + terminal: terminal.promise, + }); + + assert.equal(readCount, 0); + terminal.resolve(); + await settlement; + assert.equal(readCount, 1); + }); + + it('fails closed on pending terminal status without polling again', async () => { + let readCount = 0; + const status = settledStatus(); + const first = status.operationOutcomes[0]; + assert.ok(first); + + await assert.rejects( + assertXtreamSettlementAfterTerminal({ + captureGeneration: 2, + readStatus: async () => { + readCount += 1; + return { + ...status, + activeOperations: [ + { + operation: first.operation, + operationId: 'operation-1', + playlistId: 'playlist-1', + progress: null, + requestId: 'request-1', + }, + ], + }; + }, + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + terminal: Promise.resolve(), + }), + /xtream-main-not-settled-after-terminal/ + ); + assert.equal(readCount, 1); + }); + + it('rejects a changed capture generation after terminal evidence', async () => { + await assert.rejects( + assertXtreamSettlementAfterTerminal({ + captureGeneration: 3, + readStatus: async () => settledStatus(), + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + terminal: Promise.resolve(), + }), + /xtream-measured-capture-generation-changed/ + ); + }); + + it('freezes both captures after renderer settlement without waiting for late driver evidence', async () => { + const rendererTerminal = deferred(); + const driverTerminal = deferred<{ terminalEpochMs: number }>(); + const calls: string[] = []; + const finalization = finalizeXtreamCaptureAfterRendererTerminal({ + captureGeneration: 2, + driverTerminal: driverTerminal.promise, + readStatus: async () => { + calls.push('status'); + return settledStatus(); + }, + rendererTerminal: rendererTerminal.promise, + scenarioId: XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + stopMain: async () => { + calls.push('stop-main'); + return 'main'; + }, + stopRenderer: async () => { + calls.push('stop-renderer'); + return 'renderer'; + }, + }); + + await Promise.resolve(); + assert.deepEqual(calls, []); + + rendererTerminal.resolve(); + await waitFor(() => calls.includes('stop-main')); + assert.deepEqual(calls, ['status', 'stop-renderer', 'stop-main']); + + let completed = false; + void finalization.then(() => { + completed = true; + }); + await Promise.resolve(); + assert.equal(completed, false); + + driverTerminal.resolve({ terminalEpochMs: 2_000 }); + assert.deepEqual(await finalization, { + driverTerminal: { terminalEpochMs: 2_000 }, + mainCapture: 'main', + rendererCapture: 'renderer', + }); + }); +}); + +function settledStatus(): XtreamMainCaptureStatus { + return { + active: true, + activeOperations: [], + captureGeneration: 2, + captureStartedEpochMs: 1_000, + cutoffPhase: 'active', + databaseWorkerCount: 1, + invalidReasons: [], + lateDatabaseRequestCount: 0, + lateEventCount: 0, + measurementPhase: 'measuring', + measurementStartedEpochMs: 1_001, + operationOutcomes: expectedXtreamWorkerRequestInventory( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ).map(({ count, operation, success }) => ({ + count, + operation, + outcome: success ? 'success' : 'error', + })), + playlistRefreshWorkerCount: 0, + }; +} + +function deferred(): { + promise: Promise; + resolve(value: T): void; +} { + let resolvePromise: (value: T) => void = () => undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + return { + promise, + resolve: resolvePromise, + }; +} + +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 10 && !predicate(); attempt += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + assert.equal(predicate(), true); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts new file mode 100644 index 000000000..aefb1725c --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts @@ -0,0 +1,201 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +interface UiActionModule { + XTREAM_UI_ACTION_ORDER?: readonly string[]; + runXtreamBackgroundUiActions?: ( + page: unknown, + options: { + readonly operationId: string; + readonly playlistId: string; + readonly searchTerm: string; + } + ) => Promise; + summarizeXtreamUiActionSamples?: ( + samples: readonly { + readonly category: 'action' | 'navigation' | 'search'; + readonly endEpochMs: number; + readonly kind: string; + readonly latencyMs: number; + readonly startEpochMs: number; + }[] + ) => { + readonly action: { + readonly max: number | null; + readonly p95: number | null; + }; + readonly navigation: { + readonly max: number | null; + readonly p95: number | null; + }; + readonly search: { + readonly max: number | null; + readonly p95: number | null; + }; + }; +} + +const sourceUrl = new URL('./xtream-ui-action-probe.ts', import.meta.url); +const modulePromise = import(sourceUrl.href) + .then((module) => module as unknown as UiActionModule) + .catch(() => null); + +test('exports the fixed background interaction sequence', async () => { + const module = await modulePromise; + + assert.ok(module, 'Xtream UI action probe module must exist'); + assert.equal(typeof module.runXtreamBackgroundUiActions, 'function'); + assert.deepEqual(module.XTREAM_UI_ACTION_ORDER, [ + 'type-filter', + 'dashboard', + 'sources', + 'name-a-z', + 'global-search', + ]); +}); + +test('requires the full refresh lifecycle before and after every action', () => { + const source = readFileSync(sourceUrl, 'utf8'); + + assert.match(source, /assertXtreamBackgroundRefreshActive/); + assert.match(source, /refresh-lifecycle/); + assert.doesNotMatch(source, /assertXtreamBackgroundDeleteActive/); + assert.match(source, /activeBefore/); + assert.match(source, /activeAfter/); +}); + +test('uses the concrete UI controls and two-frame paint gates', () => { + const source = readFileSync(sourceUrl, 'utf8'); + + assert.match(source, /app-workspace-sources-filters-panel/); + assert.match(source, /Xtream/); + assert.match(source, /workspace\/dashboard/); + assert.match(source, /lib-workspace-dashboard-rails/); + assert.match(source, /workspace-loading-overlay/); + assert.match(source, /workspace\/sources/); + assert.match(source, /input\[type="search"\]/); + assert.match(source, /Name \(A-Z\)/); + assert.match(source, /requestAnimationFrame/); +}); + +test('keeps the debounced search last and finishes only after its result paint', async () => { + const module = await modulePromise; + assert.ok(module?.runXtreamBackgroundUiActions); + const page = new SearchCompletionFakePage(); + + await module.runXtreamBackgroundUiActions(page, { + operationId: 'delete-operation-1', + playlistId: 'playlist-1', + searchTerm: 'Performance', + }); + + assert.equal( + page.searchGateObserved, + true, + 'the current input-only gate lets the probe finish before the app-owned 350 ms q/filter update' + ); + assert.equal( + page.searchRestored, + false, + 'an unmeasured search reset must not consume the remaining delete window' + ); + assert.deepEqual(page.searchPaintOrder, [ + 'app-owned-search-complete', + 'two-rendered-frames', + ]); +}); + +test('retains raw samples and summarizes navigation, search, and other actions separately', async () => { + const module = await modulePromise; + assert.ok(module?.summarizeXtreamUiActionSamples); + const samples = [ + sample('action', 'type-filter', 10), + sample('navigation', 'dashboard', 20), + sample('navigation', 'sources', 40), + sample('action', 'name-a-z', 50), + sample('search', 'global-search', 30), + ] as const; + + const summary = module.summarizeXtreamUiActionSamples(samples); + + assert.equal(summary.action.max, 50); + assert.equal(summary.navigation.max, 40); + assert.equal(summary.search.max, 30); + assert.ok((summary.action.p95 ?? 0) >= 10); + assert.ok((summary.navigation.p95 ?? 0) >= 20); +}); + +function sample( + category: 'action' | 'navigation' | 'search', + kind: string, + latencyMs: number +) { + return { + category, + endEpochMs: 100 + latencyMs, + kind, + latencyMs, + startEpochMs: 100, + }; +} + +class SearchCompletionFakePage { + private epochMs = 100; + private searchWaitVisited = false; + private searchResultsApplied = false; + readonly searchPaintOrder: string[] = []; + searchGateObserved = false; + searchRestored = false; + + async evaluate(callback: (...args: never[]) => unknown): Promise { + const source = callback.toString(); + if ( + this.searchWaitVisited && + source.includes('requestAnimationFrame') && + this.searchPaintOrder.length === 1 + ) { + this.searchPaintOrder.push('two-rendered-frames'); + } + this.epochMs += 1; + return this.epochMs as T; + } + + async waitForFunction( + callback: (...args: never[]) => unknown, + argument?: unknown + ): Promise { + const searchTerm = recordString(argument, 'searchTerm'); + if (searchTerm === null) return; + this.searchWaitVisited = true; + const source = callback.toString(); + if (searchTerm.length === 0) { + this.searchRestored = + /searchParams\.has\((['"])q\1\)/u.test(source) && + source.includes('sort-trigger'); + if (this.searchRestored) this.searchResultsApplied = false; + return; + } + this.searchGateObserved = + /searchParams\.get\((['"])q\1\)/u.test(source) && + source.includes('app-playlist-item') && + source.includes('no-results-state'); + if (this.searchGateObserved) { + this.searchResultsApplied = true; + this.searchPaintOrder.push('app-owned-search-complete'); + } + } + + getByRole(): { waitFor(): Promise } { + if (this.searchResultsApplied) { + throw new Error('sort-control-hidden-by-filtered-no-results'); + } + return { waitFor: async () => undefined }; + } +} + +function recordString(value: unknown, key: string): string | null { + if (!value || typeof value !== 'object') return null; + const candidate = (value as Record)[key]; + return typeof candidate === 'string' ? candidate : null; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts new file mode 100644 index 000000000..5d382c55c --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts @@ -0,0 +1,359 @@ +import type { Page } from '@playwright/test'; + +import type { NumericDistribution } from './xtream-benchmark-contract'; +import { summarizeNumbers } from './performance-statistics'; +import { assertXtreamBackgroundRefreshActive } from './xtream-renderer-probe'; + +export const XTREAM_BACKGROUND_WINDOW = 'refresh-lifecycle'; +export const XTREAM_UI_ACTION_ORDER = Object.freeze([ + 'type-filter', + 'dashboard', + 'sources', + 'name-a-z', + 'global-search', +] as const); + +export type XtreamUiActionKind = (typeof XTREAM_UI_ACTION_ORDER)[number]; +export type XtreamUiActionCategory = 'action' | 'navigation' | 'search'; + +export interface XtreamUiActionSample { + readonly activeAfter: true; + readonly activeBefore: true; + readonly category: XtreamUiActionCategory; + readonly endEpochMs: number; + readonly kind: XtreamUiActionKind; + readonly latencyMs: number; + readonly startEpochMs: number; +} + +export interface XtreamUiActionSummary { + readonly action: NumericDistribution; + readonly navigation: NumericDistribution; + readonly search: NumericDistribution; +} + +export interface XtreamUiActionProbeResult extends XtreamUiActionSummary { + readonly dashboardPaintVisibility: 'visible-without-overlay'; + readonly samples: readonly XtreamUiActionSample[]; +} + +export interface XtreamUiActionProbeOptions { + readonly operationId: string; + readonly playlistId: string; + readonly searchTerm: string; +} + +export async function runXtreamBackgroundUiActions( + page: Page, + options: XtreamUiActionProbeOptions +): Promise { + const samples: XtreamUiActionSample[] = []; + samples.push( + await measureAction( + page, + options, + 'type-filter', + 'action', + async () => { + const start = await clickTextOption( + page, + 'app-workspace-sources-filters-panel .option-row', + '.option-label', + 'Xtream' + ); + await page.waitForFunction(() => { + const rows = Array.from( + document.querySelectorAll( + 'app-workspace-sources-filters-panel .option-row.selected' + ) + ); + return rows.some( + (row) => + row + .querySelector('.option-label') + ?.textContent?.trim() === 'Xtream' + ); + }); + return start; + } + ) + ); + samples.push( + await measureAction( + page, + options, + 'dashboard', + 'navigation', + async () => { + const start = await clickSelector( + page, + 'a.brand[href$="/workspace/dashboard"]' + ); + await page.waitForFunction(() => { + const overlay = document.querySelector( + '.workspace-loading-overlay' + ); + const dashboard = document.querySelector( + 'lib-workspace-dashboard-rails' + ); + return ( + location.pathname.endsWith('/workspace/dashboard') && + visible(dashboard) && + !visible(overlay) + ); + function visible(element: Element | null): boolean { + return ( + element instanceof HTMLElement && + element.getClientRects().length > 0 + ); + } + }); + return start; + } + ) + ); + samples.push( + await measureAction( + page, + options, + 'sources', + 'navigation', + async () => { + const start = await clickSelector( + page, + 'a[href$="/workspace/sources"]' + ); + await page.waitForFunction( + () => + location.pathname.endsWith('/workspace/sources') && + document.querySelector('app-workspace-sources') !== null + ); + return start; + } + ) + ); + samples.push(await measureNameSort(page, options)); + samples.push( + await measureAction( + page, + options, + 'global-search', + 'search', + async () => { + const start = await setSearchInput(page, options.searchTerm); + await page.waitForFunction( + ({ searchTerm }) => { + const input = document.querySelector( + 'app-workspace-shell-header .search-field input[type="search"]' + ); + const normalizedSearchTerm = searchTerm + .trim() + .toLowerCase(); + const searchParams = new URLSearchParams( + location.search + ); + const resultRows = Array.from( + document.querySelectorAll( + 'app-workspace-sources app-playlist-item' + ) + ); + const resultPainted = + resultRows.length > 0 + ? resultRows.every((row) => + ( + row.querySelector('.playlist-title') + ?.textContent ?? '' + ) + .trim() + .toLowerCase() + .includes(normalizedSearchTerm) + ) + : visible( + document.querySelector( + 'app-workspace-sources .no-results-state' + ) + ); + return ( + input instanceof HTMLInputElement && + input.value === searchTerm && + searchParams.get('q') === searchTerm.trim() && + resultPainted + ); + function visible(element: Element | null): boolean { + return ( + element instanceof HTMLElement && + element.getClientRects().length > 0 + ); + } + }, + { searchTerm: options.searchTerm } + ); + return start; + } + ) + ); + assertExactActionOrder(samples); + const summary = summarizeXtreamUiActionSamples(samples); + return Object.freeze({ + ...summary, + dashboardPaintVisibility: 'visible-without-overlay', + samples: Object.freeze([...samples]), + }); +} + +export function summarizeXtreamUiActionSamples( + samples: readonly Pick< + XtreamUiActionSample, + 'category' | 'endEpochMs' | 'kind' | 'latencyMs' | 'startEpochMs' + >[] +): XtreamUiActionSummary { + const values = (category: XtreamUiActionCategory): readonly number[] => + samples + .filter((sample) => sample.category === category) + .map((sample) => sample.latencyMs); + return Object.freeze({ + action: summarizeNumbers(values('action')), + navigation: summarizeNumbers(values('navigation')), + search: summarizeNumbers(values('search')), + }); +} + +async function measureAction( + page: Page, + options: Pick, + kind: XtreamUiActionKind, + category: XtreamUiActionCategory, + triggerAndWait: () => Promise +): Promise { + await assertXtreamBackgroundRefreshActive(page, options); + const activeBefore = true as const; + const startEpochMs = await triggerAndWait(); + const endEpochMs = await waitForTwoRenderedFrames(page); + await assertXtreamBackgroundRefreshActive(page, options); + const activeAfter = true as const; + if ( + !Number.isFinite(startEpochMs) || + !Number.isFinite(endEpochMs) || + endEpochMs < startEpochMs + ) { + throw new Error(`xtream-ui-action-invalid:${kind}`); + } + return Object.freeze({ + activeAfter, + activeBefore, + category, + endEpochMs, + kind, + latencyMs: endEpochMs - startEpochMs, + startEpochMs, + }); +} + +async function measureNameSort( + page: Page, + options: Pick +): Promise { + return measureAction(page, options, 'name-a-z', 'action', async () => { + const start = await clickSelector( + page, + 'app-workspace-sources .sort-trigger' + ); + await page + .getByRole('menuitem', { name: 'Name (A-Z)' }) + .waitFor({ state: 'visible' }); + await clickTextOption(page, '[role="menuitem"]', 'span', 'Name (A-Z)'); + await page.waitForFunction( + () => + document + .querySelector('app-workspace-sources .sort-trigger__label') + ?.textContent?.trim() === 'Name (A-Z)' + ); + return start; + }); +} + +async function clickSelector(page: Page, selector: string): Promise { + return page.evaluate((selector) => { + const element = document.querySelector(selector); + if (!(element instanceof HTMLElement)) { + throw new Error('xtream-ui-action-target-unavailable'); + } + const startedAt = performance.timeOrigin + performance.now(); + element.click(); + return startedAt; + }, selector); +} + +async function clickTextOption( + page: Page, + rowSelector: string, + labelSelector: string, + expectedLabel: string +): Promise { + return page.evaluate( + ({ expectedLabel, labelSelector, rowSelector }) => { + const match = Array.from( + document.querySelectorAll(rowSelector) + ).find( + (element) => + element + .querySelector(labelSelector) + ?.textContent?.trim() === expectedLabel + ); + if (!(match instanceof HTMLElement)) { + throw new Error('xtream-ui-action-option-unavailable'); + } + const startedAt = performance.timeOrigin + performance.now(); + match.click(); + return startedAt; + }, + { expectedLabel, labelSelector, rowSelector } + ); +} + +async function setSearchInput(page: Page, value: string): Promise { + return page.evaluate((value) => { + const input = document.querySelector( + 'app-workspace-shell-header .search-field input[type="search"]' + ); + if (!(input instanceof HTMLInputElement) || input.disabled) { + throw new Error('xtream-ui-search-unavailable'); + } + const startedAt = performance.timeOrigin + performance.now(); + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value' + )?.set; + setter?.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + return startedAt; + }, value); +} + +async function waitForTwoRenderedFrames(page: Page): Promise { + return page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(performance.timeOrigin + performance.now()); + }); + }); + }) + ); +} + +function assertExactActionOrder( + samples: readonly XtreamUiActionSample[] +): void { + if ( + samples.length !== XTREAM_UI_ACTION_ORDER.length || + samples.some( + (sample, index) => sample.kind !== XTREAM_UI_ACTION_ORDER[index] + ) + ) { + throw new Error( + `xtream-ui-action-order-invalid:${XTREAM_BACKGROUND_WINDOW}` + ); + } +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.spec.ts new file mode 100644 index 000000000..83a309d89 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.spec.ts @@ -0,0 +1,209 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { parseXtreamWorkerPerformancePhaseEvents } from './xtream-worker-phase-events'; + +function phasePair( + phase: string, + startEpochMs: number, + itemCount: number, + requestId = 'request-1' +) { + return [ + { + boundary: 'start', + durationMs: null, + epochMs: startEpochMs, + phase, + requestId, + }, + { + boundary: 'end', + durationMs: 2, + epochMs: startEpochMs + 2, + metadata: { itemCount }, + phase, + requestId, + }, + ]; +} + +describe('Xtream worker performance phase parser', () => { + it('accepts each exact finalized operation sequence', () => { + const fixtures = [ + ['DB_GET_CATEGORIES', phasePair('sqlite.categories.read', 11, 100)], + [ + 'DB_SAVE_CATEGORIES', + [ + ...phasePair('normalize.categories', 11, 100), + ...phasePair( + 'sqlite.categories.write-transactions', + 14, + 100 + ), + ], + ], + ['DB_GET_CONTENT', phasePair('sqlite.content.read', 11, 60_000)], + [ + 'DB_SAVE_CONTENT', + [ + ...phasePair('sqlite.content.category-map-read', 11, 60), + ...phasePair('normalize.content', 14, 60_000), + ...phasePair( + 'sqlite.content.write-transactions', + 17, + 60_000 + ), + ], + ], + [ + 'DB_CLEAR_XTREAM_IMPORT_CACHE', + phasePair( + 'sqlite.xtream-cache-clear.write-transactions', + 11, + 60_060 + ), + ], + [ + 'DB_DELETE_XTREAM_CONTENT', + [ + ...phasePair( + 'sqlite.xtream-delete.collect-user-data', + 11, + 60_060 + ), + ...phasePair( + 'sqlite.xtream-delete.write-transactions', + 14, + 60_060 + ), + ], + ], + [ + 'DB_DELETE_PLAYLIST', + [ + ...phasePair( + 'sqlite.playlist-delete.collect-ids', + 11, + 60_060 + ), + ...phasePair( + 'sqlite.playlist-delete.write-transactions', + 14, + 60_061 + ), + ], + ], + [ + 'DB_SEARCH_CONTENT', + [ + ...phasePair('sqlite.search.query', 11, 200), + ...phasePair('normalize.search-rank', 14, 50), + ], + ], + ] as const; + + for (const [operation, events] of fixtures) { + assert.deepEqual( + parseXtreamWorkerPerformancePhaseEvents( + operation, + events, + 10, + 30, + true + ), + events + ); + } + }); + + it('returns undefined for non-Xtream operations and null for invalid Xtream events', () => { + assert.equal( + parseXtreamWorkerPerformancePhaseEvents( + 'DB_GET_APP_PLAYLIST', + [], + 10, + 30, + true + ), + undefined + ); + + const valid = phasePair('sqlite.categories.read', 11, 100); + for (const invalid of [ + [], + valid.slice(0, 1), + [...valid].reverse(), + [ + valid[0], + { ...valid[1], metadata: { itemCount: 100, secret: 'no' } }, + ], + [valid[0], { ...valid[1], epochMs: 9 }], + [valid[0], { ...valid[1], requestId: 'request-2' }], + [valid[0], { ...valid[1], phase: 'sqlite.write' }], + [valid[0], { ...valid[1], durationMs: -1 }], + ]) { + assert.equal( + parseXtreamWorkerPerformancePhaseEvents( + 'DB_GET_CATEGORIES', + invalid, + 10, + 30, + true + ), + null + ); + } + }); + + it('allows missing end count metadata only for unsuccessful partial captures', () => { + const partial = [ + { + boundary: 'start', + durationMs: null, + epochMs: 11, + phase: 'sqlite.categories.read', + requestId: 'request-1', + }, + { + boundary: 'end', + durationMs: 2, + epochMs: 13, + phase: 'sqlite.categories.read', + requestId: 'request-1', + }, + ]; + + assert.equal( + parseXtreamWorkerPerformancePhaseEvents( + 'DB_GET_CATEGORIES', + partial, + 10, + 30, + true + ), + null + ); + assert.deepEqual( + parseXtreamWorkerPerformancePhaseEvents( + 'DB_GET_CATEGORIES', + partial, + 10, + 30, + false + ), + partial + ); + + assert.deepEqual( + parseXtreamWorkerPerformancePhaseEvents( + 'DB_SAVE_CONTENT', + phasePair('sqlite.content.category-map-read', 11, 60), + 10, + 30, + false + ), + phasePair('sqlite.content.category-map-read', 11, 60) + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.ts new file mode 100644 index 000000000..9955c5a35 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.ts @@ -0,0 +1,176 @@ +import { + XTREAM_DATABASE_PERFORMANCE_PHASE, + type PerformancePhaseEvent, + type PerformancePhaseMetadata, + type XtreamDatabasePerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { fitsPerformanceDurationEnvelope } from './performance-phase-duration-envelope'; + +const PHASE_SEQUENCES = { + DB_CLEAR_XTREAM_IMPORT_CACHE: [ + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + ], + DB_DELETE_PLAYLIST: [ + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + ], + DB_DELETE_XTREAM_CONTENT: [ + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + ], + DB_GET_CATEGORIES: [ + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ, + ], + DB_GET_CONTENT: [XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ], + DB_SAVE_CATEGORIES: [ + XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CATEGORIES, + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + ], + DB_SAVE_CONTENT: [ + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CONTENT, + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + ], + DB_SEARCH_CONTENT: [ + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY, + XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_SEARCH_RANK, + ], +} as const satisfies Readonly< + Record +>; +const EVENT_KEYS = new Set([ + 'boundary', + 'durationMs', + 'epochMs', + 'metadata', + 'phase', + 'requestId', +]); + +export function parseXtreamWorkerPerformancePhaseEvents( + operation: string | null, + input: unknown, + workStartedEpochMs: number, + workEndedEpochMs: number, + requestSucceeded: boolean +): + | readonly PerformancePhaseEvent[] + | null + | undefined { + const phaseSequence = + operation === null + ? undefined + : PHASE_SEQUENCES[operation as keyof typeof PHASE_SEQUENCES]; + if (!phaseSequence) { + return undefined; + } + const completeEventCount = phaseSequence.length * 2; + if ( + !Array.isArray(input) || + input.length === 0 || + input.length % 2 !== 0 || + input.length > completeEventCount || + (requestSucceeded && input.length !== completeEventCount) + ) { + return null; + } + + const parsed: PerformancePhaseEvent[] = []; + let lastEpochMs = workStartedEpochMs; + for (let index = 0; index < input.length; index += 1) { + const raw = input[index]; + if ( + !isRecord(raw) || + Object.keys(raw).some((key) => !EVENT_KEYS.has(key)) + ) { + return null; + } + const boundary = raw['boundary']; + const durationMs = raw['durationMs']; + const epochMs = raw['epochMs']; + const metadata = parseMetadata(raw['metadata']); + const phase = raw['phase']; + const requestId = raw['requestId']; + if ( + boundary !== (index % 2 === 0 ? 'start' : 'end') || + phase !== phaseSequence[Math.floor(index / 2)] || + typeof requestId !== 'string' || + requestId.length === 0 || + !isFiniteNonNegativeNumber(epochMs) || + epochMs < lastEpochMs || + epochMs > workEndedEpochMs || + (boundary === 'start' && + (durationMs !== null || metadata !== undefined)) || + (boundary === 'end' && !isFiniteNonNegativeNumber(durationMs)) || + (boundary === 'end' && + requestSucceeded && + metadata?.itemCount === undefined) || + metadata === null || + (parsed.length > 0 && parsed[0]?.requestId !== requestId) + ) { + return null; + } + const phaseStart = parsed[parsed.length - 1]; + if ( + boundary === 'end' && + (!phaseStart || + !fitsPerformanceDurationEnvelope( + Number(durationMs), + workStartedEpochMs, + workEndedEpochMs + )) + ) { + return null; + } + parsed.push({ + boundary: boundary as 'end' | 'start', + durationMs: durationMs as number | null, + epochMs, + ...(metadata === undefined ? {} : { metadata }), + phase: phase as XtreamDatabasePerformancePhase, + requestId, + }); + lastEpochMs = epochMs; + } + return parsed; +} + +function parseMetadata( + input: unknown +): PerformancePhaseMetadata | undefined | null { + if (input === undefined) { + return undefined; + } + if ( + !isRecord(input) || + Object.keys(input).some( + (key) => key !== 'byteCount' && key !== 'itemCount' + ) + ) { + return null; + } + const byteCount = input['byteCount']; + const itemCount = input['itemCount']; + if ( + (byteCount !== undefined && !isSafeCount(byteCount)) || + (itemCount !== undefined && !isSafeCount(itemCount)) + ) { + return null; + } + return { + ...(byteCount === undefined ? {} : { byteCount: Number(byteCount) }), + ...(itemCount === undefined ? {} : { itemCount: Number(itemCount) }), + }; +} + +function isRecord(input: unknown): input is Record { + return typeof input === 'object' && input !== null && !Array.isArray(input); +} + +function isFiniteNonNegativeNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function isSafeCount(value: unknown): boolean { + return Number.isSafeInteger(value) && Number(value) >= 0; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-phase-evidence.fixture.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-evidence.fixture.ts new file mode 100644 index 000000000..22cb34cbe --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-evidence.fixture.ts @@ -0,0 +1,235 @@ +import type { + DatabaseWorkerPerformancePhase, + PerformancePhaseEvent, +} from '@iptvnator/shared/interfaces'; + +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import type { + XtreamAttributionPhaseEvent, + XtreamPhaseAttributionInput, +} from './xtream-phase-attribution'; +import { XTREAM_ATTRIBUTION_PHASE as PHASE } from './xtream-phase-inventory'; +import { normalizeWorkerRequestPerformanceOutcome } from './worker-request-performance'; + +const WORKER_PHASES = new Set([ + PHASE.DESERIALIZE_PLAYLIST, + PHASE.NORMALIZE_CATEGORIES, + PHASE.NORMALIZE_CONTENT, + PHASE.NORMALIZE_SEARCH_RANK, + PHASE.SERIALIZE_PLAYLIST, + PHASE.SQLITE_CATEGORIES_READ, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.SQLITE_CONTENT_READ, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + PHASE.SQLITE_GENERIC_READ, + PHASE.SQLITE_GENERIC_WRITE, + PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + PHASE.SQLITE_SEARCH_QUERY, + PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, +]); + +interface WorkerGroup { + readonly correlationId: string; + readonly events: readonly XtreamAttributionPhaseEvent[]; + readonly phaseSignature: string; +} + +export function bindFixtureWorkerEvidence( + input: XtreamPhaseAttributionInput, + requests: readonly WorkerRequestPerformanceMetrics[] +): { + readonly phaseCapture: XtreamPhaseAttributionInput; + readonly requests: readonly WorkerRequestPerformanceMetrics[]; +} { + const groups = workerGroups(input.events); + const unused = new Set(groups); + const replacements = new Map< + string, + readonly XtreamAttributionPhaseEvent[] + >(); + const bound = requests.map((request) => { + if (request.phaseEvents.length === 0) return request; + const phaseSignature = signature( + request.phaseEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ phase }) => phase) + ); + const candidates = groups.filter( + (group) => + unused.has(group) && group.phaseSignature === phaseSignature + ); + const matched = + candidates.find((group) => sameCounts(group, request)) ?? + candidates[0]; + if (!matched) fixtureError(); + unused.delete(matched); + const events = replaceItemCounts(matched, request); + replacements.set(matched.correlationId, events); + return alignRequest(request, matched.correlationId, events); + }); + if (unused.size !== 0) fixtureError(); + return { + phaseCapture: { + ...input, + events: input.events.map((event) => { + if (!WORKER_PHASES.has(event.phase)) return event; + const replacement = replacements + .get(event.correlationId) + ?.find( + (candidate) => + candidate.phase === event.phase && + candidate.boundary === event.boundary + ); + if (!replacement) fixtureError(); + return replacement; + }), + }, + requests: resequenceFixtureIpcCallIds(bound), + }; +} + +function resequenceFixtureIpcCallIds( + requests: readonly WorkerRequestPerformanceMetrics[] +): readonly WorkerRequestPerformanceMetrics[] { + const correlated = requests + .filter( + (request) => + request.ipcCallId !== null && request.sourceEpochMs !== null + ) + .sort( + (left, right) => + Number(left.sourceEpochMs) - Number(right.sourceEpochMs) || + Number(left.ipcCallId) - Number(right.ipcCallId) + ); + const ids = correlated + .map(({ ipcCallId }) => Number(ipcCallId)) + .sort((left, right) => left - right); + const reassigned = new Map( + correlated.map((request, index) => [request, ids[index]]) + ); + return requests.map((request) => { + const ipcCallId = reassigned.get(request); + return ipcCallId === undefined ? request : { ...request, ipcCallId }; + }); +} + +function workerGroups( + events: readonly XtreamAttributionPhaseEvent[] +): readonly WorkerGroup[] { + const grouped = new Map(); + for (const event of events) { + if (!WORKER_PHASES.has(event.phase)) continue; + grouped.set(event.correlationId, [ + ...(grouped.get(event.correlationId) ?? []), + event, + ]); + } + return [...grouped.entries()].map(([correlationId, selected]) => ({ + correlationId, + events: selected, + phaseSignature: signature( + selected + .filter(({ boundary }) => boundary === 'end') + .map(({ phase }) => phase) + ), + })); +} + +function sameCounts( + group: WorkerGroup, + request: WorkerRequestPerformanceMetrics +): boolean { + const captured = group.events + .filter(({ boundary }) => boundary === 'end') + .map(({ itemCount }) => itemCount); + const requested = request.phaseEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ metadata }) => metadata?.itemCount ?? null); + return JSON.stringify(captured) === JSON.stringify(requested); +} + +function replaceItemCounts( + group: WorkerGroup, + request: WorkerRequestPerformanceMetrics +): readonly XtreamAttributionPhaseEvent[] { + const counts = request.phaseEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ metadata }) => metadata?.itemCount ?? null); + let endIndex = 0; + return group.events.map((event) => ({ + ...event, + itemCount: + event.boundary === 'end' ? (counts[endIndex++] ?? null) : null, + })); +} + +function alignRequest( + request: WorkerRequestPerformanceMetrics, + requestId: string, + events: readonly XtreamAttributionPhaseEvent[] +): WorkerRequestPerformanceMetrics { + const phaseEvents = events.map( + (event): PerformancePhaseEvent => ({ + boundary: event.boundary, + durationMs: event.durationMs, + epochMs: event.epochMs, + ...(event.boundary === 'end' && event.itemCount !== null + ? { metadata: { itemCount: event.itemCount } } + : {}), + phase: event.phase as DatabaseWorkerPerformancePhase, + requestId, + }) + ); + const firstEpochMs = Math.min(...events.map(({ epochMs }) => epochMs)); + const lastEpochMs = Math.max(...events.map(({ epochMs }) => epochMs)); + const requestReceivedEpochMs = firstEpochMs - 2; + const workStartedEpochMs = firstEpochMs - 1; + const workEndedEpochMs = lastEpochMs + 0.25; + const histogramFlushedEpochMs = + request.histogramFlushedEpochMs === null + ? null + : workEndedEpochMs + 0.25; + const responsePostedEpochMs = workEndedEpochMs + 0.5; + return normalizeWorkerRequestPerformanceOutcome({ + ipcCallId: request.ipcCallId, + operation: request.operation, + operationId: request.operationId, + operationIdUnavailableReason: request.operationIdUnavailableReason, + performanceCapture: { + eventLoopDelay: request.eventLoopDelay, + eventLoopDelayUnavailableReason: + request.eventLoopDelayUnavailableReason, + eventLoopUtilization: request.eventLoopUtilization, + eventLoopUtilizationUnavailableReason: + request.eventLoopUtilizationUnavailableReason, + histogramFlushedEpochMs, + invalidReason: request.invalidReason, + phaseEvents, + requestReceivedEpochMs, + responsePostedEpochMs, + threadCpuSystemMicros: request.threadCpuSystemMicros, + threadCpuUnavailableReason: request.threadCpuUnavailableReason, + threadCpuUserMicros: request.threadCpuUserMicros, + workEndedEpochMs, + workStartedEpochMs, + }, + playlistId: request.playlistId, + requestId, + responseEpochMs: responsePostedEpochMs + 0.25, + sourceEpochMs: + request.ipcCallId === null ? null : requestReceivedEpochMs - 0.25, + success: request.success, + }); +} + +function signature(phases: readonly string[]): string { + return phases.join('\0'); +} + +function fixtureError(): never { + throw new Error('invalid Xtream worker phase fixture binding'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-phase-topology.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-topology.ts new file mode 100644 index 000000000..abe106362 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-phase-topology.ts @@ -0,0 +1,99 @@ +import { XTREAM_ATTRIBUTION_PHASE as PHASE } from './xtream-phase-inventory'; +import type { XtreamTopologyPhaseSpan } from './xtream-phase-topology'; + +const CATEGORY_PHASES = new Set([ + PHASE.NORMALIZE_CATEGORIES, + PHASE.SQLITE_CATEGORIES_READ, + PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, +]); +const CONTENT_PHASES = new Set([ + PHASE.NORMALIZE_CONTENT, + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.SQLITE_CONTENT_READ, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, +]); +const WORKER_REQUEST_SIGNATURES = [ + [PHASE.SERIALIZE_PLAYLIST, PHASE.SQLITE_GENERIC_WRITE], + [PHASE.SQLITE_GENERIC_READ, PHASE.DESERIALIZE_PLAYLIST], + [PHASE.SQLITE_CATEGORIES_READ], + [PHASE.NORMALIZE_CATEGORIES, PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS], + [PHASE.SQLITE_CONTENT_READ], + [ + PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + PHASE.NORMALIZE_CONTENT, + PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + ], + [ + PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + ], + [PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS], + [ + PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + ], + [PHASE.SQLITE_SEARCH_QUERY, PHASE.NORMALIZE_SEARCH_RANK], +] as const; +const WORKER_PHASES = new Set(WORKER_REQUEST_SIGNATURES.flat()); +const WORKER_SIGNATURES = new Set( + WORKER_REQUEST_SIGNATURES.map(phaseSignature) +); + +export function assertXtreamWorkerCorrelationOwnership( + spans: readonly XtreamTopologyPhaseSpan[] +): void { + const groups = new Map(); + for (const span of spans) { + if (!WORKER_PHASES.has(span.phase)) continue; + if (!hasExpectedWorkerSource(span)) invalid(); + groups.set(span.correlationId, [ + ...(groups.get(span.correlationId) ?? []), + span, + ]); + } + for (const group of groups.values()) { + if ( + new Set(group.map(({ semanticType }) => semanticType)).size !== 1 || + new Set(group.map(sourceDomain)).size !== 1 || + !WORKER_SIGNATURES.has( + phaseSignature(group.map(({ phase }) => phase)) + ) + ) { + invalid(); + } + } +} + +function sourceDomain(span: XtreamTopologyPhaseSpan): string { + return `${span.providerAction ?? ''}\0${span.categoryType ?? ''}\0${span.contentType ?? ''}`; +} + +function hasExpectedWorkerSource(span: XtreamTopologyPhaseSpan): boolean { + if (CATEGORY_PHASES.has(span.phase)) { + return ( + span.providerAction === null && + span.categoryType !== null && + span.contentType === null + ); + } + if (CONTENT_PHASES.has(span.phase)) { + return ( + span.providerAction === null && + span.categoryType === null && + span.contentType !== null + ); + } + return ( + span.providerAction === null && + span.categoryType === null && + span.contentType === null + ); +} + +function phaseSignature(phases: readonly string[]): string { + return [...phases].sort().join('\0'); +} + +function invalid(): never { + throw new Error('invalid worker correlation ownership'); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-evidence.fixtures.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-evidence.fixtures.ts new file mode 100644 index 000000000..f7ff9f351 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-evidence.fixtures.ts @@ -0,0 +1,245 @@ +import type { + DatabaseWorkerPerformancePhase, + PerformancePhaseEvent, +} from '@iptvnator/shared/interfaces'; + +import { + expectedXtreamWorkerRequestInventory, + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import { + XTREAM_WORKER_REQUEST_IDENTITY_KIND, + xtreamWorkerRequestIdentityKind, +} from './xtream-worker-request-identity'; +import { normalizeWorkerRequestPerformanceOutcome } from './worker-request-performance'; + +export function workerRequestEvidence( + scenarioId: XtreamScenarioId +): WorkerRequestPerformanceMetrics[] { + let requestOrdinal = 0; + const operationOrdinals = new Map(); + return expectedXtreamWorkerRequestInventory(scenarioId).flatMap((entry) => + Array.from({ length: entry.count }, () => { + requestOrdinal += 1; + const operationOrdinal = + (operationOrdinals.get(entry.operation) ?? 0) + 1; + operationOrdinals.set(entry.operation, operationOrdinal); + return request( + scenarioId, + entry.operation, + entry.phases, + entry.success, + operationOrdinal, + requestOrdinal + ); + }) + ); +} + +export function invalidateWorkerRequestMetrics( + requests: readonly WorkerRequestPerformanceMetrics[], + count: number +): WorkerRequestPerformanceMetrics[] { + return requests.map((request, index) => + index >= count + ? request + : { + ...request, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + 'overlapping-database-worker-requests', + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + 'overlapping-database-worker-requests', + histogramFlushedEpochMs: null, + invalidReason: 'overlapping-database-worker-requests', + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + 'overlapping-database-worker-requests', + threadCpuUserMicros: null, + } + ); +} + +function request( + scenarioId: XtreamScenarioId, + operation: string, + phases: readonly string[], + success: boolean, + operationOrdinal: number, + requestOrdinal: number +): WorkerRequestPerformanceMetrics { + const requestId = `request-${requestOrdinal}`; + const start = 1_000 + requestOrdinal * 20; + const phaseEvents = createPhaseEvents( + phases, + phaseItemCounts(scenarioId, operation, operationOrdinal), + requestId, + start + 2, + success + ); + const identity = requestIdentity( + scenarioId, + operation, + operationOrdinal, + requestOrdinal, + start + ); + return normalizeWorkerRequestPerformanceOutcome({ + ...identity, + operation, + performanceCapture: { + eventLoopDelay: { maxMs: 9, p95Ms: 3, p99Ms: 5 }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.5, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: start + 13, + invalidReason: null, + phaseEvents, + requestReceivedEpochMs: start, + responsePostedEpochMs: start + 14, + threadCpuSystemMicros: 10, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 20, + workEndedEpochMs: start + 12, + workStartedEpochMs: start + 1, + }, + playlistId: 'synthetic-playlist', + requestId, + responseEpochMs: start + 15, + success, + }); +} + +function requestIdentity( + scenarioId: XtreamScenarioId, + operation: string, + operationOrdinal: number, + requestOrdinal: number, + start: number +) { + const kind = xtreamWorkerRequestIdentityKind(operation); + if (kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.NOT_APPLICABLE) { + return { + ipcCallId: null, + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-not-applicable', + sourceEpochMs: null, + } as const; + } + const correlatedLegacy = + kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.LEGACY_PRELOAD && + (scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE || + scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI) && + (operation === 'DB_UPSERT_APP_PLAYLIST' || operationOrdinal === 1); + const emptyDeleteFollowUp = + operation === 'DB_DELETE_PLAYLIST' && operationOrdinal === 2; + const operationId = + (kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.PRODUCER_OPERATION || + correlatedLegacy) && + !emptyDeleteFollowUp + ? `operation-${requestOrdinal}` + : null; + return { + ipcCallId: requestOrdinal, + operationId, + operationIdUnavailableReason: + kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.LEGACY_PRELOAD && + !correlatedLegacy + ? 'preload-performance-marker-uncorrelated' + : null, + sourceEpochMs: start - 1, + } as const; +} + +function createPhaseEvents( + phases: readonly string[], + itemCounts: readonly (number | null)[], + requestId: string, + firstEpochMs: number, + success: boolean +): PerformancePhaseEvent[] { + return phases.flatMap((phase, index) => { + const startEpochMs = firstEpochMs + index * 2; + const itemCount = itemCounts[index] ?? null; + const omitCancelledWriteCount = !success && index === phases.length - 1; + return [ + { + boundary: 'start' as const, + durationMs: null, + epochMs: startEpochMs, + phase: phase as DatabaseWorkerPerformancePhase, + requestId, + }, + { + boundary: 'end' as const, + durationMs: 1, + epochMs: startEpochMs + 1, + ...(!omitCancelledWriteCount && itemCount !== null + ? { metadata: { itemCount } } + : {}), + phase: phase as DatabaseWorkerPerformancePhase, + requestId, + }, + ]; + }); +} + +function phaseItemCounts( + scenarioId: XtreamScenarioId, + operation: string, + ordinal: number +): readonly (number | null)[] { + if (operation === 'DB_UPSERT_APP_PLAYLIST') return [null, 1]; + if (operation === 'DB_GET_APP_PLAYLIST') return [1, 0]; + if (operation === 'DB_DELETE_XTREAM_CONTENT') return [100_100, 100_100]; + if (operation === 'DB_DELETE_PLAYLIST') { + return ordinal === 1 ? [100_100, 100_101] : [0, 1]; + } + if (operation === 'DB_GET_CATEGORIES') { + return ( + [[0], [60], [0], [20], [0], [20], [60], [20], [20]][ordinal - 1] ?? + [] + ); + } + if (operation === 'DB_SAVE_CATEGORIES') { + const count = [60, 20, 20][ordinal - 1] ?? 0; + return [count, count]; + } + if (operation === 'DB_GET_CONTENT') { + if (scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT) return [0]; + return ( + [ + [0], + [60_000], + [0], + [20_000], + [0], + [20_000], + [60_000], + [20_000], + [20_000], + ][ordinal - 1] ?? [] + ); + } + if (operation === 'DB_SAVE_CONTENT') { + if (scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT) { + return [60, 60_000, null]; + } + const index = ordinal - 1; + return ( + [ + [60, 60_000, 60_000], + [20, 20_000, 20_000], + [20, 20_000, 20_000], + ][index] ?? [] + ); + } + if (operation === 'DB_CLEAR_XTREAM_IMPORT_CACHE') { + return [ordinal === 1 ? 6_060 : 20]; + } + return []; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-evidence.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-evidence.ts new file mode 100644 index 000000000..69c34fac0 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-evidence.ts @@ -0,0 +1,382 @@ +import { isDeepStrictEqual } from 'node:util'; + +import { + expectedXtreamWorkerRequestInventory, + XTREAM_SCENARIO_ID, + type XtreamScenarioId, +} from './xtream-benchmark-contract'; +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import { + hasFiniteNonNegativeKeys, + hasOrderedEventLoopDelay, + isExactRecord, + isPositiveSafeInteger, + isSafeNonNegativeInteger, + isUtilization, +} from './xtream-exact-schema'; +import { + XTREAM_WORKER_REQUEST_INVALID_REASON, + XTREAM_WORKER_REQUEST_METRIC_SCOPE, +} from './xtream-summary-contract'; +import { + hasDistinctMonotonicXtreamIpcProducerIdentities, + xtreamIpcProducerIdentityKey, + xtreamIpcProducerNamespaceForWorkerOperation, + type XtreamIpcProducerIdentity, +} from './xtream-ipc-producer-identity'; +import { deriveXtreamWorkerRequestWindowProxy } from './xtream-worker-request-proxy'; +import { validXtreamWorkerRequestIdentity } from './xtream-worker-request-identity'; +import { + snapshotXtreamWorkerRequestAccounting, + XTREAM_WORKER_REQUEST_KEYS, +} from './xtream-worker-request-snapshot'; +import { normalizeWorkerRequestPerformanceOutcome } from './worker-request-performance'; + +export function validXtreamWorkerRequestAccounting( + value: unknown, + scenarioId: XtreamScenarioId +): boolean { + const accounting = snapshotXtreamWorkerRequestAccounting(value); + if ( + accounting === null || + !hasFiniteNonNegativeKeys(accounting, [ + 'eventLoopDelayMaxMs', + 'eventLoopDelayP95Ms', + 'eventLoopDelayP99Ms', + 'eventLoopUtilization', + ]) || + !hasOrderedEventLoopDelay(accounting) || + !isUtilization(accounting['eventLoopUtilization']) || + !isPositiveSafeInteger(accounting['totalCount']) || + !isPositiveSafeInteger(accounting['validSampleCount']) || + !isSafeNonNegativeInteger(accounting['invalidSampleCount']) || + accounting['metricScope'] !== + XTREAM_WORKER_REQUEST_METRIC_SCOPE.VALID_REQUEST_WINDOW_PROXY + ) { + return false; + } + + const evidence = accounting[ + 'evidence' + ] as readonly WorkerRequestPerformanceMetrics[]; + const invalidReasons = accounting['invalidReasons'] as readonly unknown[]; + if ( + evidence.length !== accounting['totalCount'] || + evidence.some((request) => !isCanonicalRequest(request)) || + !hasDistinctRequestIdentities(evidence) || + !hasMonotonicProducerChronology(evidence) || + !hasExpectedInventory(evidence, scenarioId) || + !hasExpectedItemCounts(evidence, scenarioId) || + !hasExpectedDeleteIdentities(evidence, scenarioId) + ) { + return false; + } + const invalidSampleCount = evidence.filter( + (request) => + request.invalidReason === + XTREAM_WORKER_REQUEST_INVALID_REASON.OVERLAPPING_DATABASE_REQUESTS + ).length; + if ( + invalidSampleCount !== accounting['invalidSampleCount'] || + evidence.length - invalidSampleCount !== + accounting['validSampleCount'] || + (scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE && + invalidSampleCount > 0) + ) { + return false; + } + const proxy = deriveXtreamWorkerRequestWindowProxy(evidence); + return ( + proxy !== null && + proxy.eventLoopDelayMaxMs === accounting['eventLoopDelayMaxMs'] && + proxy.eventLoopDelayP95Ms === accounting['eventLoopDelayP95Ms'] && + proxy.eventLoopDelayP99Ms === accounting['eventLoopDelayP99Ms'] && + proxy.eventLoopUtilization === accounting['eventLoopUtilization'] && + hasExactInvalidReasons(invalidReasons, invalidSampleCount) + ); +} + +function isCanonicalRequest( + value: unknown +): value is WorkerRequestPerformanceMetrics { + if ( + !isExactRecord(value, XTREAM_WORKER_REQUEST_KEYS) || + typeof value['operation'] !== 'string' || + !value['operation'].startsWith('DB_') || + typeof value['requestId'] !== 'string' || + value['requestId'].length === 0 || + typeof value['success'] !== 'boolean' || + value['performanceCaptureUnavailableReason'] !== null || + !validNullableIdentifier(value['playlistId']) || + !validXtreamWorkerRequestIdentity(value) + ) { + return false; + } + const normalized = normalizeWorkerRequestPerformanceOutcome({ + ipcCallId: value['ipcCallId'] as number | null, + operation: value['operation'], + operationId: value['operationId'] as string | null, + operationIdUnavailableReason: value['operationIdUnavailableReason'] as + string | null, + performanceCapture: { + eventLoopDelay: value['eventLoopDelay'], + eventLoopDelayUnavailableReason: + value['eventLoopDelayUnavailableReason'], + eventLoopUtilization: value['eventLoopUtilization'], + eventLoopUtilizationUnavailableReason: + value['eventLoopUtilizationUnavailableReason'], + histogramFlushedEpochMs: value['histogramFlushedEpochMs'], + invalidReason: value['invalidReason'], + phaseEvents: value['phaseEvents'], + requestReceivedEpochMs: value['requestReceivedEpochMs'], + responsePostedEpochMs: value['responsePostedEpochMs'], + threadCpuSystemMicros: value['threadCpuSystemMicros'], + threadCpuUnavailableReason: value['threadCpuUnavailableReason'], + threadCpuUserMicros: value['threadCpuUserMicros'], + workEndedEpochMs: value['workEndedEpochMs'], + workStartedEpochMs: value['workStartedEpochMs'], + }, + playlistId: value['playlistId'] as string | null, + requestId: value['requestId'], + responseEpochMs: value['responseEpochMs'] as number, + sourceEpochMs: value['sourceEpochMs'] as number | null, + success: value['success'], + }); + return ( + normalized.performanceCaptureUnavailableReason === null && + isDeepStrictEqual(normalized, value) + ); +} + +function hasDistinctRequestIdentities( + evidence: readonly WorkerRequestPerformanceMetrics[] +): boolean { + const requestIds = evidence.map(({ requestId }) => requestId); + const producerIdentities = collectProducerIdentities(evidence); + if (producerIdentities === null) return false; + const ipcCallIds = producerIdentities.map(({ ipcCallId, namespace }) => + xtreamIpcProducerIdentityKey(namespace, ipcCallId) + ); + const operationIds = evidence.flatMap(({ operationId }) => + operationId === null ? [] : [operationId] + ); + return ( + new Set(requestIds).size === requestIds.length && + new Set(ipcCallIds).size === ipcCallIds.length && + new Set(operationIds).size === operationIds.length + ); +} + +function hasMonotonicProducerChronology( + evidence: readonly WorkerRequestPerformanceMetrics[] +): boolean { + // Evidence is only a subset of preload calls, so ID gaps are valid. The + // producer clamps its clock, which also makes equal source epochs valid. + const identities = collectProducerIdentities(evidence); + return ( + identities !== null && + hasDistinctMonotonicXtreamIpcProducerIdentities(identities) + ); +} + +function collectProducerIdentities( + evidence: readonly WorkerRequestPerformanceMetrics[] +): readonly XtreamIpcProducerIdentity[] | null { + const identities: XtreamIpcProducerIdentity[] = []; + for (const { ipcCallId, operation, sourceEpochMs } of evidence) { + if (ipcCallId === null && sourceEpochMs === null) continue; + const namespace = xtreamIpcProducerNamespaceForWorkerOperation( + typeof operation === 'string' ? operation : '' + ); + if ( + namespace === null || + !isPositiveSafeInteger(ipcCallId) || + typeof sourceEpochMs !== 'number' + ) { + return null; + } + identities.push({ ipcCallId, namespace, sourceEpochMs }); + } + return identities; +} + +function hasExpectedInventory( + evidence: readonly WorkerRequestPerformanceMetrics[], + scenarioId: XtreamScenarioId +): boolean { + const expected = new Map(); + for (const entry of expectedXtreamWorkerRequestInventory(scenarioId)) { + expected.set(signature(entry), entry.count); + } + const actual = new Map(); + for (const request of evidence) { + const phases = request.phaseEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ phase }) => phase); + const key = signature({ + operation: request.operation ?? '', + phases, + success: request.success, + }); + actual.set(key, (actual.get(key) ?? 0) + 1); + } + return isDeepStrictEqual(actual, expected); +} + +function hasExpectedItemCounts( + evidence: readonly WorkerRequestPerformanceMetrics[], + scenarioId: XtreamScenarioId +): boolean { + const operations = new Set( + expectedXtreamWorkerRequestInventory(scenarioId).map( + ({ operation }) => operation + ) + ); + for (const operation of operations) { + const actual = evidence + .filter((request) => request.operation === operation) + .map(requestItemCounts); + const expected = expectedItemCounts(scenarioId, operation); + if (expected === null) continue; + if (operation === 'DB_CLEAR_XTREAM_IMPORT_CACHE') { + const values = actual.flat(); + if ( + values.length !== 3 || + values.filter((value) => value === 20).length !== 2 || + values.filter( + (value) => + typeof value === 'number' && + value >= 6_060 && + value < 60_060 + ).length !== 1 + ) { + return false; + } + continue; + } + if (!isDeepStrictEqual(sorted(actual), sorted(expected))) return false; + } + return true; +} + +function expectedItemCounts( + scenarioId: XtreamScenarioId, + operation: string +): readonly (readonly (number | null)[])[] | null { + if (operation === 'DB_UPSERT_APP_PLAYLIST') return [[null, 1]]; + if (operation === 'DB_GET_APP_PLAYLIST') return [[1, 0]]; + if (operation === 'DB_GET_CATEGORIES') { + const importReads = [[0], [0], [0], [60], [20], [20]] as const; + return scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE || + scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI + ? [...importReads, [60], [20], [20]] + : importReads; + } + if (operation === 'DB_SAVE_CATEGORIES') { + return [ + [60, 60], + [20, 20], + [20, 20], + ]; + } + if (operation === 'DB_GET_CONTENT') { + if (scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT) return [[0]]; + const importReads = [ + [0], + [60_000], + [0], + [20_000], + [0], + [20_000], + ] as const; + return scenarioId === XTREAM_SCENARIO_ID.REFRESH_LARGE || + scenarioId === XTREAM_SCENARIO_ID.BACKGROUND_UI + ? [...importReads, [60_000], [20_000], [20_000]] + : importReads; + } + if (operation === 'DB_SAVE_CONTENT') { + return scenarioId === XTREAM_SCENARIO_ID.CANCEL_IMPORT + ? [[60, 60_000, null]] + : [ + [60, 60_000, 60_000], + [20, 20_000, 20_000], + [20, 20_000, 20_000], + ]; + } + if (operation === 'DB_DELETE_XTREAM_CONTENT') { + return [[100_100, 100_100]]; + } + if (operation === 'DB_DELETE_PLAYLIST') { + return [ + [100_100, 100_101], + [0, 1], + ]; + } + if (operation === 'DB_CLEAR_XTREAM_IMPORT_CACHE') return [[], [], []]; + return null; +} + +function hasExpectedDeleteIdentities( + evidence: readonly WorkerRequestPerformanceMetrics[], + scenarioId: XtreamScenarioId +): boolean { + if (scenarioId !== XTREAM_SCENARIO_ID.DELETE_LARGE) return true; + const deletes = evidence.filter( + ({ operation }) => operation === 'DB_DELETE_PLAYLIST' + ); + const populated = deletes.find((request) => + isDeepStrictEqual(requestItemCounts(request), [100_100, 100_101]) + ); + const emptyFollowUp = deletes.find((request) => + isDeepStrictEqual(requestItemCounts(request), [0, 1]) + ); + return ( + deletes.length === 2 && + typeof populated?.operationId === 'string' && + populated.operationId.length > 0 && + populated.operationIdUnavailableReason === null && + emptyFollowUp?.operationId === null && + emptyFollowUp.operationIdUnavailableReason === null + ); +} + +function requestItemCounts( + request: WorkerRequestPerformanceMetrics +): readonly (number | null)[] { + return request.phaseEvents + .filter(({ boundary }) => boundary === 'end') + .map(({ metadata }) => metadata?.itemCount ?? null); +} + +function hasExactInvalidReasons( + value: readonly unknown[], + invalidSampleCount: number +): boolean { + if (invalidSampleCount === 0) return value.length === 0; + const reason = value[0]; + return ( + value.length === 1 && + isExactRecord(reason, ['count', 'reason']) && + reason['count'] === invalidSampleCount && + reason['reason'] === + XTREAM_WORKER_REQUEST_INVALID_REASON.OVERLAPPING_DATABASE_REQUESTS + ); +} + +function signature(value: { + readonly operation: string; + readonly phases: readonly string[]; + readonly success: boolean; +}): string { + return `${value.operation}\0${value.success ? 'success' : 'error'}\0${value.phases.join('\0')}`; +} + +function sorted( + values: readonly (readonly (number | null)[])[] +): readonly string[] { + return values.map((value) => JSON.stringify(value)).sort(); +} + +function validNullableIdentifier(value: unknown): boolean { + return value === null || (typeof value === 'string' && value.length > 0); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-identity.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-identity.ts new file mode 100644 index 000000000..07646609d --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-identity.ts @@ -0,0 +1,118 @@ +import { isPositiveSafeInteger } from './xtream-exact-schema'; + +const SOURCE_RECEIPT_CLOCK_SKEW_TOLERANCE_MS = 1; + +export const XTREAM_WORKER_REQUEST_IDENTITY_KIND = { + LEGACY_PRELOAD: 'legacy-preload', + NOT_APPLICABLE: 'not-applicable', + PRODUCER_IPC: 'producer-ipc', + PRODUCER_OPERATION: 'producer-operation', +} as const; + +export type XtreamWorkerRequestIdentityKind = + (typeof XTREAM_WORKER_REQUEST_IDENTITY_KIND)[keyof typeof XTREAM_WORKER_REQUEST_IDENTITY_KIND]; + +const PRODUCER_OPERATION_ID_OPERATIONS = new Set([ + 'DB_DELETE_PLAYLIST', + 'DB_DELETE_XTREAM_CONTENT', + 'DB_RESTORE_XTREAM_USER_DATA', + 'DB_SAVE_CONTENT', +]); +const PRODUCER_IPC_OPERATIONS = new Set([ + 'DB_GET_CATEGORIES', + 'DB_GET_CONTENT', + 'DB_GLOBAL_SEARCH', + 'DB_SAVE_CATEGORIES', + 'DB_SEARCH_CONTENT', +]); +const LEGACY_PRELOAD_OPERATIONS = new Set([ + 'DB_GET_APP_PLAYLIST', + 'DB_UPSERT_APP_PLAYLIST', +]); + +export function xtreamWorkerRequestIdentityKind( + operation: string +): XtreamWorkerRequestIdentityKind { + if (PRODUCER_OPERATION_ID_OPERATIONS.has(operation)) { + return XTREAM_WORKER_REQUEST_IDENTITY_KIND.PRODUCER_OPERATION; + } + if (PRODUCER_IPC_OPERATIONS.has(operation)) { + return XTREAM_WORKER_REQUEST_IDENTITY_KIND.PRODUCER_IPC; + } + if (LEGACY_PRELOAD_OPERATIONS.has(operation)) { + return XTREAM_WORKER_REQUEST_IDENTITY_KIND.LEGACY_PRELOAD; + } + return XTREAM_WORKER_REQUEST_IDENTITY_KIND.NOT_APPLICABLE; +} + +export function validXtreamWorkerRequestIdentity( + value: Record +): boolean { + const operation = value['operation']; + if (typeof operation !== 'string') return false; + const kind = xtreamWorkerRequestIdentityKind(operation); + if (kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.NOT_APPLICABLE) { + return ( + value['ipcCallId'] === null && + value['sourceEpochMs'] === null && + value['operationId'] === null && + value['operationIdUnavailableReason'] === + 'preload-performance-marker-not-applicable' + ); + } + if (!hasOrderedIpcIdentity(value)) return false; + if (kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.PRODUCER_OPERATION) { + if (operation === 'DB_DELETE_PLAYLIST') { + // The explicit Xtream delete has an operation ID, while the NgRx + // persistence follow-up is a real correlated preload call without + // one. Request evidence binds each shape to its exact row counts. + return ( + value['operationIdUnavailableReason'] === null && + (nonEmptyString(value['operationId']) || + value['operationId'] === null) + ); + } + return ( + nonEmptyString(value['operationId']) && + value['operationIdUnavailableReason'] === null + ); + } + if (kind === XTREAM_WORKER_REQUEST_IDENTITY_KIND.PRODUCER_IPC) { + return ( + value['operationId'] === null && + value['operationIdUnavailableReason'] === null + ); + } + return nonEmptyString(value['operationId']) + ? value['operationIdUnavailableReason'] === null + : value['operationId'] === null && + value['operationIdUnavailableReason'] === + 'preload-performance-marker-uncorrelated'; +} + +function hasOrderedIpcIdentity(value: Record): boolean { + const sourceEpochMs = value['sourceEpochMs']; + const requestReceivedEpochMs = value['requestReceivedEpochMs']; + const responseEpochMs = value['responseEpochMs']; + return ( + isPositiveSafeInteger(value['ipcCallId']) && + isFinitePositive(sourceEpochMs) && + isFiniteNonNegative(requestReceivedEpochMs) && + isFiniteNonNegative(responseEpochMs) && + sourceEpochMs - requestReceivedEpochMs < + SOURCE_RECEIPT_CLOCK_SKEW_TOLERANCE_MS && + requestReceivedEpochMs <= responseEpochMs + ); +} + +function isFinitePositive(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0; +} + +function isFiniteNonNegative(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-inventory.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-inventory.spec.ts new file mode 100644 index 000000000..8a4d8495e --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-inventory.spec.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + expectedXtreamWorkerRequestInventory, + XTREAM_SCENARIO_ID, +} from './xtream-benchmark-contract'; + +describe('Xtream database-worker request inventory', () => { + it('defines the exact operation, outcome, and phase multiset for every scenario', () => { + const expectedTotals = new Map([ + [XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, 30], + [XTREAM_SCENARIO_ID.REFRESH_LARGE, 51], + [XTREAM_SCENARIO_ID.DELETE_LARGE, 2], + [XTREAM_SCENARIO_ID.CANCEL_IMPORT, 24], + [XTREAM_SCENARIO_ID.BACKGROUND_UI, 54], + ]); + + for (const scenarioId of Object.values(XTREAM_SCENARIO_ID)) { + const inventory = expectedXtreamWorkerRequestInventory(scenarioId); + assert.equal( + inventory.reduce((total, entry) => total + entry.count, 0), + expectedTotals.get(scenarioId) + ); + assert.ok( + inventory.every( + ({ count, operation, success, phases }) => + Number.isSafeInteger(count) && + count > 0 && + operation.startsWith('DB_') && + typeof success === 'boolean' && + phases.every((phase) => phase.length > 0) + ) + ); + } + }); + + it('includes the route hydration requests observed after refresh commit', () => { + for (const scenarioId of [ + XTREAM_SCENARIO_ID.REFRESH_LARGE, + XTREAM_SCENARIO_ID.BACKGROUND_UI, + ]) { + const counts = new Map( + expectedXtreamWorkerRequestInventory(scenarioId).map( + ({ count, operation }) => [operation, count] + ) + ); + + assert.equal(counts.get('DB_GET_PLAYLIST'), 2); + assert.equal(counts.get('DB_GET_APP_STATE'), 12); + assert.equal(counts.get('DB_GET_CATEGORIES'), 9); + assert.equal(counts.get('DB_GET_CONTENT'), 9); + assert.equal(counts.get('DB_SET_APP_STATE'), 6); + } + }); + + it('attributes portal-route hydration without accepting dashboard work', () => { + for (const scenarioId of [ + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + XTREAM_SCENARIO_ID.REFRESH_LARGE, + XTREAM_SCENARIO_ID.CANCEL_IMPORT, + XTREAM_SCENARIO_ID.BACKGROUND_UI, + ]) { + const operations = expectedXtreamWorkerRequestInventory( + scenarioId + ).map(({ operation }) => operation); + assert.ok(operations.includes('DB_GET_PLAYLIST')); + } + + for (const scenarioId of [ + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE, + XTREAM_SCENARIO_ID.CANCEL_IMPORT, + ]) { + const operations = expectedXtreamWorkerRequestInventory( + scenarioId + ).map(({ operation }) => operation); + assert.ok(operations.includes('DB_GET_ALL_PLAYBACK_POSITIONS')); + assert.equal( + operations.includes('DB_GET_GLOBAL_RECENTLY_ADDED'), + false + ); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-proxy.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-proxy.spec.ts new file mode 100644 index 000000000..ccc0ebe11 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-proxy.spec.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { XTREAM_SCENARIO_ID } from './xtream-benchmark-contract'; +import { + invalidateWorkerRequestMetrics, + workerRequestEvidence, +} from './xtream-worker-request-evidence.fixtures'; +import { deriveXtreamWorkerRequestWindowProxy } from './xtream-worker-request-proxy'; + +describe('Xtream request-window proxy', () => { + it('uses the worst valid request window and excludes only overlap-invalid samples', () => { + const evidence = workerRequestEvidence( + XTREAM_SCENARIO_ID.INITIAL_IMPORT_LARGE + ).map((request, index) => + index === 1 + ? { + ...request, + eventLoopDelay: { + maxMs: 12, + p95Ms: 4, + p99Ms: 7, + }, + eventLoopUtilization: 0.75, + } + : request + ); + const withOverlap = invalidateWorkerRequestMetrics(evidence, 1); + + assert.deepEqual(deriveXtreamWorkerRequestWindowProxy(withOverlap), { + eventLoopDelayMaxMs: 12, + eventLoopDelayP95Ms: 4, + eventLoopDelayP99Ms: 7, + eventLoopUtilization: 0.75, + }); + assert.equal( + deriveXtreamWorkerRequestWindowProxy( + invalidateWorkerRequestMetrics(evidence, evidence.length) + ), + null + ); + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-proxy.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-proxy.ts new file mode 100644 index 000000000..6fc05dbd2 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-proxy.ts @@ -0,0 +1,71 @@ +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import { XTREAM_WORKER_REQUEST_INVALID_REASON } from './xtream-summary-contract'; + +export interface XtreamWorkerRequestWindowProxy { + readonly eventLoopDelayMaxMs: number; + readonly eventLoopDelayP95Ms: number; + readonly eventLoopDelayP99Ms: number; + readonly eventLoopUtilization: number; +} + +export function deriveXtreamWorkerRequestWindowProxy( + evidence: readonly WorkerRequestPerformanceMetrics[] +): XtreamWorkerRequestWindowProxy | null { + const valid = evidence.filter( + ({ invalidReason }) => + invalidReason !== + XTREAM_WORKER_REQUEST_INVALID_REASON.OVERLAPPING_DATABASE_REQUESTS + ); + if ( + valid.length === 0 || + valid.some( + ({ eventLoopDelay, eventLoopUtilization, invalidReason }) => + invalidReason !== null || + !isDelay(eventLoopDelay) || + !isUtilization(eventLoopUtilization) + ) + ) { + return null; + } + return { + eventLoopDelayMaxMs: Math.max( + ...valid.map(({ eventLoopDelay }) => eventLoopDelay?.maxMs ?? 0) + ), + eventLoopDelayP95Ms: Math.max( + ...valid.map(({ eventLoopDelay }) => eventLoopDelay?.p95Ms ?? 0) + ), + eventLoopDelayP99Ms: Math.max( + ...valid.map(({ eventLoopDelay }) => eventLoopDelay?.p99Ms ?? 0) + ), + eventLoopUtilization: Math.max( + ...valid.map( + ({ eventLoopUtilization }) => eventLoopUtilization ?? 0 + ) + ), + }; +} + +function isDelay( + value: WorkerRequestPerformanceMetrics['eventLoopDelay'] +): value is NonNullable { + return ( + value !== null && + [value.p95Ms, value.p99Ms, value.maxMs].every( + (sample) => + typeof sample === 'number' && + Number.isFinite(sample) && + sample >= 0 + ) && + value.p95Ms <= value.p99Ms && + value.p99Ms <= value.maxMs + ); +} + +function isUtilization(value: number | null): value is number { + return ( + typeof value === 'number' && + Number.isFinite(value) && + value >= 0 && + value <= 1 + ); +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-snapshot.spec.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-snapshot.spec.ts new file mode 100644 index 000000000..6404d2f58 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-snapshot.spec.ts @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + createXtreamIterationDefinitions, + XTREAM_SCENARIO_ID, +} from './xtream-benchmark-contract'; +import { iteration } from './xtream-benchmark-report.fixtures'; +import type { XtreamWorkerRequestAccounting } from './xtream-summary-contract'; +import { validXtreamWorkerRequestAccounting } from './xtream-worker-request-evidence'; + +describe('Xtream worker request canonical snapshot', () => { + it('rejects an accessor-backed request record without invoking it', () => { + const requests = deleteAccounting(); + const target = required(requests.evidence, 0); + const forged = { ...target }; + const invoked: string[] = []; + Object.defineProperty(forged, 'operation', { + enumerable: true, + get() { + invoked.push('request.operation'); + return target.operation; + }, + }); + + assert.equal( + validXtreamWorkerRequestAccounting( + withRequest(requests, target, forged), + XTREAM_SCENARIO_ID.DELETE_LARGE + ), + false + ); + assert.deepEqual(invoked, []); + }); + + it('rejects caller-owned nested phase array methods without invoking them', () => { + const requests = deleteAccounting(); + const target = required(requests.evidence, 0); + const phaseEvents = [...target.phaseEvents]; + const invoked: string[] = []; + for (const method of ['filter', 'flatMap', 'map', 'some'] as const) { + Object.defineProperty(phaseEvents, method, { + configurable: true, + value(...args: unknown[]) { + invoked.push(method); + return Reflect.apply( + Array.prototype[method], + target.phaseEvents, + args + ); + }, + }); + } + const forged = { ...target, phaseEvents }; + + assert.equal( + validXtreamWorkerRequestAccounting( + withRequest(requests, target, forged), + XTREAM_SCENARIO_ID.DELETE_LARGE + ), + false + ); + assert.deepEqual(invoked, []); + }); +}); + +function deleteAccounting(): XtreamWorkerRequestAccounting { + const definition = createXtreamIterationDefinitions(false).find( + ({ scenarioId }) => scenarioId === XTREAM_SCENARIO_ID.DELETE_LARGE + ); + assert.ok(definition); + return iteration(definition, 1).databaseWorker.requests; +} + +function withRequest( + accounting: XtreamWorkerRequestAccounting, + target: XtreamWorkerRequestAccounting['evidence'][number], + replacement: XtreamWorkerRequestAccounting['evidence'][number] +): XtreamWorkerRequestAccounting { + return { + ...accounting, + evidence: accounting.evidence.map((request) => + request === target ? replacement : request + ), + }; +} + +function required(values: readonly T[], index: number): T { + const value = values[index]; + assert.ok(value); + return value; +} diff --git a/apps/electron-backend-e2e/src/performance/xtream-worker-request-snapshot.ts b/apps/electron-backend-e2e/src/performance/xtream-worker-request-snapshot.ts new file mode 100644 index 000000000..9006561e6 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/xtream-worker-request-snapshot.ts @@ -0,0 +1,230 @@ +import type { + DatabaseWorkerPerformancePhase, + PerformancePhaseEvent, + PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; + +import type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; + +const ACCOUNTING_KEYS = [ + 'evidence', + 'eventLoopDelayMaxMs', + 'eventLoopDelayP95Ms', + 'eventLoopDelayP99Ms', + 'eventLoopUtilization', + 'invalidReasons', + 'invalidSampleCount', + 'metricScope', + 'totalCount', + 'validSampleCount', +] as const; +const EVENT_LOOP_DELAY_KEYS = ['maxMs', 'p95Ms', 'p99Ms'] as const; +const PHASE_EVENT_KEYS = [ + 'boundary', + 'durationMs', + 'epochMs', + 'phase', + 'requestId', +] as const; +const PHASE_METADATA_KEYS = new Set(['byteCount', 'itemCount']); +const INVALID_REASON_KEYS = ['count', 'reason'] as const; + +export const XTREAM_WORKER_REQUEST_KEYS = [ + 'eventLoopDelay', + 'eventLoopDelayUnavailableReason', + 'eventLoopUtilization', + 'eventLoopUtilizationUnavailableReason', + 'histogramFlushedEpochMs', + 'ipcCallId', + 'invalidReason', + 'operation', + 'operationId', + 'operationIdUnavailableReason', + 'performanceCaptureUnavailableReason', + 'phaseEvents', + 'playlistId', + 'requestId', + 'requestReceivedEpochMs', + 'responseEpochMs', + 'responsePostedEpochMs', + 'sourceEpochMs', + 'success', + 'threadCpuSystemMicros', + 'threadCpuUnavailableReason', + 'threadCpuUserMicros', + 'workEndedEpochMs', + 'workStartedEpochMs', +] as const; + +export function snapshotXtreamWorkerRequestAccounting( + value: unknown +): Readonly> | null { + try { + const accounting = snapshotExactRecord(value, ACCOUNTING_KEYS); + const evidence = snapshotArray( + accounting['evidence'], + snapshotWorkerRequest + ); + const invalidReasons = snapshotArray( + accounting['invalidReasons'], + (reason) => snapshotExactRecord(reason, INVALID_REASON_KEYS) + ); + return Object.freeze({ + ...accounting, + evidence, + invalidReasons, + }); + } catch { + return null; + } +} + +function snapshotWorkerRequest( + value: unknown +): WorkerRequestPerformanceMetrics { + const request = snapshotExactRecord(value, XTREAM_WORKER_REQUEST_KEYS); + const eventLoopDelay = + request['eventLoopDelay'] === null + ? null + : snapshotExactRecord( + request['eventLoopDelay'], + EVENT_LOOP_DELAY_KEYS + ); + const phaseEvents = snapshotArray( + request['phaseEvents'], + snapshotPhaseEvent + ); + return Object.freeze({ + ...request, + eventLoopDelay, + phaseEvents, + }) as unknown as WorkerRequestPerformanceMetrics; +} + +function snapshotPhaseEvent( + value: unknown +): PerformancePhaseEvent { + const hasMetadata = hasOwnDataProperty(value, 'metadata'); + const event = snapshotExactRecord( + value, + hasMetadata ? [...PHASE_EVENT_KEYS, 'metadata'] : PHASE_EVENT_KEYS + ); + if (!hasMetadata) { + return event as unknown as PerformancePhaseEvent; + } + const metadata = + event['metadata'] === undefined + ? undefined + : snapshotPhaseMetadata(event['metadata']); + return Object.freeze({ + ...event, + metadata, + }) as unknown as PerformancePhaseEvent; +} + +function snapshotPhaseMetadata(value: unknown): PerformancePhaseMetadata { + const keys = ownDataKeys(value); + if ( + keys.length > PHASE_METADATA_KEYS.size || + keys.some((key) => !PHASE_METADATA_KEYS.has(key)) + ) { + invalid(); + } + return snapshotExactRecord( + value, + keys + ) as unknown as PerformancePhaseMetadata; +} + +function snapshotArray( + value: unknown, + snapshotElement: (element: unknown) => T +): readonly T[] { + if (!Array.isArray(value)) invalid(); + const lengthDescriptor = Reflect.getOwnPropertyDescriptor(value, 'length'); + const length = dataDescriptorValue(lengthDescriptor); + if ( + !Number.isSafeInteger(length) || + Number(length) < 0 || + Number(length) > 512 + ) { + invalid(); + } + const expectedKeys = new Set(['length']); + const snapshot: T[] = []; + for (let index = 0; index < Number(length); index += 1) { + const key = String(index); + expectedKeys.add(key); + const descriptor = Reflect.getOwnPropertyDescriptor(value, key); + snapshot[index] = snapshotElement(dataDescriptorValue(descriptor)); + } + const ownKeys = Reflect.ownKeys(value); + if ( + ownKeys.length !== expectedKeys.size || + ownKeys.some((key) => !expectedKeys.has(key)) + ) { + invalid(); + } + return Object.freeze(snapshot); +} + +function snapshotExactRecord( + value: unknown, + keys: readonly string[] +): Readonly> { + const ownKeys = ownDataKeys(value); + if ( + ownKeys.length !== keys.length || + ownKeys.some((key) => !keys.includes(key)) + ) { + invalid(); + } + const snapshot: Record = {}; + for (const key of keys) { + snapshot[key] = dataDescriptorValue( + Reflect.getOwnPropertyDescriptor(value as object, key) + ); + } + return Object.freeze(snapshot); +} + +function ownDataKeys(value: unknown): string[] { + if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) || + ![Object.prototype, null].includes(Reflect.getPrototypeOf(value)) + ) { + invalid(); + } + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== 'string')) invalid(); + for (const key of keys) { + dataDescriptorValue(Reflect.getOwnPropertyDescriptor(value, key)); + } + return keys as string[]; +} + +function hasOwnDataProperty(value: unknown, key: string): boolean { + if (typeof value !== 'object' || value === null) invalid(); + const descriptor = Reflect.getOwnPropertyDescriptor(value, key); + if (!descriptor) return false; + dataDescriptorValue(descriptor); + return true; +} + +function dataDescriptorValue( + descriptor: PropertyDescriptor | undefined +): unknown { + if ( + !descriptor || + !Object.prototype.hasOwnProperty.call(descriptor, 'value') + ) { + invalid(); + } + return descriptor.value; +} + +function invalid(): never { + throw new Error('invalid Xtream worker request snapshot'); +} diff --git a/apps/electron-backend-e2e/src/xtream.performance.ts b/apps/electron-backend-e2e/src/xtream.performance.ts new file mode 100644 index 000000000..16f86859d --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream.performance.ts @@ -0,0 +1,7 @@ +import { test } from '@playwright/test'; + +import { runXtreamBenchmark } from './performance/xtream-benchmark-orchestrator'; + +test('profiles the synthetic 100k Xtream workflows', async () => { + await runXtreamBenchmark(); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts b/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts index 69c8a7e0f..1b8d01fd8 100644 --- a/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts +++ b/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts @@ -3,6 +3,7 @@ import type { Playlist, PlaylistRefreshPayload, PreloadPerformanceMarker, + XtreamPreloadPerformanceMarker, } from '@iptvnator/shared/interfaces'; export const PRELOAD_ENVIRONMENT = { @@ -31,6 +32,7 @@ export interface PreloadPerformanceHarness { getApi: () => ExposedElectronApi; getMarkers: () => PreloadPerformanceMarker[]; getSentPayloads: (channel: string) => T[]; + getXtreamMarkers: () => XtreamPreloadPerformanceMarker[]; ipcRenderer: MockIpcRenderer; load: (environment?: PreloadEnvironment) => Promise; } @@ -130,6 +132,10 @@ export function createPreloadPerformanceHarness(): PreloadPerformanceHarness { 'IPTVNATOR_PERF_CAPTURE_MARKER' ), getSentPayloads, + getXtreamMarkers: () => + getSentPayloads( + 'IPTVNATOR_XTREAM_PERF_CAPTURE_MARKER' + ), ipcRenderer, load: async (environment = {}) => { jest.resetModules(); diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 6f39995da..f9c4c5fd3 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -15,6 +15,11 @@ import { createPreloadPerformanceCapture, toPreloadPerformanceTargetMethod, } from './preload-performance-capture'; +import { + createXtreamPreloadPerformanceCapture, + isXtreamPreloadPerformanceCaptureEnabled, + toXtreamPreloadPerformanceTargetMethod, +} from './xtream-preload-performance-capture'; import type { EmbeddedMpvBounds, EmbeddedMpvRecordingStartOptions, @@ -78,10 +83,17 @@ const dbSaveContentProgressListeners = new Set< const shouldTraceRendererApi = isRendererApiTraceEnabled(); const shouldCapturePerformance = isPerformanceCaptureEnabled(); +const shouldCaptureXtreamPerformance = + isXtreamPreloadPerformanceCaptureEnabled(); const preloadPerformanceCapture = createPreloadPerformanceCapture( shouldCapturePerformance, (channel, marker) => ipcRenderer.send(channel, marker) ); +const xtreamPreloadPerformanceCapture = shouldCaptureXtreamPerformance + ? createXtreamPreloadPerformanceCapture(true, (channel, marker) => + ipcRenderer.send(channel, marker) + ) + : null; function emitRendererTrace(payload: { method: string; @@ -105,12 +117,18 @@ function wrapElectronApi(api: T): T { return Object.fromEntries( Object.entries(api).map(([name, value]) => { + const isPreloadPerformanceTarget = + toPreloadPerformanceTargetMethod(name) !== null; + const isXtreamPreloadPerformanceTarget = + shouldCaptureXtreamPerformance && + toXtreamPreloadPerformanceTargetMethod(name) !== null; if ( typeof value !== 'function' || name.startsWith('on') || name.startsWith('remove') || (!shouldTraceRendererApi && - !toPreloadPerformanceTargetMethod(name)) + !isPreloadPerformanceTarget && + !isXtreamPreloadPerformanceTarget) ) { return [name, value]; } @@ -134,6 +152,9 @@ function wrapElectronApi(api: T): T { name, args ); + const xtreamPerformanceCall = + xtreamPreloadPerformanceCapture?.start(name, args) ?? + null; try { const result = original(...args); @@ -162,6 +183,9 @@ function wrapElectronApi(api: T): T { performanceCall, resolvedValue ); + xtreamPreloadPerformanceCapture?.success( + xtreamPerformanceCall + ); return resolvedValue; }) .catch((error: unknown) => { @@ -179,6 +203,9 @@ function wrapElectronApi(api: T): T { preloadPerformanceCapture.error( performanceCall ); + xtreamPreloadPerformanceCapture?.error( + xtreamPerformanceCall + ); throw error; }); } @@ -198,6 +225,9 @@ function wrapElectronApi(api: T): T { performanceCall, result ); + xtreamPreloadPerformanceCapture?.success( + xtreamPerformanceCall + ); return result; } catch (error) { @@ -213,6 +243,9 @@ function wrapElectronApi(api: T): T { }); } preloadPerformanceCapture.error(performanceCall); + xtreamPreloadPerformanceCapture?.error( + xtreamPerformanceCall + ); throw error; } }, @@ -738,11 +771,7 @@ const electronApi: ElectronBridgeApi = { kind: 'all' | 'vod' | 'series', limit?: number, playlistType?: - | 'xtream' - | 'stalker' - | 'm3u-file' - | 'm3u-text' - | 'm3u-url' + 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url' ) => ipcRenderer.invoke( 'DB_GET_GLOBAL_RECENTLY_ADDED', diff --git a/apps/electron-backend/src/app/api/main.preload.xtream-performance.contract.spec.ts b/apps/electron-backend/src/app/api/main.preload.xtream-performance.contract.spec.ts new file mode 100644 index 000000000..4a77bbe9f --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.xtream-performance.contract.spec.ts @@ -0,0 +1,187 @@ +import { + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, + type XtreamPreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; +import { + createPreloadPerformanceHarness, + createRefreshPayload, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; +import { + XTREAM_PRELOAD_REQUEST_PAYLOAD, + XTREAM_PRELOAD_TARGET_CASES, +} from './main.preload.xtream-performance.spec-data'; + +const MARKER_KEYS = [ + 'action', + 'boundary', + 'categoryType', + 'contentType', + 'ipcCallId', + 'itemCount', + 'method', + 'operationId', + 'outcome', + 'playlistId', + 'schemaVersion', + 'sessionId', + 'sourceEpochMs', +] as const; + +describe('main preload Xtream performance marker contract', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(() => { + harness = createPreloadPerformanceHarness(); + }); + + afterEach(() => { + harness.cleanup(); + jest.restoreAllMocks(); + }); + + it.each(XTREAM_PRELOAD_TARGET_CASES)( + 'marks $method without changing IPC arguments or result identity', + async ({ expectedInvoke, expectedMetadata, invoke, method }) => { + const result = new Proxy({}, { get: () => undefined }); + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + + await expect( + Promise.resolve(invoke(harness.getApi())) + ).resolves.toBe(result); + + expect(harness.ipcRenderer.invoke).toHaveBeenCalledTimes(1); + expect(harness.ipcRenderer.invoke).toHaveBeenCalledWith( + ...expectedInvoke + ); + const markers = harness.getXtreamMarkers(); + expect(markers).toHaveLength(2); + expect(markers).toEqual([ + expect.objectContaining({ + ...expectedMetadata, + boundary: 'start', + ipcCallId: 1, + method, + outcome: null, + schemaVersion: 1, + }), + expect.objectContaining({ + ...expectedMetadata, + boundary: 'end', + ipcCallId: 1, + method, + outcome: 'success', + schemaVersion: 1, + }), + ]); + expectFixedMarkerShape(markers); + } + ); + + it('preserves exact rejection and synchronous throw identities', async () => { + const rejection = new Error('provider-error-secret'); + const synchronousError = new Error('provider-sync-secret'); + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + harness.ipcRenderer.invoke.mockRejectedValueOnce(rejection); + + await expect( + harness.getApi().dbGetContent('playlist-1', 'live') + ).rejects.toBe(rejection); + expect( + harness.getXtreamMarkers().map(({ outcome }) => outcome) + ).toEqual([null, 'error']); + + harness.ipcRenderer.invoke.mockImplementationOnce(() => { + throw synchronousError; + }); + expect(() => harness.getApi().dbCancelOperation('operation-1')).toThrow( + synchronousError + ); + expect( + harness.getXtreamMarkers().map(({ outcome }) => outcome) + ).toEqual([null, 'error', null, 'error']); + }); + + it('preserves a synchronous target return identity', async () => { + const result = { synchronous: true }; + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + harness.ipcRenderer.invoke.mockReturnValueOnce(result); + + const actual = harness + .getApi() + .dbCancelOperation('operation-1') as unknown; + + expect(actual).toBe(result); + expect( + harness.getXtreamMarkers().map(({ outcome }) => outcome) + ).toEqual([null, 'success']); + }); + + it('keeps M3U and Xtream protocols on their own channels', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + + await harness.getApi().refreshPlaylist(createRefreshPayload()); + await harness.getApi().xtreamRequest(XTREAM_PRELOAD_REQUEST_PAYLOAD); + + expect(harness.getMarkers()).toHaveLength(2); + expect(harness.getXtreamMarkers()).toHaveLength(2); + expect( + harness.getSentPayloads(PRELOAD_PERFORMANCE_MARKER_CHANNEL) + ).toEqual(harness.getMarkers()); + expect( + harness.getSentPayloads(XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL) + ).toEqual(harness.getXtreamMarkers()); + expect( + harness + .getMarkers() + .every(({ method }) => method === 'refreshPlaylist') + ).toBe(true); + expect( + harness + .getXtreamMarkers() + .every(({ method }) => method === 'xtreamRequest') + ).toBe(true); + }); +}); + +function expectFixedMarkerShape( + markers: XtreamPreloadPerformanceMarker[] +): void { + for (const [index, marker] of markers.entries()) { + expect(Object.keys(marker).sort()).toEqual([...MARKER_KEYS].sort()); + expect(Number.isFinite(marker.sourceEpochMs)).toBe(true); + if (index > 0) { + expect(marker.sourceEpochMs).toBeGreaterThanOrEqual( + markers[index - 1].sourceEpochMs + ); + } + } + const serialized = JSON.stringify(markers); + for (const forbidden of [ + 'provider-password-secret', + 'provider-user-secret', + 'provider-url-secret', + 'request-secret', + 'secret-title', + 'secret-stream-title', + 'secret-favorite-title', + 'secret-recent-title', + 'provider-search-secret', + 'global-search-secret', + 'secret-source', + 'provider-error-secret', + ]) { + expect(serialized).not.toContain(forbidden); + } +} diff --git a/apps/electron-backend/src/app/api/main.preload.xtream-performance.gates.spec.ts b/apps/electron-backend/src/app/api/main.preload.xtream-performance.gates.spec.ts new file mode 100644 index 000000000..b7ba6da61 --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.xtream-performance.gates.spec.ts @@ -0,0 +1,180 @@ +import { DEBUG_TRACE_EVENT_CHANNEL } from '../services/debug-trace'; +import { + createPreloadPerformanceHarness, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; + +describe('main preload Xtream performance marker gates', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(() => { + harness = createPreloadPerformanceHarness(); + }); + + afterEach(() => { + harness.cleanup(); + jest.restoreAllMocks(); + }); + + it.each([ + undefined, + '', + '0', + 'false', + 'true', + ' TRUE ', + 'yes', + 'On', + ' 1 ', + ])( + 'requires the exact performance flag value 1, not %p', + async (flagValue) => { + await harness.load( + flagValue === undefined + ? {} + : { + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: flagValue, + } + ); + + await harness.getApi().dbGetContent('playlist-1', 'live'); + + expect(harness.getXtreamMarkers()).toEqual([]); + } + ); + + it('enables the Xtream marker only for the exact value 1', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + + await harness.getApi().dbGetContent('playlist-1', 'live'); + + expect(harness.getXtreamMarkers()).toHaveLength(2); + }); + + it('does no Xtream clock or metadata work while its exact gate is disabled', async () => { + const clock = jest + .spyOn(globalThis.performance, 'now') + .mockImplementation(() => { + throw new Error('clock must stay idle'); + }); + const payload = new Proxy( + {}, + { + get: () => { + throw new Error('metadata must stay opaque'); + }, + } + ); + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: 'true', + }); + + await expect( + harness.getApi().xtreamRequest(payload as never) + ).resolves.toEqual({ success: true }); + + expect(clock).not.toHaveBeenCalled(); + expect(harness.getXtreamMarkers()).toEqual([]); + expect(harness.ipcRenderer.invoke.mock.calls[0][0]).toBe( + 'XTREAM_REQUEST' + ); + expect(harness.ipcRenderer.invoke.mock.calls[0][1]).toBe(payload); + }); + + it('does not inspect or mark non-target methods in performance-only mode', async () => { + const settings = new Proxy( + {}, + { + get: () => { + throw new Error('non-target data must stay opaque'); + }, + } + ); + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + + await harness.getApi().updateSettings(settings); + + expect(harness.getXtreamMarkers()).toEqual([]); + expect(harness.ipcRenderer.invoke.mock.calls[0][0]).toBe( + 'SETTINGS_UPDATE' + ); + expect(harness.ipcRenderer.invoke.mock.calls[0][1]).toBe(settings); + }); + + it('keeps trace-only Xtream calls on the existing debug channel', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + + await harness.getApi().dbGetContent('playlist-1', 'live'); + + expect( + harness + .getSentPayloads>( + DEBUG_TRACE_EVENT_CHANNEL + ) + .map(({ method, phase }) => ({ method, phase })) + ).toEqual([ + { method: 'dbGetContent', phase: 'start' }, + { method: 'dbGetContent', phase: 'success' }, + ]); + expect(harness.getXtreamMarkers()).toEqual([]); + }); + + it('keeps both debug tracing and Xtream markers when both exact gates are enabled', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + + await harness.getApi().dbGetContent('playlist-1', 'live'); + + expect(harness.getSentPayloads(DEBUG_TRACE_EVENT_CHANNEL)).toHaveLength( + 2 + ); + expect(harness.getXtreamMarkers()).toHaveLength(2); + }); + + it('keeps the bridge fail-neutral when marker delivery fails', async () => { + const result = { success: true }; + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + harness.ipcRenderer.send.mockImplementation((channel: string) => { + if (channel === 'IPTVNATOR_XTREAM_PERF_CAPTURE_MARKER') { + throw new Error('marker sink failure'); + } + }); + + await expect( + harness.getApi().dbGetContent('playlist-1', 'live') + ).resolves.toBe(result); + expect(harness.ipcRenderer.invoke).toHaveBeenCalledTimes(1); + }); + + it('keeps the bridge fail-neutral when the high-resolution clock fails', async () => { + const result = { success: true }; + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + jest.spyOn(globalThis.performance, 'now').mockImplementation(() => { + throw new Error('clock failure'); + }); + + await expect( + harness.getApi().dbGetContent('playlist-1', 'live') + ).resolves.toBe(result); + expect( + harness + .getXtreamMarkers() + .every(({ sourceEpochMs }) => Number.isFinite(sourceEpochMs)) + ).toBe(true); + }); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.xtream-performance.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.xtream-performance.spec-data.ts new file mode 100644 index 000000000..4f3855676 --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.xtream-performance.spec-data.ts @@ -0,0 +1,230 @@ +import { + XtreamCodeActions, + type XtreamPreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; +import type { ExposedElectronApi } from './main.preload.performance.test-helpers'; + +type ExpectedMetadata = Pick< + XtreamPreloadPerformanceMarker, + | 'action' + | 'categoryType' + | 'contentType' + | 'itemCount' + | 'operationId' + | 'playlistId' + | 'sessionId' +>; + +const EMPTY_METADATA: ExpectedMetadata = { + action: null, + categoryType: null, + contentType: null, + itemCount: null, + operationId: null, + playlistId: null, + sessionId: null, +}; + +export const XTREAM_PRELOAD_REQUEST_PAYLOAD = { + params: { + action: XtreamCodeActions.GetLiveStreams, + password: 'provider-password-secret', + username: 'provider-user-secret', + }, + requestId: 'request-secret', + sessionId: 'session-1', + suppressErrorLog: true, + url: 'http://provider-url-secret', +}; + +const categoryItems = [ + { + category_id: '1', + category_name: 'secret-title', + parent_id: 0, + }, +]; +const streamItems = [{ name: 'secret-stream-title', stream_id: 1 }]; +const favorites = [{ title: 'secret-favorite-title' }]; +const recent = [{ title: 'secret-recent-title' }, {}]; +const globalSources = [{ playlistId: 'secret-source' }]; +const globalOptions = { limit: 25 }; + +interface TargetCase { + expectedInvoke: readonly unknown[]; + expectedMetadata: ExpectedMetadata; + invoke: (api: ExposedElectronApi) => unknown; + method: string; +} + +export const XTREAM_PRELOAD_TARGET_CASES: TargetCase[] = [ + { + expectedInvoke: ['XTREAM_REQUEST', XTREAM_PRELOAD_REQUEST_PAYLOAD], + expectedMetadata: { + ...EMPTY_METADATA, + action: XtreamCodeActions.GetLiveStreams, + sessionId: 'session-1', + }, + invoke: (api) => api.xtreamRequest(XTREAM_PRELOAD_REQUEST_PAYLOAD), + method: 'xtreamRequest', + }, + { + expectedInvoke: ['XTREAM_CANCEL_SESSION', 'session-2'], + expectedMetadata: { ...EMPTY_METADATA, sessionId: 'session-2' }, + invoke: (api) => api.xtreamCancelSession('session-2'), + method: 'xtreamCancelSession', + }, + { + expectedInvoke: ['DB_GET_CATEGORIES', 'playlist-1', 'movies'], + expectedMetadata: { + ...EMPTY_METADATA, + categoryType: 'movies', + playlistId: 'playlist-1', + }, + invoke: (api) => api.dbGetCategories('playlist-1', 'movies'), + method: 'dbGetCategories', + }, + { + expectedInvoke: [ + 'DB_SAVE_CATEGORIES', + 'playlist-2', + categoryItems, + 'live', + [91], + ], + expectedMetadata: { + ...EMPTY_METADATA, + categoryType: 'live', + itemCount: 1, + playlistId: 'playlist-2', + }, + invoke: (api) => + api.dbSaveCategories('playlist-2', categoryItems, 'live', [91]), + method: 'dbSaveCategories', + }, + { + expectedInvoke: ['DB_GET_CONTENT', 'playlist-3', 'series'], + expectedMetadata: { + ...EMPTY_METADATA, + contentType: 'series', + playlistId: 'playlist-3', + }, + invoke: (api) => api.dbGetContent('playlist-3', 'series'), + method: 'dbGetContent', + }, + { + expectedInvoke: [ + 'DB_SAVE_CONTENT', + 'playlist-4', + streamItems, + 'movie', + 'operation-4', + ], + expectedMetadata: { + ...EMPTY_METADATA, + contentType: 'movie', + itemCount: 1, + operationId: 'operation-4', + playlistId: 'playlist-4', + }, + invoke: (api) => + api.dbSaveContent( + 'playlist-4', + streamItems as never, + 'movie', + 'operation-4' + ), + method: 'dbSaveContent', + }, + { + expectedInvoke: [ + 'DB_DELETE_XTREAM_CONTENT', + 'playlist-5', + 'operation-5', + ], + expectedMetadata: { + ...EMPTY_METADATA, + operationId: 'operation-5', + playlistId: 'playlist-5', + }, + invoke: (api) => api.dbDeleteXtreamContent('playlist-5', 'operation-5'), + method: 'dbDeleteXtreamContent', + }, + { + expectedInvoke: ['DB_DELETE_PLAYLIST', 'playlist-6', 'operation-6'], + expectedMetadata: { + ...EMPTY_METADATA, + operationId: 'operation-6', + playlistId: 'playlist-6', + }, + invoke: (api) => api.dbDeletePlaylist('playlist-6', 'operation-6'), + method: 'dbDeletePlaylist', + }, + { + expectedInvoke: [ + 'DB_RESTORE_XTREAM_USER_DATA', + 'playlist-7', + favorites, + recent, + 'operation-7', + ], + expectedMetadata: { + ...EMPTY_METADATA, + itemCount: 3, + operationId: 'operation-7', + playlistId: 'playlist-7', + }, + invoke: (api) => + api.dbRestoreXtreamUserData( + 'playlist-7', + favorites as never, + recent as never, + 'operation-7' + ), + method: 'dbRestoreXtreamUserData', + }, + { + expectedInvoke: [ + 'DB_SEARCH_CONTENT', + 'playlist-8', + 'provider-search-secret', + ['live'], + true, + ], + expectedMetadata: { ...EMPTY_METADATA, playlistId: 'playlist-8' }, + invoke: (api) => + api.dbSearchContent( + 'playlist-8', + 'provider-search-secret', + ['live'], + true + ), + method: 'dbSearchContent', + }, + { + expectedInvoke: [ + 'DB_GLOBAL_SEARCH', + 'global-search-secret', + ['live'], + true, + globalSources, + globalOptions, + ], + expectedMetadata: EMPTY_METADATA, + invoke: (api) => + api.dbGlobalSearch( + 'global-search-secret', + ['live'], + true, + globalSources as never, + globalOptions + ), + method: 'dbGlobalSearch', + }, + { + expectedInvoke: ['DB_CANCEL_OPERATION', 'operation-9'], + expectedMetadata: { ...EMPTY_METADATA, operationId: 'operation-9' }, + invoke: (api) => api.dbCancelOperation('operation-9'), + method: 'dbCancelOperation', + }, +]; diff --git a/apps/electron-backend/src/app/api/xtream-preload-performance-capture.runtime.spec.ts b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.runtime.spec.ts new file mode 100644 index 000000000..42f4babbc --- /dev/null +++ b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.runtime.spec.ts @@ -0,0 +1,127 @@ +import type { XtreamPreloadPerformanceMarker } from '@iptvnator/shared/interfaces'; +import { createXtreamPreloadPerformanceCapture } from './xtream-preload-performance-capture'; + +describe('Xtream preload performance capture runtime', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does zero target metadata or clock work when disabled', () => { + const clock = jest.fn(() => { + throw new Error('clock must not run'); + }); + const sendMarker = jest.fn(); + const args = new Proxy([{}], { + get: () => { + throw new Error('metadata must not run'); + }, + }); + const capture = createXtreamPreloadPerformanceCapture( + false, + sendMarker, + clock + ); + + expect(capture.start('xtreamRequest', args)).toBeNull(); + expect(capture.start('updateSettings', args)).toBeNull(); + capture.success(null); + capture.error(null); + + expect(clock).not.toHaveBeenCalled(); + expect(sendMarker).not.toHaveBeenCalled(); + }); + + it('keeps calls independent with positive ids and nondecreasing finite timestamps', () => { + const markers: XtreamPreloadPerformanceMarker[] = []; + const clockValues = [10.5, 9.5, Number.NaN, 11.25]; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => clockValues.shift() as number + ); + + const first = capture.start('dbGetContent', ['playlist-1', 'live']); + const second = capture.start('dbGetContent', ['playlist-2', 'movie']); + capture.success(second); + capture.error(first); + + expect( + markers.map(({ boundary, ipcCallId, outcome }) => ({ + boundary, + ipcCallId, + outcome, + })) + ).toEqual([ + { boundary: 'start', ipcCallId: 1, outcome: null }, + { boundary: 'start', ipcCallId: 2, outcome: null }, + { boundary: 'end', ipcCallId: 2, outcome: 'success' }, + { boundary: 'end', ipcCallId: 1, outcome: 'error' }, + ]); + expect( + markers.every(({ sourceEpochMs }) => Number.isFinite(sourceEpochMs)) + ).toBe(true); + expect(markers.map(({ sourceEpochMs }) => sourceEpochMs)).toEqual([ + 10.5, 10.5, 10.5, 11.25, + ]); + }); + + it.each([ + ['non-finite', () => Number.NaN], + [ + 'throwing', + () => { + throw new Error('clock failure'); + }, + ], + ])( + 'uses a positive finite epoch fallback for a first %s clock sample', + (_description, clock) => { + jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + clock + ); + + capture.start('dbGetContent', ['playlist-1', 'live']); + + expect(markers[0].sourceEpochMs).toBe(1_700_000_000_000); + expect(markers[0].sourceEpochMs).toBeGreaterThan(0); + expect(Number.isFinite(markers[0].sourceEpochMs)).toBe(true); + } + ); + + it('swallows sink and clock failures without inspecting result or error values', () => { + const result = new Proxy( + {}, + { + get: () => { + throw new Error('result must stay opaque'); + }, + } + ); + const error = new Proxy( + {}, + { + get: () => { + throw new Error('error must stay opaque'); + }, + } + ); + const capture = createXtreamPreloadPerformanceCapture( + true, + () => { + throw new Error('sink failure'); + }, + () => { + throw new Error('clock failure'); + } + ); + + const call = capture.start('dbGetContent', ['playlist-1', 'live']); + + expect(() => capture.success(call, result)).not.toThrow(); + expect(() => capture.error(call, error)).not.toThrow(); + }); +}); diff --git a/apps/electron-backend/src/app/api/xtream-preload-performance-capture.safety.spec.ts b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.safety.spec.ts new file mode 100644 index 000000000..102d1c479 --- /dev/null +++ b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.safety.spec.ts @@ -0,0 +1,230 @@ +import type { XtreamPreloadPerformanceMarker } from '@iptvnator/shared/interfaces'; +import { createXtreamPreloadPerformanceCapture } from './xtream-preload-performance-capture'; + +describe('Xtream preload performance capture safety', () => { + it.each([ + ['', null], + [' ', null], + ['-leading-dash', null], + ['contains space', null], + ['contains/slash', null], + ['x'.repeat(129), null], + [' playlist.ok:1 ', null], + ['playlist.ok:1', 'playlist.ok:1'], + ['A', 'A'], + ])('normalizes identifier %p to %p', (identifier, expected) => { + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 1_000 + ); + + capture.start('dbDeletePlaylist', [identifier, identifier]); + + expect(markers[0]).toMatchObject({ + operationId: expected, + playlistId: expected, + }); + }); + + it.each([ + ['xtreamRequest', [{ params: { action: 'provider-action-secret' } }]], + ['dbGetCategories', ['playlist-1', 'vod']], + ['dbGetCategories', ['playlist-1', 'LIVE']], + ['dbGetContent', ['playlist-1', 'movies']], + ['dbGetContent', ['playlist-1', 'vod']], + ] as const)( + 'turns invalid action or type metadata into null for %s', + (method, args) => { + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 1_000 + ); + + capture.start(method, args); + + expect(markers[0]).toMatchObject({ + action: null, + categoryType: null, + contentType: null, + }); + expect(JSON.stringify(markers)).not.toContain( + 'provider-action-secret' + ); + } + ); + + it('never enumerates provider payloads, reads array entries, or records forbidden strings', () => { + const forbidden = [ + 'provider-url-secret', + 'provider-user-secret', + 'provider-password-secret', + 'provider-title-secret', + 'provider-search-secret', + 'provider-result-secret', + 'provider-error-secret', + ]; + const params = Object.defineProperties( + { action: 'get_series' }, + { + password: { + enumerable: true, + get: () => { + throw new Error(forbidden[2]); + }, + }, + username: { + enumerable: true, + get: () => { + throw new Error(forbidden[1]); + }, + }, + } + ); + const payload = { + params, + sessionId: 'session-1', + title: forbidden[3], + url: forbidden[0], + }; + const entryRead = jest.fn(() => { + throw new Error('array entries must stay opaque'); + }); + const streams = new Array(1); + Object.defineProperty(streams, 0, { get: entryRead }); + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 1_000 + ); + + const request = capture.start('xtreamRequest', [payload]); + capture.success(request); + const save = capture.start('dbSaveContent', [ + 'playlist-1', + streams, + 'live', + 'operation-1', + ]); + capture.error(save); + capture.start('dbSearchContent', [ + 'playlist-1', + forbidden[4], + ['live'], + ]); + capture.start('dbGlobalSearch', [ + forbidden[4], + ['live'], + false, + [{ title: forbidden[3] }], + ]); + + expect(entryRead).not.toHaveBeenCalled(); + const serialized = JSON.stringify(markers); + for (const value of forbidden) { + expect(serialized).not.toContain(value); + } + }); + + it('swallows hostile argument, payload, params, and array length traps', () => { + const throwingProxy = new Proxy( + {}, + { + get: () => { + throw new Error('hostile getter'); + }, + } + ); + const hostileArray = new Proxy([], { + get: () => { + throw new Error('hostile array length'); + }, + }); + const hostileArgs = new Proxy([throwingProxy], { + get: () => { + throw new Error('hostile args'); + }, + }); + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 1_000 + ); + + expect(() => capture.start('xtreamRequest', hostileArgs)).not.toThrow(); + expect(() => + capture.start('dbSaveCategories', [ + 'playlist-1', + hostileArray, + 'live', + ]) + ).not.toThrow(); + + expect(markers).toHaveLength(2); + expect(markers[0]).toMatchObject({ + action: null, + sessionId: null, + }); + expect(markers[1].itemCount).toBeNull(); + }); + + it('swallows revoked array proxies without changing the call', () => { + const { proxy, revoke } = Proxy.revocable([], {}); + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 1_000 + ); + revoke(); + + expect(() => + capture.start('dbSaveContent', [ + 'playlist-1', + proxy, + 'live', + 'operation-1', + ]) + ).not.toThrow(); + + expect(markers[0].itemCount).toBeNull(); + }); + + it('accepts only bounded array lengths and safely sums restore counts', () => { + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 1_000 + ); + + capture.start('dbSaveCategories', [ + 'playlist-1', + new Array(1_000_001), + 'live', + ]); + capture.start('dbRestoreXtreamUserData', [ + 'playlist-1', + new Array(600_000), + new Array(400_000), + 'operation-1', + ]); + capture.start('dbRestoreXtreamUserData', [ + 'playlist-1', + new Array(600_000), + new Array(400_001), + 'operation-1', + ]); + + expect(markers.map(({ itemCount }) => itemCount)).toEqual([ + null, + 1_000_000, + null, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/api/xtream-preload-performance-capture.spec.ts b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.spec.ts new file mode 100644 index 000000000..959ca5b7c --- /dev/null +++ b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.spec.ts @@ -0,0 +1,219 @@ +import { + XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, + XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION, + type XtreamPreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; +import { XtreamCodeActions } from '@iptvnator/shared/interfaces'; +import { createXtreamPreloadPerformanceCapture } from './xtream-preload-performance-capture'; + +const MARKER_KEYS = [ + 'action', + 'boundary', + 'categoryType', + 'contentType', + 'ipcCallId', + 'itemCount', + 'method', + 'operationId', + 'outcome', + 'playlistId', + 'schemaVersion', + 'sessionId', + 'sourceEpochMs', +] as const; + +const EMPTY_METADATA = { + action: null, + categoryType: null, + contentType: null, + itemCount: null, + operationId: null, + playlistId: null, + sessionId: null, +} as const; + +describe('Xtream preload performance capture contract', () => { + it.each([ + { + args: [ + { + params: { action: XtreamCodeActions.GetLiveStreams }, + sessionId: 'session-1', + }, + ], + expected: { + ...EMPTY_METADATA, + action: XtreamCodeActions.GetLiveStreams, + sessionId: 'session-1', + }, + method: 'xtreamRequest', + }, + { + args: ['session-2'], + expected: { ...EMPTY_METADATA, sessionId: 'session-2' }, + method: 'xtreamCancelSession', + }, + { + args: ['playlist-1', 'movies'], + expected: { + ...EMPTY_METADATA, + categoryType: 'movies', + playlistId: 'playlist-1', + }, + method: 'dbGetCategories', + }, + { + args: ['playlist-2', [{}, {}, {}], 'live', [99]], + expected: { + ...EMPTY_METADATA, + categoryType: 'live', + itemCount: 3, + playlistId: 'playlist-2', + }, + method: 'dbSaveCategories', + }, + { + args: ['playlist-3', 'series'], + expected: { + ...EMPTY_METADATA, + contentType: 'series', + playlistId: 'playlist-3', + }, + method: 'dbGetContent', + }, + { + args: ['playlist-4', [{}, {}], 'movie', 'operation-4'], + expected: { + ...EMPTY_METADATA, + contentType: 'movie', + itemCount: 2, + operationId: 'operation-4', + playlistId: 'playlist-4', + }, + method: 'dbSaveContent', + }, + { + args: ['playlist-5', 'operation-5'], + expected: { + ...EMPTY_METADATA, + operationId: 'operation-5', + playlistId: 'playlist-5', + }, + method: 'dbDeleteXtreamContent', + }, + { + args: ['playlist-6', 'operation-6'], + expected: { + ...EMPTY_METADATA, + operationId: 'operation-6', + playlistId: 'playlist-6', + }, + method: 'dbDeletePlaylist', + }, + { + args: ['playlist-7', [{}, {}], [{}], 'operation-7'], + expected: { + ...EMPTY_METADATA, + itemCount: 3, + operationId: 'operation-7', + playlistId: 'playlist-7', + }, + method: 'dbRestoreXtreamUserData', + }, + { + args: ['playlist-8', 'provider search', ['live'], true], + expected: { + ...EMPTY_METADATA, + playlistId: 'playlist-8', + }, + method: 'dbSearchContent', + }, + { + args: ['provider search', ['live'], true, [{ id: 'source-1' }]], + expected: EMPTY_METADATA, + method: 'dbGlobalSearch', + }, + { + args: ['operation-9'], + expected: { ...EMPTY_METADATA, operationId: 'operation-9' }, + method: 'dbCancelOperation', + }, + ] as const)( + 'extracts only safe positional metadata for $method', + ({ args, expected, method }) => { + const sendMarker = jest.fn(); + const capture = createXtreamPreloadPerformanceCapture( + true, + sendMarker, + () => 1_000.25 + ); + + const call = capture.start(method, args); + + expect(call).not.toBeNull(); + expect(sendMarker).toHaveBeenCalledWith( + XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, + { + ...expected, + boundary: 'start', + ipcCallId: 1, + method, + outcome: null, + schemaVersion: XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION, + sourceEpochMs: 1_000.25, + } + ); + } + ); + + it('emits fixed-key start and end markers with matching call metadata', () => { + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 2_000.5 + ); + + const successCall = capture.start('dbGetContent', [ + 'playlist-1', + 'live', + ]); + capture.success(successCall); + const errorCall = capture.start('dbCancelOperation', ['operation-2']); + capture.error(errorCall); + + expect( + markers.map(({ boundary, ipcCallId, outcome }) => ({ + boundary, + ipcCallId, + outcome, + })) + ).toEqual([ + { boundary: 'start', ipcCallId: 1, outcome: null }, + { boundary: 'end', ipcCallId: 1, outcome: 'success' }, + { boundary: 'start', ipcCallId: 2, outcome: null }, + { boundary: 'end', ipcCallId: 2, outcome: 'error' }, + ]); + for (const marker of markers) { + expect(Object.keys(marker).sort()).toEqual([...MARKER_KEYS].sort()); + } + }); + + it('allows every current Xtream action including the legacy alias', () => { + const markers: XtreamPreloadPerformanceMarker[] = []; + const capture = createXtreamPreloadPerformanceCapture( + true, + (_channel, marker) => markers.push(marker), + () => 3_000 + ); + const actions = Object.values(XtreamCodeActions); + + for (const action of actions) { + capture.start('xtreamRequest', [{ params: { action } }]); + } + + expect(markers.map(({ action }) => action)).toEqual(actions); + expect(actions).toContain('get_simple_data_table'); + expect(actions).toContain('get_simple_date_table'); + }); +}); diff --git a/apps/electron-backend/src/app/api/xtream-preload-performance-capture.ts b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.ts new file mode 100644 index 000000000..ace7569cc --- /dev/null +++ b/apps/electron-backend/src/app/api/xtream-preload-performance-capture.ts @@ -0,0 +1,191 @@ +import { + XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, + XTREAM_PRELOAD_PERFORMANCE_METHOD, + XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION, + type XtreamPreloadPerformanceBoundary, + type XtreamPreloadPerformanceMarker, + type XtreamPreloadPerformanceMethod, + type XtreamPreloadPerformanceOutcome, +} from '@iptvnator/shared/interfaces'; +import { + extractXtreamPreloadPerformanceMetadata, + type XtreamPreloadMarkerMetadata, +} from './xtream-preload-performance-metadata'; + +export interface XtreamPreloadPerformanceCallContext extends XtreamPreloadMarkerMetadata { + ipcCallId: number; + method: XtreamPreloadPerformanceMethod; +} + +export interface XtreamPreloadPerformanceCapture { + error: ( + call: XtreamPreloadPerformanceCallContext | null, + ignoredError?: unknown + ) => void; + start: ( + methodName: string, + args: readonly unknown[] + ) => XtreamPreloadPerformanceCallContext | null; + success: ( + call: XtreamPreloadPerformanceCallContext | null, + ignoredResult?: unknown + ) => void; +} + +type MarkerSender = ( + channel: typeof XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, + marker: XtreamPreloadPerformanceMarker +) => void; + +type EpochReader = () => number; + +export function toXtreamPreloadPerformanceTargetMethod( + methodName: string +): XtreamPreloadPerformanceMethod | null { + switch (methodName) { + case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_REQUEST: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_CANCEL_SESSION: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CATEGORIES: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CATEGORIES: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CONTENT: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CONTENT: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_XTREAM_CONTENT: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_PLAYLIST: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_RESTORE_XTREAM_USER_DATA: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SEARCH_CONTENT: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GLOBAL_SEARCH: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_CANCEL_OPERATION: + return methodName; + default: + return null; + } +} + +export function isXtreamPreloadPerformanceCaptureEnabled(): boolean { + return process.env['IPTVNATOR_PERF_CAPTURE'] === '1'; +} + +function readHighResolutionEpochMs(): number { + try { + const timeOrigin = globalThis.performance?.timeOrigin; + const monotonicNow = globalThis.performance?.now?.(); + const highResolutionEpoch = + typeof timeOrigin === 'number' && typeof monotonicNow === 'number' + ? timeOrigin + monotonicNow + : Number.NaN; + if (Number.isFinite(highResolutionEpoch)) { + return highResolutionEpoch; + } + } catch { + // Fall through to the wall clock. + } + + try { + return Date.now(); + } catch { + return Number.NaN; + } +} + +function readPositiveWallClockEpochMs(): number { + try { + const wallClockEpochMs = Date.now(); + return Number.isFinite(wallClockEpochMs) && wallClockEpochMs > 0 + ? wallClockEpochMs + : 1; + } catch { + return 1; + } +} + +function readMonotonicEpochMs( + readEpochMs: EpochReader, + lastSourceEpochMs: number +): number { + let clockValue = Number.NaN; + try { + clockValue = readEpochMs(); + } catch { + // Use the last valid sample or the wall clock below. + } + + const fallbackEpochMs = + lastSourceEpochMs > 0 + ? lastSourceEpochMs + : readPositiveWallClockEpochMs(); + const sourceEpochMs = + Number.isFinite(clockValue) && clockValue > 0 + ? clockValue + : fallbackEpochMs; + return Math.max(sourceEpochMs, lastSourceEpochMs); +} + +export function createXtreamPreloadPerformanceCapture( + enabled: boolean, + sendMarker: MarkerSender, + readEpochMs: EpochReader = readHighResolutionEpochMs +): XtreamPreloadPerformanceCapture { + let lastSourceEpochMs = 0; + let nextIpcCallId = 1; + + function emit( + call: XtreamPreloadPerformanceCallContext, + boundary: XtreamPreloadPerformanceBoundary, + outcome: XtreamPreloadPerformanceOutcome + ): void { + const sourceEpochMs = readMonotonicEpochMs( + readEpochMs, + lastSourceEpochMs + ); + lastSourceEpochMs = sourceEpochMs; + try { + sendMarker(XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL, { + schemaVersion: XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION, + sourceEpochMs, + ipcCallId: call.ipcCallId, + method: call.method, + boundary, + outcome, + playlistId: call.playlistId, + operationId: call.operationId, + sessionId: call.sessionId, + action: call.action, + categoryType: call.categoryType, + contentType: call.contentType, + itemCount: call.itemCount, + }); + } catch { + // Performance instrumentation must never affect the bridge call. + } + } + + return { + error: (call) => { + if (call) { + emit(call, 'end', 'error'); + } + }, + start: (methodName, args) => { + if (!enabled) { + return null; + } + const method = toXtreamPreloadPerformanceTargetMethod(methodName); + if (!method || nextIpcCallId > Number.MAX_SAFE_INTEGER) { + return null; + } + const call = { + ipcCallId: nextIpcCallId, + method, + ...extractXtreamPreloadPerformanceMetadata(method, args), + }; + nextIpcCallId += 1; + emit(call, 'start', null); + return call; + }, + success: (call) => { + if (call) { + emit(call, 'end', 'success'); + } + }, + }; +} diff --git a/apps/electron-backend/src/app/api/xtream-preload-performance-metadata.ts b/apps/electron-backend/src/app/api/xtream-preload-performance-metadata.ts new file mode 100644 index 000000000..812a0946a --- /dev/null +++ b/apps/electron-backend/src/app/api/xtream-preload-performance-metadata.ts @@ -0,0 +1,190 @@ +import { + XTREAM_PRELOAD_PERFORMANCE_METHOD, + XtreamCodeActions, + type XtreamPreloadPerformanceCategoryType, + type XtreamPreloadPerformanceContentType, + type XtreamPreloadPerformanceMarker, + type XtreamPreloadPerformanceMethod, +} from '@iptvnator/shared/interfaces'; + +const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; +const MAX_IDENTIFIER_LENGTH = 128; +const MAX_ITEM_COUNT = 1_000_000; +const ALLOWED_ACTIONS = new Set(Object.values(XtreamCodeActions)); +const ALLOWED_CATEGORY_TYPES = new Set(['live', 'movies', 'series']); +const ALLOWED_CONTENT_TYPES = new Set(['live', 'movie', 'series']); + +export type XtreamPreloadMarkerMetadata = Pick< + XtreamPreloadPerformanceMarker, + | 'action' + | 'categoryType' + | 'contentType' + | 'itemCount' + | 'operationId' + | 'playlistId' + | 'sessionId' +>; + +function readValue(value: unknown, property: PropertyKey): unknown { + if ( + (typeof value !== 'object' || value === null) && + typeof value !== 'function' + ) { + return undefined; + } + + try { + return Reflect.get(value, property); + } catch { + return undefined; + } +} + +function readArgument(args: readonly unknown[], index: number): unknown { + return readValue(args, index); +} + +function normalizeIdentifier(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + return value.length >= 1 && + value.length <= MAX_IDENTIFIER_LENGTH && + IDENTIFIER_PATTERN.test(value) + ? value + : null; +} + +function normalizeAction(value: unknown): XtreamCodeActions | null { + return typeof value === 'string' && ALLOWED_ACTIONS.has(value) + ? (value as XtreamCodeActions) + : null; +} + +function normalizeCategoryType( + value: unknown +): XtreamPreloadPerformanceCategoryType | null { + return typeof value === 'string' && ALLOWED_CATEGORY_TYPES.has(value) + ? (value as XtreamPreloadPerformanceCategoryType) + : null; +} + +function normalizeContentType( + value: unknown +): XtreamPreloadPerformanceContentType | null { + return typeof value === 'string' && ALLOWED_CONTENT_TYPES.has(value) + ? (value as XtreamPreloadPerformanceContentType) + : null; +} + +function readArrayCount(value: unknown): number | null { + let isArray = false; + try { + isArray = Array.isArray(value); + } catch { + return null; + } + if (!isArray) { + return null; + } + + const length = readValue(value, 'length'); + return typeof length === 'number' && + Number.isSafeInteger(length) && + length >= 0 && + length <= MAX_ITEM_COUNT + ? length + : null; +} + +function sumArrayCounts(first: unknown, second: unknown): number | null { + const firstCount = readArrayCount(first); + const secondCount = readArrayCount(second); + if (firstCount === null || secondCount === null) { + return null; + } + + const total = firstCount + secondCount; + return Number.isSafeInteger(total) && total <= MAX_ITEM_COUNT + ? total + : null; +} + +function emptyMetadata(): XtreamPreloadMarkerMetadata { + return { + action: null, + categoryType: null, + contentType: null, + itemCount: null, + operationId: null, + playlistId: null, + sessionId: null, + }; +} + +export function extractXtreamPreloadPerformanceMetadata( + method: XtreamPreloadPerformanceMethod, + args: readonly unknown[] +): XtreamPreloadMarkerMetadata { + const metadata = emptyMetadata(); + const first = readArgument(args, 0); + + switch (method) { + case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_REQUEST: { + const params = readValue(first, 'params'); + metadata.sessionId = normalizeIdentifier( + readValue(first, 'sessionId') + ); + metadata.action = normalizeAction(readValue(params, 'action')); + return metadata; + } + case XTREAM_PRELOAD_PERFORMANCE_METHOD.XTREAM_CANCEL_SESSION: + metadata.sessionId = normalizeIdentifier(first); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CATEGORIES: + metadata.playlistId = normalizeIdentifier(first); + metadata.categoryType = normalizeCategoryType( + readArgument(args, 1) + ); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CATEGORIES: + metadata.playlistId = normalizeIdentifier(first); + metadata.itemCount = readArrayCount(readArgument(args, 1)); + metadata.categoryType = normalizeCategoryType( + readArgument(args, 2) + ); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GET_CONTENT: + metadata.playlistId = normalizeIdentifier(first); + metadata.contentType = normalizeContentType(readArgument(args, 1)); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SAVE_CONTENT: + metadata.playlistId = normalizeIdentifier(first); + metadata.itemCount = readArrayCount(readArgument(args, 1)); + metadata.contentType = normalizeContentType(readArgument(args, 2)); + metadata.operationId = normalizeIdentifier(readArgument(args, 3)); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_XTREAM_CONTENT: + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_DELETE_PLAYLIST: + metadata.playlistId = normalizeIdentifier(first); + metadata.operationId = normalizeIdentifier(readArgument(args, 1)); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_RESTORE_XTREAM_USER_DATA: + metadata.playlistId = normalizeIdentifier(first); + metadata.itemCount = sumArrayCounts( + readArgument(args, 1), + readArgument(args, 2) + ); + metadata.operationId = normalizeIdentifier(readArgument(args, 3)); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_SEARCH_CONTENT: + metadata.playlistId = normalizeIdentifier(first); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_CANCEL_OPERATION: + metadata.operationId = normalizeIdentifier(first); + return metadata; + case XTREAM_PRELOAD_PERFORMANCE_METHOD.DB_GLOBAL_SEARCH: + return metadata; + } +} diff --git a/apps/electron-backend/src/app/database/operations/category.operations.performance.spec.ts b/apps/electron-backend/src/app/database/operations/category.operations.performance.spec.ts new file mode 100644 index 000000000..f03a60895 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/category.operations.performance.spec.ts @@ -0,0 +1,110 @@ +import type { AppDatabase } from '../database.types'; +import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; +import { getCategories, saveCategories } from './category.operations'; +import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers'; + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +describe('category operation performance phases', () => { + it('keeps the categories read phase open until the query resolves', async () => { + const rows = [{ id: 1, name: 'News' }]; + const pending = createDeferred(); + const orderBy = jest.fn(() => pending.promise); + const where = jest.fn(() => ({ orderBy })); + const from = jest.fn(() => ({ where })); + const db = { + select: jest.fn(() => ({ from })), + } as unknown as AppDatabase; + const recording = createRecordingOperationPhaseCapture(); + + const result = getCategories( + db, + 'playlist-1', + 'live', + recording.capture + ); + + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ, + }, + ]); + + pending.resolve(rows); + await expect(result).resolves.toBe(rows); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ, + }, + { + boundary: 'end', + metadata: { itemCount: 1 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ, + }, + ]); + }); + + it('captures normalization and the existing category write separately', async () => { + const existingWhere = jest.fn().mockResolvedValue([{ count: 0 }]); + const select = jest.fn(() => ({ + from: jest.fn(() => ({ where: existingWhere })), + })); + const onConflictDoNothing = jest.fn().mockResolvedValue(undefined); + const values = jest.fn(() => ({ onConflictDoNothing })); + const insert = jest.fn(() => ({ values })); + const db = { insert, select } as unknown as AppDatabase; + const recording = createRecordingOperationPhaseCapture(); + + await expect( + saveCategories( + db, + 'playlist-1', + [ + { category_name: 'News', category_id: '101' }, + { category_name: 'Broken', category_id: 'not-a-number' }, + { category_name: 'Sports', category_id: 102 }, + ], + 'live', + [102], + recording.capture + ) + ).resolves.toEqual({ success: true }); + + expect(values).toHaveBeenCalledWith([ + expect.objectContaining({ name: 'News', xtreamId: 101 }), + expect.objectContaining({ + hidden: true, + name: 'Sports', + xtreamId: 102, + }), + ]); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CATEGORIES, + }, + { + boundary: 'end', + metadata: { itemCount: 2 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CATEGORIES, + }, + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + }, + { + boundary: 'end', + metadata: { itemCount: 2 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + }, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/category.operations.ts b/apps/electron-backend/src/app/database/operations/category.operations.ts index 27bd2cb46..8bfd5b6e8 100644 --- a/apps/electron-backend/src/app/database/operations/category.operations.ts +++ b/apps/electron-backend/src/app/database/operations/category.operations.ts @@ -1,12 +1,16 @@ import { and, eq, inArray, sql } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; +import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; +import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture'; type XtreamCategoryInput = { category_name: string; category_id: string | number; }; +type XtreamCategoryValue = typeof schema.categories.$inferInsert; + // Category rows cross the DB-worker IPC boundary in the snake_case wire // shape declared by XCategoryFromDb/XtreamCategoryFromDb. A bare select() // would leak Drizzle's camelCase property names (xtreamId, playlistId) @@ -29,6 +33,49 @@ function normalizeXtreamCategoryId( return Number.isNaN(xtreamId) ? null : xtreamId; } +function normalizeXtreamCategories( + playlistId: string, + categories: XtreamCategoryInput[], + type: 'live' | 'movies' | 'series', + hiddenCategoryXtreamIds?: number[] +): XtreamCategoryValue[] { + const hiddenSet = new Set(hiddenCategoryXtreamIds || []); + + return categories.flatMap((category) => { + const xtreamId = normalizeXtreamCategoryId(category.category_id); + + if (xtreamId === null) { + return []; + } + + return [ + { + playlistId, + name: category.category_name, + type, + xtreamId, + hidden: hiddenSet.has(xtreamId), + }, + ]; + }); +} + +async function insertXtreamCategories( + db: AppDatabase, + values: XtreamCategoryValue[] +): Promise { + await db + .insert(schema.categories) + .values(values) + .onConflictDoNothing({ + target: [ + schema.categories.playlistId, + schema.categories.type, + schema.categories.xtreamId, + ], + }); +} + export async function hasCategories( db: AppDatabase, playlistId: string, @@ -50,13 +97,14 @@ export async function hasCategories( export async function getCategories( db: AppDatabase, playlistId: string, - type: 'live' | 'movies' | 'series' + type: 'live' | 'movies' | 'series', + capturePhase?: DatabaseOperationPerformancePhaseCapture ) { // Xtream categories are inserted once in provider order and existing // xtream IDs are preserved, so row id order represents server order. // If partial category re-inserts are added later, persist a provider // sort index instead of relying on the insertion id. - return db + const query = db .select(categoryWireShape) .from(schema.categories) .where( @@ -67,6 +115,14 @@ export async function getCategories( ) ) .orderBy(schema.categories.id); + + return capturePhase + ? capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ, + async () => query, + (rows) => ({ itemCount: rows.length }) + ) + : query; } export async function saveCategories( @@ -74,7 +130,8 @@ export async function saveCategories( playlistId: string, categories: XtreamCategoryInput[], type: 'live' | 'movies' | 'series', - hiddenCategoryXtreamIds?: number[] + hiddenCategoryXtreamIds?: number[], + capturePhase?: DatabaseOperationPerformancePhaseCapture ): Promise<{ success: boolean }> { if (!categories || categories.length === 0) { return { success: true }; @@ -94,39 +151,38 @@ export async function saveCategories( return { success: true }; } - const hiddenSet = new Set(hiddenCategoryXtreamIds || []); - const values = categories.flatMap((category) => { - const xtreamId = normalizeXtreamCategoryId(category.category_id); - - if (xtreamId === null) { - return []; - } - - return [ - { - playlistId, - name: category.category_name, - type, - xtreamId, - hidden: hiddenSet.has(xtreamId), - }, - ]; - }); + const values = capturePhase + ? capturePhase.captureSync( + XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CATEGORIES, + () => + normalizeXtreamCategories( + playlistId, + categories, + type, + hiddenCategoryXtreamIds + ), + (result) => ({ itemCount: result.length }) + ) + : normalizeXtreamCategories( + playlistId, + categories, + type, + hiddenCategoryXtreamIds + ); if (values.length === 0) { return { success: true }; } - await db - .insert(schema.categories) - .values(values) - .onConflictDoNothing({ - target: [ - schema.categories.playlistId, - schema.categories.type, - schema.categories.xtreamId, - ], - }); + if (capturePhase) { + await capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_WRITE_TRANSACTIONS, + () => insertXtreamCategories(db, values), + () => ({ itemCount: values.length }) + ); + } else { + await insertXtreamCategories(db, values); + } return { success: true }; } diff --git a/apps/electron-backend/src/app/database/operations/content-import-performance.spec.ts b/apps/electron-backend/src/app/database/operations/content-import-performance.spec.ts new file mode 100644 index 000000000..60383828c --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/content-import-performance.spec.ts @@ -0,0 +1,269 @@ +import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; +import type { AppDatabase } from '../database.types'; +import { + clearXtreamImportCache, + getContent, + saveContent, +} from './content.operations'; +import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers'; + +function createContentDb(streamCount: number, timeline: string[]) { + const existingWhere = jest.fn().mockResolvedValue([{ count: 0 }]); + const categoriesWhere = jest + .fn() + .mockResolvedValue([{ id: 42, xtreamId: 201 }]); + const select = jest + .fn() + .mockReturnValueOnce({ + from: jest.fn(() => ({ + innerJoin: jest.fn(() => ({ where: existingWhere })), + })), + }) + .mockReturnValueOnce({ + from: jest.fn(() => ({ where: categoriesWhere })), + }); + const run = jest.fn(); + const values = jest.fn((_chunk: unknown[]) => ({ + onConflictDoNothing: jest.fn(() => ({ run })), + })); + const insert = jest.fn(() => ({ values })); + const transactionResults: unknown[] = []; + const transaction = jest.fn((callback: (tx: unknown) => unknown) => { + timeline.push('transaction'); + const result = callback({ insert }); + transactionResults.push(result); + return result; + }); + const streams = Array.from({ length: streamCount }, (_, index) => ({ + category_id: '201', + name: `Channel ${index}`, + stream_id: String(index + 1), + })); + + return { + db: { select, transaction } as unknown as AppDatabase, + streams, + transaction, + transactionResults, + values, + }; +} + +describe('content import performance phases', () => { + it('keeps the content read phase open until the ordered query resolves', async () => { + const rows = [{ id: 1, title: 'One' }]; + let resolveRows!: (value: typeof rows) => void; + const pending = new Promise((resolve) => { + resolveRows = resolve; + }); + const orderBy = jest.fn(() => pending); + const where = jest.fn(() => ({ orderBy })); + const innerJoin = jest.fn(() => ({ where })); + const from = jest.fn(() => ({ innerJoin })); + const db = { + select: jest.fn(() => ({ from })), + } as unknown as AppDatabase; + const recording = createRecordingOperationPhaseCapture(); + + const result = getContent(db, 'playlist-1', 'live', recording.capture); + expect(recording.events).toHaveLength(1); + + resolveRows(rows); + await expect(result).resolves.toBe(rows); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ, + }, + { + boundary: 'end', + metadata: { itemCount: 1 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ, + }, + ]); + }); + + it('uses one aggregate write pair around every existing batch commit and progress callback', async () => { + const timeline: string[] = []; + const recording = createRecordingOperationPhaseCapture((event) => + timeline.push(`${event.phase}:${event.boundary}`) + ); + const harness = createContentDb(205, timeline); + const checkpoint = jest.fn(() => { + timeline.push('checkpoint'); + }); + const onProgress = jest.fn((progress: { current?: number }) => { + timeline.push(`progress:${progress.current}`); + }); + + await expect( + saveContent( + harness.db, + 'playlist-1', + harness.streams, + 'live', + { checkpoint, onProgress }, + recording.capture + ) + ).resolves.toEqual({ success: true, count: 205 }); + + expect(harness.transaction).toHaveBeenCalledTimes(3); + expect(harness.transactionResults).toEqual([ + undefined, + undefined, + undefined, + ]); + expect( + harness.values.mock.calls.map(([chunk]) => chunk.length) + ).toEqual([100, 100, 5]); + expect(onProgress.mock.calls.map(([value]) => value.current)).toEqual([ + 100, 200, 205, + ]); + const writeStart = timeline.indexOf( + 'sqlite.content.write-transactions:start' + ); + const writeEnd = timeline.indexOf( + 'sqlite.content.write-transactions:end' + ); + expect(writeStart).toBeGreaterThanOrEqual(0); + expect(writeEnd).toBeGreaterThan(timeline.lastIndexOf('progress:205')); + expect( + timeline + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => entry === 'transaction') + .every(({ index }) => index > writeStart && index < writeEnd) + ).toBe(true); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + }, + { + boundary: 'end', + metadata: { itemCount: 1 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + }, + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CONTENT, + }, + { + boundary: 'end', + metadata: { itemCount: 205 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CONTENT, + }, + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + }, + { + boundary: 'end', + metadata: { itemCount: 205 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + }, + ]); + }); + + it('closes the aggregate write phase on cancellation without changing the error', async () => { + const timeline: string[] = []; + const recording = createRecordingOperationPhaseCapture(); + const harness = createContentDb(101, timeline); + const cancellation = new Error('cancelled'); + cancellation.name = 'AbortError'; + let checkpointCount = 0; + + await expect( + saveContent( + harness.db, + 'playlist-1', + harness.streams, + 'live', + { + checkpoint: () => { + checkpointCount += 1; + if (checkpointCount === 2) throw cancellation; + }, + }, + recording.capture + ) + ).rejects.toBe(cancellation); + + expect(harness.transaction).toHaveBeenCalledTimes(1); + expect(recording.events.at(-1)).toEqual({ + boundary: 'end', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + }); + }); + + it('captures cache deletion as one pair across content and category chunks', async () => { + const categoryRows = Array.from({ length: 150 }, (_, id) => ({ id })); + const contentRows = Array.from({ length: 205 }, (_, id) => ({ id })); + const select = jest + .fn() + .mockReturnValueOnce({ + from: jest.fn(() => ({ + where: jest.fn().mockResolvedValue(categoryRows), + })), + }) + .mockReturnValueOnce({ + from: jest.fn(() => ({ + where: jest.fn().mockResolvedValue(contentRows), + })), + }); + const run = jest.fn(); + const deleteRows = jest.fn(() => ({ + where: jest.fn(() => ({ run })), + })); + const transaction = jest.fn((callback: (tx: unknown) => unknown) => + callback({ delete: deleteRows }) + ); + const db = { select, transaction } as unknown as AppDatabase; + const recording = createRecordingOperationPhaseCapture(); + + await clearXtreamImportCache( + db, + 'playlist-1', + 'live', + recording.capture + ); + + expect(transaction).toHaveBeenCalledTimes(5); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + }, + { + boundary: 'end', + metadata: { itemCount: 355 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + }, + ]); + }); + + it('emits one zero-count cache-clear pair when no categories exist', async () => { + const where = jest.fn().mockResolvedValue([]); + const from = jest.fn(() => ({ where })); + const select = jest.fn(() => ({ from })); + const transaction = jest.fn(); + const db = { select, transaction } as unknown as AppDatabase; + const recording = createRecordingOperationPhaseCapture(); + + await expect( + clearXtreamImportCache(db, 'playlist-1', 'live', recording.capture) + ).resolves.toEqual({ success: true }); + + expect(transaction).not.toHaveBeenCalled(); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + }, + { + boundary: 'end', + metadata: { itemCount: 0 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + }, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/content-search-performance.spec.ts b/apps/electron-backend/src/app/database/operations/content-search-performance.spec.ts new file mode 100644 index 000000000..e6f20599f --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/content-search-performance.spec.ts @@ -0,0 +1,124 @@ +import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; +import type { AppDatabase } from '../database.types'; +import { searchContent } from './content.operations'; +import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers'; + +function createSearchDb(query: Promise) { + const limit = jest.fn(() => query); + const where = jest.fn(() => ({ limit })); + const innerJoin = jest.fn(() => ({ where })); + const from = jest.fn(() => ({ innerJoin })); + const select = jest.fn(() => ({ from })); + return { + db: { select } as unknown as AppDatabase, + select, + }; +} + +describe('in-playlist search performance phases', () => { + it('closes query before measuring synchronous filtering and ranking', async () => { + const candidates = [ + { + id: 1, + title: 'News One', + xtream_id: 101, + type: 'live', + }, + { + id: 2, + title: 'Movies', + xtream_id: 102, + type: 'live', + }, + ]; + let resolveQuery!: (value: typeof candidates) => void; + const query = new Promise((resolve) => { + resolveQuery = resolve; + }); + const { db } = createSearchDb(query); + const recording = createRecordingOperationPhaseCapture(); + + const result = searchContent( + db, + 'playlist-1', + 'news', + ['live'], + false, + recording.capture + ); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY, + }, + ]); + + resolveQuery(candidates); + await expect(result).resolves.toEqual([candidates[0]]); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY, + }, + { + boundary: 'end', + metadata: { itemCount: 2 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY, + }, + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_SEARCH_RANK, + }, + { + boundary: 'end', + metadata: { itemCount: 1 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_SEARCH_RANK, + }, + ]); + }); + + it('closes a rejected query without starting rank and preserves identity', async () => { + const queryError = new Error('search query failed'); + const { db } = createSearchDb(Promise.reject(queryError)); + const recording = createRecordingOperationPhaseCapture(); + + await expect( + searchContent( + db, + 'playlist-1', + 'news', + ['live'], + false, + recording.capture + ) + ).rejects.toBe(queryError); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY, + }, + { + boundary: 'end', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY, + }, + ]); + }); + + it('keeps invalid input as zero-work with no query or phases', async () => { + const { db, select } = createSearchDb(Promise.resolve([])); + const recording = createRecordingOperationPhaseCapture(); + + await expect( + searchContent( + db, + 'playlist-1', + '', + ['live'], + false, + recording.capture + ) + ).resolves.toEqual([]); + expect(select).not.toHaveBeenCalled(); + expect(recording.events).toEqual([]); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index b2eeaf8c8..f22822e3c 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -9,6 +9,7 @@ import { GlobalSearchResult, GlobalSearchResultSource, M3uGlobalSearchResult, + XTREAM_DATABASE_PERFORMANCE_PHASE, getXtreamRecentlyAddedMaxEpochSeconds, toXtreamRecentlyAddedEpochSeconds, XtreamGlobalSearchResult, @@ -31,12 +32,15 @@ import { shouldUseContentTitleFts, shouldUseContentTitlePrefixIndex, } from './content-search.util'; +import type { OperationControl } from './operation-control'; +import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture'; import { - checkpointOperation, - chunkValues, - type OperationControl, - reportOperationProgress, -} from './operation-control'; + deleteXtreamCacheRows, + normalizeXtreamContentValues, + rankSearchCandidates, + writeXtreamContentValues, + type XtreamContentValue, +} from './xtream-content-operation-steps'; export { scoreSearchTextMatch } from './content-search.util'; @@ -673,7 +677,8 @@ export async function hasContent( export async function getContent( db: AppDatabase, playlistId: string, - type: 'live' | 'movie' | 'series' + type: 'live' | 'movie' | 'series', + capturePhase?: DatabaseOperationPerformancePhaseCapture ) { const baseQuery = db .select(selectContentFields()) @@ -689,17 +694,22 @@ export async function getContent( ) ); - return type === 'live' - ? baseQuery.orderBy(asc(schema.content.id)) - : baseQuery.orderBy(desc(schema.content.added)); + const query = + type === 'live' + ? baseQuery.orderBy(asc(schema.content.id)) + : baseQuery.orderBy(desc(schema.content.added)); + + return capturePhase + ? capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ, + async () => query, + (rows) => ({ itemCount: rows.length }) + ) + : query; } export type RecentlyAddedPlaylistType = - | 'xtream' - | 'stalker' - | 'm3u-file' - | 'm3u-text' - | 'm3u-url'; + 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'; export async function getGlobalRecentlyAdded( db: AppDatabase, @@ -796,20 +806,6 @@ function getGlobalRecentlyAddedByType( .limit(limit); } -type XtreamContentValue = { - categoryId: number; - title: string; - rating: string; - added: string; - posterUrl: string; - epgChannelId?: string | null; - tvArchive?: number | null; - tvArchiveDuration?: number | null; - directSource?: string | null; - xtreamId: number; - type: 'live' | 'movie' | 'series'; -}; - type XtreamContentSource = Record & { category_id?: string | number; rating?: string | number; @@ -898,7 +894,8 @@ export async function saveContent( playlistId: string, streams: Array>, type: 'live' | 'movie' | 'series', - control?: OperationControl + control?: OperationControl, + capturePhase?: DatabaseOperationPerformancePhaseCapture ): Promise<{ success: boolean; count: number }> { const dbType = type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live'; @@ -922,7 +919,7 @@ export async function saveContent( return { success: true, count: existingContent[0].count }; } - const categories = await db + const categoriesQuery = db .select({ id: schema.categories.id, xtreamId: schema.categories.xtreamId, @@ -935,49 +932,46 @@ export async function saveContent( ) ); - const categoryMap = new Map( - categories.map((category) => [category.xtreamId, category.id]) - ); + const categories = capturePhase + ? await capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_CATEGORY_MAP_READ, + async () => categoriesQuery, + (rows) => ({ itemCount: rows.length }) + ) + : await categoriesQuery; + const values = capturePhase + ? capturePhase.captureSync( + XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_CONTENT, + () => + normalizeXtreamContentValues( + streams, + type, + categories, + toXtreamContentValue + ), + (result) => ({ itemCount: result.length }) + ) + : normalizeXtreamContentValues( + streams, + type, + categories, + toXtreamContentValue + ); - const values = streams - .map((stream) => toXtreamContentValue(stream, type, categoryMap)) - .filter((value): value is XtreamContentValue => value !== null); - - const total = values.length; - const chunkSize = 100; - let totalInserted = 0; - - for (let index = 0; index < values.length; index += chunkSize) { - await checkpointOperation(control); - const chunk = values.slice(index, index + chunkSize); - await db.transaction((tx) => { - tx.insert(schema.content) - .values(chunk) - .onConflictDoNothing({ - target: [ - schema.content.categoryId, - schema.content.type, - schema.content.xtreamId, - ], - }) - .run(); - }); - totalInserted += chunk.length; - await reportOperationProgress(control, { - phase: 'saving-content', - current: totalInserted, - total, - increment: chunk.length, - }); - } - - return { success: true, count: totalInserted }; + return capturePhase + ? capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_WRITE_TRANSACTIONS, + () => writeXtreamContentValues(db, values, control), + (result) => ({ itemCount: result.count }) + ) + : writeXtreamContentValues(db, values, control); } export async function clearXtreamImportCache( db: AppDatabase, playlistId: string, - type: 'live' | 'movie' | 'series' + type: 'live' | 'movie' | 'series', + capturePhase?: DatabaseOperationPerformancePhaseCapture ): Promise<{ success: boolean }> { const dbType = type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live'; @@ -994,7 +988,13 @@ export async function clearXtreamImportCache( const categoryIds = categoryRows.map((category) => category.id); if (categoryIds.length === 0) { - return { success: true }; + return capturePhase + ? capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + async () => ({ success: true }), + () => ({ itemCount: 0 }) + ) + : { success: true }; } const contentRows = await db @@ -1002,26 +1002,13 @@ export async function clearXtreamImportCache( .from(schema.content) .where(inArray(schema.content.categoryId, categoryIds)); - for (const chunk of chunkValues( - contentRows.map((row) => row.id), - 100 - )) { - await db.transaction((tx) => { - tx.delete(schema.content) - .where(inArray(schema.content.id, chunk)) - .run(); - }); - } - - for (const chunk of chunkValues(categoryIds, 100)) { - await db.transaction((tx) => { - tx.delete(schema.categories) - .where(inArray(schema.categories.id, chunk)) - .run(); - }); - } - - return { success: true }; + return capturePhase + ? capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS, + () => deleteXtreamCacheRows(db, contentRows, categoryIds), + () => ({ itemCount: contentRows.length + categoryIds.length }) + ) + : deleteXtreamCacheRows(db, contentRows, categoryIds); } export async function getContentByXtreamId( @@ -1057,7 +1044,8 @@ export async function searchContent( playlistId: string, searchTerm: string, types: string[], - excludeHidden = false + excludeHidden = false, + capturePhase?: DatabaseOperationPerformancePhaseCapture ) { if (!types || types.length === 0 || !normalizeSearchMatchText(searchTerm)) { return []; @@ -1076,7 +1064,7 @@ export async function searchContent( conditions.push(eq(schema.categories.hidden, false)); } - const candidates = await db + const query = db .select(selectContentFields()) .from(schema.content) .innerJoin( @@ -1086,12 +1074,21 @@ export async function searchContent( .where(and(...conditions)) .limit(200); - return candidates - .filter( - (item) => - scoreSearchTextMatch(item.title ?? '', searchTerm) !== null - ) - .slice(0, 50); + const candidates = capturePhase + ? await capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_SEARCH_QUERY, + async () => query, + (rows) => ({ itemCount: rows.length }) + ) + : await query; + + return capturePhase + ? capturePhase.captureSync( + XTREAM_DATABASE_PERFORMANCE_PHASE.NORMALIZE_SEARCH_RANK, + () => rankSearchCandidates(candidates, searchTerm), + (result) => ({ itemCount: result.length }) + ) + : rankSearchCandidates(candidates, searchTerm); } export async function globalSearch( diff --git a/apps/electron-backend/src/app/database/operations/performance-phase-capture.test-helpers.ts b/apps/electron-backend/src/app/database/operations/performance-phase-capture.test-helpers.ts new file mode 100644 index 000000000..80a78d9c7 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/performance-phase-capture.test-helpers.ts @@ -0,0 +1,68 @@ +import type { + XtreamDatabasePerformancePhase, + PerformancePhaseMetadata, +} from '@iptvnator/shared/interfaces'; +import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture'; + +export interface RecordedOperationPhase { + readonly boundary: 'end' | 'start'; + readonly metadata?: PerformancePhaseMetadata; + readonly phase: XtreamDatabasePerformancePhase; +} + +export function createRecordingOperationPhaseCapture( + onBoundary?: (event: RecordedOperationPhase) => void +): { + readonly capture: DatabaseOperationPerformancePhaseCapture; + readonly events: RecordedOperationPhase[]; +} { + const events: RecordedOperationPhase[] = []; + const record = (event: RecordedOperationPhase): void => { + events.push(event); + onBoundary?.(event); + }; + + return { + capture: { + async captureAsync( + phase: XtreamDatabasePerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ): Promise { + record({ boundary: 'start', phase }); + try { + const result = await execute(); + record({ + boundary: 'end', + metadata: metadata?.(result), + phase, + }); + return result; + } catch (error) { + record({ boundary: 'end', phase }); + throw error; + } + }, + captureSync( + phase: XtreamDatabasePerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ): TResult { + record({ boundary: 'start', phase }); + try { + const result = execute(); + record({ + boundary: 'end', + metadata: metadata?.(result), + phase, + }); + return result; + } catch (error) { + record({ boundary: 'end', phase }); + throw error; + } + }, + }, + events, + }; +} diff --git a/apps/electron-backend/src/app/database/operations/performance-phase-capture.ts b/apps/electron-backend/src/app/database/operations/performance-phase-capture.ts new file mode 100644 index 000000000..ecafad7e8 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/performance-phase-capture.ts @@ -0,0 +1,17 @@ +import type { + PerformancePhaseMetadata, + XtreamDatabasePerformancePhase, +} from '@iptvnator/shared/interfaces'; + +export interface DatabaseOperationPerformancePhaseCapture { + captureAsync: ( + phase: XtreamDatabasePerformancePhase, + execute: () => Promise, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => Promise; + captureSync: ( + phase: XtreamDatabasePerformancePhase, + execute: () => TResult, + metadata?: (result: TResult) => PerformancePhaseMetadata + ) => TResult; +} diff --git a/apps/electron-backend/src/app/database/operations/playlist-delete-performance.spec.ts b/apps/electron-backend/src/app/database/operations/playlist-delete-performance.spec.ts new file mode 100644 index 000000000..695a273ec --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/playlist-delete-performance.spec.ts @@ -0,0 +1,165 @@ +import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; +import type { AppDatabase } from '../database.types'; +import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers'; +import { deletePlaylist } from './playlist.operations'; + +function createSelectStep( + rows: readonly unknown[], + label: string, + timeline: string[] +) { + const where = jest.fn(() => { + timeline.push(label); + return Promise.resolve(rows); + }); + const from = jest.fn(() => ({ where })); + return { from }; +} + +function createDeleteHarness() { + const timeline: string[] = []; + const selections = [ + ['select:favorites', [{ id: 1 }, { id: 2 }]], + ['select:recently-viewed', [{ id: 3 }]], + ['select:playback-positions', []], + ['select:downloads', [{ id: 4 }]], + ['select:categories', [{ id: 10 }, { id: 11 }]], + [ + 'select:content', + Array.from({ length: 205 }, (_, id) => ({ id: id + 100 })), + ], + ] as const; + const select = jest.fn(); + for (const [label, rows] of selections) { + select.mockReturnValueOnce(createSelectStep(rows, label, timeline)); + } + const transactionRun = jest.fn(); + const transactionDelete = jest.fn(() => ({ + where: jest.fn(() => ({ run: transactionRun })), + })); + const transaction = jest.fn((execute: (tx: unknown) => unknown) => { + timeline.push('transaction'); + return execute({ delete: transactionDelete }); + }); + const playlistWhere = jest.fn(() => { + timeline.push('delete:playlist'); + return Promise.resolve(undefined); + }); + const deletePlaylistRow = jest.fn(() => ({ where: playlistWhere })); + + return { + db: { + delete: deletePlaylistRow, + select, + transaction, + } as unknown as AppDatabase, + timeline, + transaction, + }; +} + +describe('playlist delete performance phases', () => { + it('counts collected dependent IDs and includes the final playlist row in writes', async () => { + const harness = createDeleteHarness(); + const recording = createRecordingOperationPhaseCapture((event) => { + harness.timeline.push(`${event.phase}:${event.boundary}`); + }); + const checkpoint = jest.fn(() => { + harness.timeline.push('checkpoint'); + }); + const onProgress = jest.fn((progress: { phase: string }) => { + harness.timeline.push(`progress:${progress.phase}`); + }); + + await expect( + deletePlaylist( + harness.db, + 'playlist-1', + { checkpoint, onProgress }, + recording.capture + ) + ).resolves.toEqual({ success: true }); + + expect(harness.transaction).toHaveBeenCalledTimes(7); + // Seven committed chunks plus the final playlist row each keep both + // the pre-write and post-progress cooperative checkpoints. + expect(checkpoint).toHaveBeenCalledTimes(16); + expect(harness.timeline.slice(1, 7)).toEqual([ + 'select:favorites', + 'select:recently-viewed', + 'select:playback-positions', + 'select:downloads', + 'select:categories', + 'select:content', + ]); + const writeStart = harness.timeline.indexOf( + 'sqlite.playlist-delete.write-transactions:start' + ); + const writeEnd = harness.timeline.indexOf( + 'sqlite.playlist-delete.write-transactions:end' + ); + expect(harness.timeline.indexOf('delete:playlist')).toBeGreaterThan( + writeStart + ); + expect(writeEnd).toBeGreaterThan( + harness.timeline.indexOf('progress:deleting-playlist') + ); + expect( + harness.timeline + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => entry === 'transaction') + .every(({ index }) => index > writeStart && index < writeEnd) + ).toBe(true); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + }, + { + boundary: 'end', + metadata: { itemCount: 211 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + }, + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + }, + { + boundary: 'end', + metadata: { itemCount: 212 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + }, + ]); + }); + + it('closes the write phase on cancellation before deleting the playlist row', async () => { + const harness = createDeleteHarness(); + const recording = createRecordingOperationPhaseCapture(); + const cancellation = new Error('synthetic cancellation'); + cancellation.name = 'AbortError'; + let checkpointCount = 0; + + await expect( + deletePlaylist( + harness.db, + 'playlist-1', + { + checkpoint: () => { + checkpointCount += 1; + if (checkpointCount === 2) { + throw cancellation; + } + }, + }, + recording.capture + ) + ).rejects.toBe(cancellation); + + expect(harness.transaction).toHaveBeenCalledTimes(1); + expect(harness.timeline).not.toContain('delete:playlist'); + expect(recording.events.at(-1)).toEqual({ + boundary: 'end', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + }); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index c8f5843db..8401fa991 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -4,6 +4,7 @@ import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces'; import { APP_PLAYLIST_GET_PERFORMANCE_PHASE, APP_PLAYLIST_UPSERT_PERFORMANCE_PHASE, + XTREAM_DATABASE_PERFORMANCE_PHASE, type AppPlaylistGetPerformancePhase, type AppPlaylistUpsertPerformancePhase, type PerformancePhaseMetadata, @@ -15,6 +16,7 @@ import { type OperationControl, reportOperationProgress, } from './operation-control'; +import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture'; const PLAYLIST_TYPES = { XTREAM: 'xtream', @@ -365,13 +367,10 @@ export async function upsertAppPlaylist( } const write = async () => { - await db - .insert(schema.playlists) - .values(row) - .onConflictDoUpdate({ - target: schema.playlists.id, - set: row, - }); + await db.insert(schema.playlists).values(row).onConflictDoUpdate({ + target: schema.playlists.id, + set: row, + }); }; if (capturePhase) { await capturePhase.captureAsync( @@ -483,17 +482,14 @@ export async function getAppPlaylist( (value) => ({ itemCount: value.length }) ) : await read(); - const deserialize = () => - rows[0] ? parseAppPlaylist(rows[0]) : null; + const deserialize = () => (rows[0] ? parseAppPlaylist(rows[0]) : null); return capturePhase ? capturePhase.captureSync( APP_PLAYLIST_GET_PERFORMANCE_PHASE.DESERIALIZE_PLAYLIST, deserialize, (value) => ({ - itemCount: value - ? (getPlaylistItemCount(value) ?? 0) - : 0, + itemCount: value ? (getPlaylistItemCount(value) ?? 0) : 0, }) ) : deserialize(); @@ -592,11 +588,19 @@ export async function updatePlaylist( return { success: true }; } -export async function deletePlaylist( +interface PlaylistDeletionCollection { + readonly categoryIds: number[]; + readonly contentRows: Array<{ id: number }>; + readonly downloadRows: Array<{ id: number }>; + readonly favoriteRows: Array<{ id: number }>; + readonly playbackPositionRows: Array<{ id: number }>; + readonly recentlyViewedRows: Array<{ id: number }>; +} + +async function collectPlaylistDeletionRows( db: AppDatabase, - playlistId: string, - control?: OperationControl -): Promise<{ success: boolean }> { + playlistId: string +): Promise { const [ favoriteRows, recentlyViewedRows, @@ -634,40 +638,69 @@ export async function deletePlaylist( .where(inArray(schema.content.categoryId, categoryIds)) : []; + return { + categoryIds, + contentRows, + downloadRows, + favoriteRows, + playbackPositionRows, + recentlyViewedRows, + }; +} + +function countPlaylistDeletionRows( + collection: PlaylistDeletionCollection +): number { + return ( + collection.favoriteRows.length + + collection.recentlyViewedRows.length + + collection.playbackPositionRows.length + + collection.downloadRows.length + + collection.contentRows.length + + collection.categoryIds.length + ); +} + +async function deleteCollectedPlaylistRows( + db: AppDatabase, + playlistId: string, + collection: PlaylistDeletionCollection, + control?: OperationControl +): Promise { for (const [phase, ids, column, table] of [ [ 'deleting-favorites', - favoriteRows.map((row) => row.id), + collection.favoriteRows.map((row) => row.id), schema.favorites.id, schema.favorites, ], [ 'deleting-recently-viewed', - recentlyViewedRows.map((row) => row.id), + collection.recentlyViewedRows.map((row) => row.id), schema.recentlyViewed.id, schema.recentlyViewed, ], [ 'deleting-playback-positions', - playbackPositionRows.map((row) => row.id), + collection.playbackPositionRows.map((row) => row.id), schema.playbackPositions.id, schema.playbackPositions, ], [ 'deleting-downloads', - downloadRows.map((row) => row.id), + collection.downloadRows.map((row) => row.id), schema.downloads.id, schema.downloads, ], [ 'deleting-content', - contentRows.map((row) => row.id), + collection.contentRows.map((row) => row.id), schema.content.id, schema.content, ], [ 'deleting-categories', - categoryIds, + collection.categoryIds, schema.categories.id, schema.categories, ], @@ -701,6 +734,41 @@ export async function deletePlaylist( increment: 1, }); + return countPlaylistDeletionRows(collection) + 1; +} + +export async function deletePlaylist( + db: AppDatabase, + playlistId: string, + control?: OperationControl, + capturePhase?: DatabaseOperationPerformancePhaseCapture +): Promise<{ success: boolean }> { + const collection = capturePhase + ? await capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_COLLECT_IDS, + () => collectPlaylistDeletionRows(db, playlistId), + (result) => ({ + itemCount: countPlaylistDeletionRows(result), + }) + ) + : await collectPlaylistDeletionRows(db, playlistId); + + if (capturePhase) { + await capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS, + () => + deleteCollectedPlaylistRows( + db, + playlistId, + collection, + control + ), + (itemCount) => ({ itemCount }) + ); + } else { + await deleteCollectedPlaylistRows(db, playlistId, collection, control); + } + return { success: true }; } diff --git a/apps/electron-backend/src/app/database/operations/xtream-content-operation-steps.ts b/apps/electron-backend/src/app/database/operations/xtream-content-operation-steps.ts new file mode 100644 index 000000000..a522ff993 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/xtream-content-operation-steps.ts @@ -0,0 +1,117 @@ +import { inArray } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../database.types'; +import { scoreSearchTextMatch } from './content-search.util'; +import { + checkpointOperation, + chunkValues, + type OperationControl, + reportOperationProgress, +} from './operation-control'; + +export type XtreamContentValue = { + categoryId: number; + title: string; + rating: string; + added: string; + posterUrl: string; + epgChannelId?: string | null; + tvArchive?: number | null; + tvArchiveDuration?: number | null; + directSource?: string | null; + xtreamId: number; + type: 'live' | 'movie' | 'series'; +}; + +export function normalizeXtreamContentValues( + streams: Array>, + type: 'live' | 'movie' | 'series', + categories: Array<{ id: number; xtreamId: number }>, + normalizeValue: ( + stream: Record, + type: 'live' | 'movie' | 'series', + categoryMap: Map + ) => XtreamContentValue | null +): XtreamContentValue[] { + const categoryMap = new Map( + categories.map((category) => [category.xtreamId, category.id]) + ); + + return streams + .map((stream) => normalizeValue(stream, type, categoryMap)) + .filter((value): value is XtreamContentValue => value !== null); +} + +export async function writeXtreamContentValues( + db: AppDatabase, + values: XtreamContentValue[], + control?: OperationControl +): Promise<{ success: boolean; count: number }> { + const total = values.length; + const chunkSize = 100; + let totalInserted = 0; + + for (let index = 0; index < values.length; index += chunkSize) { + await checkpointOperation(control); + const chunk = values.slice(index, index + chunkSize); + await db.transaction((tx) => { + tx.insert(schema.content) + .values(chunk) + .onConflictDoNothing({ + target: [ + schema.content.categoryId, + schema.content.type, + schema.content.xtreamId, + ], + }) + .run(); + }); + totalInserted += chunk.length; + await reportOperationProgress(control, { + phase: 'saving-content', + current: totalInserted, + total, + increment: chunk.length, + }); + } + + return { success: true, count: totalInserted }; +} + +export async function deleteXtreamCacheRows( + db: AppDatabase, + contentRows: Array<{ id: number }>, + categoryIds: number[] +): Promise<{ success: boolean }> { + for (const chunk of chunkValues( + contentRows.map((row) => row.id), + 100 + )) { + await db.transaction((tx) => { + tx.delete(schema.content) + .where(inArray(schema.content.id, chunk)) + .run(); + }); + } + + for (const chunk of chunkValues(categoryIds, 100)) { + await db.transaction((tx) => { + tx.delete(schema.categories) + .where(inArray(schema.categories.id, chunk)) + .run(); + }); + } + + return { success: true }; +} + +export function rankSearchCandidates< + TCandidate extends { title: string | null }, +>(candidates: TCandidate[], searchTerm: string): TCandidate[] { + return candidates + .filter( + (item) => + scoreSearchTextMatch(item.title ?? '', searchTerm) !== null + ) + .slice(0, 50); +} diff --git a/apps/electron-backend/src/app/database/operations/xtream-delete-performance.spec.ts b/apps/electron-backend/src/app/database/operations/xtream-delete-performance.spec.ts new file mode 100644 index 000000000..5be8b47b9 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/xtream-delete-performance.spec.ts @@ -0,0 +1,217 @@ +import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; +import type { AppDatabase } from '../database.types'; +import { createRecordingOperationPhaseCapture } from './performance-phase-capture.test-helpers'; +import { deleteXtreamContent } from './xtream.operations'; + +function createSelectStep( + rows: readonly unknown[], + label: string, + timeline: string[] +) { + const where = jest.fn(() => { + timeline.push(label); + return Promise.resolve(rows); + }); + const innerJoin = jest.fn(() => ({ where })); + const from = jest.fn(() => ({ innerJoin, where })); + return { from }; +} + +function createDeleteHarness() { + const timeline: string[] = []; + const categories = [ + { hidden: true, id: 11, type: 'live', xtreamId: 101 }, + { hidden: false, id: 12, type: 'movies', xtreamId: 102 }, + ]; + const favorites = [ + { + addedAt: '2026-01-01T00:00:00.000Z', + contentType: 'live', + position: 3, + xtreamId: 201, + }, + ]; + const recentlyViewed = [ + { + contentType: 'movie', + viewedAt: '2026-01-02T00:00:00.000Z', + xtreamId: 202, + }, + ]; + const content = Array.from({ length: 205 }, (_, id) => ({ id: id + 1 })); + const select = jest + .fn() + .mockReturnValueOnce( + createSelectStep(categories, 'select:categories', timeline) + ) + .mockReturnValueOnce( + createSelectStep(favorites, 'select:favorites', timeline) + ) + .mockReturnValueOnce( + createSelectStep(recentlyViewed, 'select:recently-viewed', timeline) + ) + .mockReturnValueOnce( + createSelectStep(content, 'select:content', timeline) + ); + const run = jest.fn(); + const deleteRows = jest.fn(() => ({ + where: jest.fn(() => ({ run })), + })); + const transaction = jest.fn((execute: (tx: unknown) => unknown) => { + timeline.push('transaction'); + return execute({ delete: deleteRows }); + }); + + return { + content, + db: { select, transaction } as unknown as AppDatabase, + timeline, + transaction, + }; +} + +describe('Xtream delete performance phases', () => { + it('times all user-data collection but counts only content/category deletion candidates', async () => { + const harness = createDeleteHarness(); + const recording = createRecordingOperationPhaseCapture((event) => { + harness.timeline.push(`${event.phase}:${event.boundary}`); + }); + const checkpoint = jest.fn(() => { + harness.timeline.push('checkpoint'); + }); + const onProgress = jest.fn((progress: { current?: number }) => { + harness.timeline.push(`progress:${progress.current}`); + }); + + await expect( + deleteXtreamContent( + harness.db, + 'playlist-1', + { checkpoint, onProgress }, + recording.capture + ) + ).resolves.toEqual({ + favorites: [ + { + addedAt: '2026-01-01T00:00:00.000Z', + contentType: 'live', + position: 3, + xtreamId: 201, + }, + ], + hiddenCategories: [{ categoryType: 'live', xtreamId: 101 }], + recentlyViewed: [ + { + contentType: 'movie', + viewedAt: '2026-01-02T00:00:00.000Z', + xtreamId: 202, + }, + ], + success: true, + }); + + expect(harness.transaction).toHaveBeenCalledTimes(4); + // Existing progress reporting performs a second cooperative + // checkpoint after each committed batch. + expect(checkpoint).toHaveBeenCalledTimes(8); + expect(onProgress.mock.calls.map(([value]) => value.current)).toEqual([ + 100, 200, 205, 2, + ]); + expect(harness.timeline.slice(1, 5)).toEqual([ + 'select:categories', + 'select:favorites', + 'select:recently-viewed', + 'select:content', + ]); + const writeStart = harness.timeline.indexOf( + 'sqlite.xtream-delete.write-transactions:start' + ); + const writeEnd = harness.timeline.indexOf( + 'sqlite.xtream-delete.write-transactions:end' + ); + expect(writeEnd).toBeGreaterThan( + harness.timeline.lastIndexOf('progress:2') + ); + expect( + harness.timeline + .map((entry, index) => ({ entry, index })) + .filter(({ entry }) => entry === 'transaction') + .every(({ index }) => index > writeStart && index < writeEnd) + ).toBe(true); + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + }, + { + boundary: 'end', + metadata: { itemCount: 207 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + }, + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + }, + { + boundary: 'end', + metadata: { itemCount: 207 }, + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + }, + ]); + }); + + it('closes the write phase on cancellation and preserves error identity', async () => { + const harness = createDeleteHarness(); + const recording = createRecordingOperationPhaseCapture(); + const cancellation = new Error('synthetic cancellation'); + cancellation.name = 'AbortError'; + let checkpointCount = 0; + + await expect( + deleteXtreamContent( + harness.db, + 'playlist-1', + { + checkpoint: () => { + checkpointCount += 1; + if (checkpointCount === 2) { + throw cancellation; + } + }, + }, + recording.capture + ) + ).rejects.toBe(cancellation); + + expect(harness.transaction).toHaveBeenCalledTimes(1); + expect(recording.events.at(-1)).toEqual({ + boundary: 'end', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + }); + }); + + it('closes collection on query failure and preserves error identity', async () => { + const failure = new Error('synthetic select failure'); + const where = jest.fn().mockRejectedValue(failure); + const from = jest.fn(() => ({ where })); + const db = { + select: jest.fn(() => ({ from })), + } as unknown as AppDatabase; + const recording = createRecordingOperationPhaseCapture(); + + await expect( + deleteXtreamContent(db, 'playlist-1', undefined, recording.capture) + ).rejects.toBe(failure); + + expect(recording.events).toEqual([ + { + boundary: 'start', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + }, + { + boundary: 'end', + phase: XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + }, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/xtream.operations.ts b/apps/electron-backend/src/app/database/operations/xtream.operations.ts index 93643f951..779c7d779 100644 --- a/apps/electron-backend/src/app/database/operations/xtream.operations.ts +++ b/apps/electron-backend/src/app/database/operations/xtream.operations.ts @@ -1,9 +1,10 @@ import { and, eq, inArray } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; -import type { - XtreamBackupFavoriteItem, - XtreamBackupHiddenCategory, - XtreamBackupRecentlyViewedItem, +import { + XTREAM_DATABASE_PERFORMANCE_PHASE, + type XtreamBackupFavoriteItem, + type XtreamBackupHiddenCategory, + type XtreamBackupRecentlyViewedItem, } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; import { @@ -12,6 +13,7 @@ import { type OperationControl, reportOperationProgress, } from './operation-control'; +import type { DatabaseOperationPerformancePhaseCapture } from './performance-phase-capture'; const DEFAULT_BATCH_SIZE = 100; @@ -28,16 +30,18 @@ function toContentIdentityKey( return `${contentType}:${xtreamId}`; } -export async function deleteXtreamContent( +interface XtreamDeletionCollection { + readonly categoryIds: number[]; + readonly contentRows: Array<{ id: number }>; + readonly favorites: XtreamBackupFavoriteItem[]; + readonly hiddenCategories: XtreamBackupHiddenCategory[]; + readonly recentlyViewed: XtreamBackupRecentlyViewedItem[]; +} + +async function collectXtreamDeletionRows( db: AppDatabase, - playlistId: string, - control?: OperationControl -): Promise<{ - success: boolean; - favorites: XtreamBackupFavoriteItem[]; - recentlyViewed: XtreamBackupRecentlyViewedItem[]; - hiddenCategories: XtreamBackupHiddenCategory[]; -}> { + playlistId: string +): Promise { const categories = await db .select({ id: schema.categories.id, @@ -58,6 +62,7 @@ export async function deleteXtreamContent( let favorites: XtreamBackupFavoriteItem[] = []; let recentlyViewed: XtreamBackupRecentlyViewedItem[] = []; + let contentRows: Array<{ id: number }> = []; if (categoryIds.length > 0) { const favoritedContent = await db @@ -110,43 +115,58 @@ export async function deleteXtreamContent( viewedAt: item.viewedAt || new Date().toISOString(), })); - const contentRows = await db + contentRows = await db .select({ id: schema.content.id }) .from(schema.content) .where(inArray(schema.content.categoryId, categoryIds)); + } - let deletedContent = 0; - const totalContent = contentRows.length; + return { + categoryIds, + contentRows, + favorites, + hiddenCategories, + recentlyViewed, + }; +} - for (const chunk of chunkValues( - contentRows.map((content) => content.id), - DEFAULT_BATCH_SIZE - )) { - await checkpointOperation(control); - await db.transaction((tx) => { - tx - .delete(schema.content) - .where(inArray(schema.content.id, chunk)) - .run(); - }); - deletedContent += chunk.length; - await reportOperationProgress(control, { - phase: 'deleting-content', - current: deletedContent, - total: totalContent, - increment: chunk.length, - }); - } +async function deleteCollectedXtreamRows( + db: AppDatabase, + collection: XtreamDeletionCollection, + control?: OperationControl +): Promise { + let deletedContent = 0; + const totalContent = collection.contentRows.length; + + for (const chunk of chunkValues( + collection.contentRows.map((content) => content.id), + DEFAULT_BATCH_SIZE + )) { + await checkpointOperation(control); + await db.transaction((tx) => { + tx.delete(schema.content) + .where(inArray(schema.content.id, chunk)) + .run(); + }); + deletedContent += chunk.length; + await reportOperationProgress(control, { + phase: 'deleting-content', + current: deletedContent, + total: totalContent, + increment: chunk.length, + }); } let deletedCategories = 0; - const totalCategories = categoryIds.length; + const totalCategories = collection.categoryIds.length; - for (const chunk of chunkValues(categoryIds, DEFAULT_BATCH_SIZE)) { + for (const chunk of chunkValues( + collection.categoryIds, + DEFAULT_BATCH_SIZE + )) { await checkpointOperation(control); await db.transaction((tx) => { - tx - .delete(schema.categories) + tx.delete(schema.categories) .where(inArray(schema.categories.id, chunk)) .run(); }); @@ -159,11 +179,46 @@ export async function deleteXtreamContent( }); } + return totalContent + totalCategories; +} + +export async function deleteXtreamContent( + db: AppDatabase, + playlistId: string, + control?: OperationControl, + capturePhase?: DatabaseOperationPerformancePhaseCapture +): Promise<{ + success: boolean; + favorites: XtreamBackupFavoriteItem[]; + recentlyViewed: XtreamBackupRecentlyViewedItem[]; + hiddenCategories: XtreamBackupHiddenCategory[]; +}> { + const collection = capturePhase + ? await capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, + () => collectXtreamDeletionRows(db, playlistId), + (result) => ({ + itemCount: + result.contentRows.length + result.categoryIds.length, + }) + ) + : await collectXtreamDeletionRows(db, playlistId); + + if (capturePhase) { + await capturePhase.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS, + () => deleteCollectedXtreamRows(db, collection, control), + (itemCount) => ({ itemCount }) + ); + } else { + await deleteCollectedXtreamRows(db, collection, control); + } + return { success: true, - favorites, - recentlyViewed, - hiddenCategories, + favorites: collection.favorites, + recentlyViewed: collection.recentlyViewed, + hiddenCategories: collection.hiddenCategories, }; } diff --git a/apps/electron-backend/src/app/events/xtream-performance.runtime.spec.ts b/apps/electron-backend/src/app/events/xtream-performance.runtime.spec.ts new file mode 100644 index 000000000..ed3af1e0f --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream-performance.runtime.spec.ts @@ -0,0 +1,263 @@ +import type { + AxiosResponseHeaders, + AxiosResponseTransformer, + InternalAxiosRequestConfig, +} from 'axios'; +import { + XTREAM_MAIN_PERFORMANCE_PHASE, + type PerformancePhaseEvent, + type XtreamMainPerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { + createXtreamMainPerformanceCapture, + createXtreamMeasuredTransformResponse, + type XtreamMainPerformanceCapture, + type XtreamMainPerformanceCaptureRuntime, +} from './xtream-performance'; + +type MainPhaseEvent = PerformancePhaseEvent; + +function requireCapture( + runtime: XtreamMainPerformanceCaptureRuntime, + events: MainPhaseEvent[], + requestId?: string +): XtreamMainPerformanceCapture { + const capture = createXtreamMainPerformanceCapture({ + emit: (event) => events.push(event), + enabled: true, + requestId, + runtime, + }); + if (!capture) { + throw new Error('Expected performance capture'); + } + return capture; +} + +describe('Xtream main performance runtime hardening', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('uses a clock-free safe identifier and epoch when every injected clock throws', () => { + const events: MainPhaseEvent[] = []; + const runtime: XtreamMainPerformanceCaptureRuntime = { + createRequestId: jest.fn(() => { + throw new Error('identifier failed'); + }), + readEpochMs: jest.fn(() => { + throw new Error('epoch failed'); + }), + readMonotonicMs: jest.fn(() => { + throw new Error('monotonic failed'); + }), + }; + const wallClock = jest.spyOn(Date, 'now').mockImplementation(() => { + throw new Error('wall clock must stay untouched'); + }); + let capture: XtreamMainPerformanceCapture | undefined; + + expect(() => { + capture = requireCapture(runtime, events, ' unsafe request id '); + }).not.toThrow(); + expect(() => + capture?.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY, + () => undefined + ) + ).not.toThrow(); + + expect(wallClock).not.toHaveBeenCalled(); + expect(events).toHaveLength(2); + expect( + events.every( + ({ epochMs, requestId }) => + Number.isFinite(epochMs) && + epochMs > 0 && + /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(requestId) + ) + ).toBe(true); + }); + + it('keeps emitted epochs positive, finite, and nondecreasing across invalid and backward samples', () => { + const events: MainPhaseEvent[] = []; + const epochSamples = [Number.NaN, 100, 90, Number.POSITIVE_INFINITY]; + const runtime: XtreamMainPerformanceCaptureRuntime = { + createRequestId: jest.fn(() => 'unused'), + readEpochMs: jest.fn(() => epochSamples.shift() ?? 80), + readMonotonicMs: jest.fn(() => 10), + }; + const capture = requireCapture(runtime, events, 'xtream-mabc1001-1'); + + capture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + () => undefined + ); + capture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY, + () => undefined + ); + + const epochs = events.map(({ epochMs }) => epochMs); + expect(epochs.every((epochMs) => Number.isFinite(epochMs))).toBe(true); + expect(epochs.every((epochMs) => epochMs > 0)).toBe(true); + expect( + epochs.every( + (epochMs, index) => index === 0 || epochMs >= epochs[index - 1] + ) + ).toBe(true); + }); + + it('matches Axios by visiting a sparse transform entry and closing the failed JSON phase', () => { + const events: MainPhaseEvent[] = []; + const runtime: XtreamMainPerformanceCaptureRuntime = { + createRequestId: jest.fn(() => 'unused'), + readEpochMs: jest.fn(() => 100), + readMonotonicMs: jest.fn(() => 10), + }; + const capture = requireCapture(runtime, events, 'xtream-mabc1002-2'); + const sparseTransforms = new Array(1); + const measured = createXtreamMeasuredTransformResponse( + capture, + sparseTransforms + ); + + expect(() => + measured.call( + {} as InternalAxiosRequestConfig, + 'raw', + {} as AxiosResponseHeaders, + 200 + ) + ).toThrow(TypeError); + expect(events.map(({ boundary }) => boundary)).toEqual([ + 'start', + 'end', + ]); + }); + + it('is fail-neutral and never publishes unsafe request identifiers or business data', () => { + const runtime: XtreamMainPerformanceCaptureRuntime = { + createRequestId: jest.fn(() => 'xtream-main-safe-fallback'), + readEpochMs: jest.fn(() => 100), + readMonotonicMs: jest.fn(() => 10), + }; + const capture = createXtreamMainPerformanceCapture({ + emit: () => { + throw new Error('diagnostics sink failed'); + }, + enabled: true, + requestId: 'provider-password-secret', + runtime, + }); + const businessError = new Error('provider-password-secret'); + + expect(() => + capture?.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + () => { + throw businessError; + } + ) + ).toThrow(businessError); + expect(runtime.createRequestId).toHaveBeenCalledTimes(1); + + const events: MainPhaseEvent[] = []; + const safeCapture = requireCapture( + runtime, + events, + 'provider-password-secret' + ); + safeCapture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY, + () => ({ secret: 'response-body-secret' }) + ); + expect( + events.every( + ({ requestId }) => requestId === 'xtream-main-safe-fallback' + ) + ).toBe(true); + const serialized = JSON.stringify(events); + expect(serialized).not.toContain('provider-password-secret'); + expect(serialized).not.toContain('response-body-secret'); + expect( + events.every( + (event) => + event.metadata === undefined && + Object.keys(event).every((key) => + [ + 'boundary', + 'durationMs', + 'epochMs', + 'metadata', + 'phase', + 'requestId', + ].includes(key) + ) + ) + ).toBe(true); + }); + + it('correlates only the renderer-generated Xtream request ID shape', () => { + const events: MainPhaseEvent[] = []; + const runtime: XtreamMainPerformanceCaptureRuntime = { + createRequestId: jest.fn(() => 'xtream-main-unused'), + readEpochMs: jest.fn(() => 100), + readMonotonicMs: jest.fn(() => 10), + }; + const capture = requireCapture(runtime, events, 'xtream-mabc1234-17'); + + capture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY, + () => undefined + ); + + expect(events.map(({ requestId }) => requestId)).toEqual([ + 'xtream-mabc1234-17', + 'xtream-mabc1234-17', + ]); + expect(runtime.createRequestId).not.toHaveBeenCalled(); + }); + + it('keeps overlapping request phases isolated by capture-local request ID', async () => { + const events: MainPhaseEvent[] = []; + const runtime: XtreamMainPerformanceCaptureRuntime = { + createRequestId: jest.fn(() => 'unused'), + readEpochMs: jest.fn(() => 100), + readMonotonicMs: jest.fn(() => 10), + }; + const first = requireCapture(runtime, events, 'xtream-mabc1003-3'); + const second = requireCapture(runtime, events, 'xtream-mabc1004-4'); + let resolveFirst!: () => void; + let resolveSecond!: () => void; + const firstWork = new Promise((resolve) => { + resolveFirst = resolve; + }); + const secondWork = new Promise((resolve) => { + resolveSecond = resolve; + }); + + const firstResult = first.measureAsync( + XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + () => firstWork + ); + const secondResult = second.measureAsync( + XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + () => secondWork + ); + resolveSecond(); + await secondResult; + resolveFirst(); + await firstResult; + + for (const requestId of ['xtream-mabc1003-3', 'xtream-mabc1004-4']) { + const requestEvents = events.filter( + (event) => event.requestId === requestId + ); + expect(requestEvents.map(({ boundary }) => boundary)).toEqual([ + 'start', + 'end', + ]); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/xtream-performance.spec.ts b/apps/electron-backend/src/app/events/xtream-performance.spec.ts new file mode 100644 index 000000000..6ef20b2a8 --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream-performance.spec.ts @@ -0,0 +1,235 @@ +import type { + AxiosResponseHeaders, + AxiosResponseTransformer, + InternalAxiosRequestConfig, +} from 'axios'; +import { + XTREAM_MAIN_PERFORMANCE_PHASE, + type PerformancePhaseEvent, + type XtreamMainPerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { + createXtreamMainPerformanceCapture, + createXtreamMeasuredTransformResponse, + type XtreamMainPerformanceCapture, + type XtreamMainPerformanceCaptureRuntime, +} from './xtream-performance'; + +type MainPhaseEvent = PerformancePhaseEvent; + +function createRuntime( + requestId = 'xtream-main-generated' +): XtreamMainPerformanceCaptureRuntime { + let epochMs = 100; + let monotonicMs = 10; + return { + createRequestId: jest.fn(() => requestId), + readEpochMs: jest.fn(() => { + epochMs += 10; + return epochMs; + }), + readMonotonicMs: jest.fn(() => { + monotonicMs += 2; + return monotonicMs; + }), + }; +} + +function createCapture( + events: MainPhaseEvent[], + requestId = 'xtream-mabc1000-1', + runtime = createRuntime() +): XtreamMainPerformanceCapture { + const capture = createXtreamMainPerformanceCapture({ + emit: (event) => events.push(event), + enabled: true, + requestId, + runtime, + }); + if (!capture) { + throw new Error('Expected performance capture'); + } + return capture; +} + +describe('Xtream main-process performance capture', () => { + it('does no identifier, clock, or sink work while disabled', () => { + const runtime: XtreamMainPerformanceCaptureRuntime = { + createRequestId: jest.fn(() => { + throw new Error('identifier must stay untouched'); + }), + readEpochMs: jest.fn(() => { + throw new Error('epoch clock must stay untouched'); + }), + readMonotonicMs: jest.fn(() => { + throw new Error('monotonic clock must stay untouched'); + }), + }; + const emit = jest.fn(() => { + throw new Error('sink must stay untouched'); + }); + + expect( + createXtreamMainPerformanceCapture({ + emit, + enabled: false, + requestId: 'xtream-mabc1000-1', + runtime, + }) + ).toBeNull(); + expect(runtime.createRequestId).not.toHaveBeenCalled(); + expect(runtime.readEpochMs).not.toHaveBeenCalled(); + expect(runtime.readMonotonicMs).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }); + + it('preserves nested async results and exact errors while closing every phase', async () => { + const events: MainPhaseEvent[] = []; + const capture = createCapture(events); + const resultIdentity = { payload: 'same-object' }; + + const result = await capture.measureAsync( + XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + async () => + capture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + () => resultIdentity + ) + ); + + expect(result).toBe(resultIdentity); + expect( + events.map(({ boundary, phase }) => ({ boundary, phase })) + ).toEqual([ + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + ]); + + const businessError = new Error('same-error'); + expect(() => + capture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION, + () => { + throw businessError; + } + ) + ).toThrow(businessError); + expect(events.slice(-2).map(({ boundary }) => boundary)).toEqual([ + 'start', + 'end', + ]); + }); + + it('replays the exact Axios transform chain, context, headers, and status', () => { + const events: MainPhaseEvent[] = []; + const capture = createCapture(events); + const context = { + responseType: undefined, + } as InternalAxiosRequestConfig; + const headers = { + normalize: jest.fn(function () { + return this; + }), + } as unknown as AxiosResponseHeaders; + const firstResult = { step: 1 }; + const finalResult = { step: 2 }; + const observations: unknown[] = []; + const transforms: AxiosResponseTransformer[] = [ + function (data, receivedHeaders, status) { + observations.push({ + context: this, + data, + headers: receivedHeaders, + status, + }); + return firstResult; + }, + function (data, receivedHeaders, status) { + observations.push({ + context: this, + data, + headers: receivedHeaders, + status, + }); + return finalResult; + }, + ]; + const measured = createXtreamMeasuredTransformResponse( + capture, + transforms + ); + + const result = measured.call(context, 'raw-json', headers, 200); + + expect(result).toBe(finalResult); + expect(observations).toEqual([ + { + context, + data: 'raw-json', + headers, + status: 200, + }, + { + context, + data: firstResult, + headers, + status: 200, + }, + ]); + expect(headers.normalize).toHaveBeenCalledTimes(1); + expect( + events.filter( + ({ phase }) => + phase === XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM + ) + ).toHaveLength(2); + }); + + it('runs redirect transforms unchanged but emits one aggregate pair for the final response', () => { + const events: MainPhaseEvent[] = []; + const capture = createCapture(events); + const transform = jest.fn(function (data: unknown) { + return data; + }) as AxiosResponseTransformer; + const measured = createXtreamMeasuredTransformResponse( + capture, + transform + ); + const context = {} as InternalAxiosRequestConfig; + const headers = {} as AxiosResponseHeaders; + + expect(measured.call(context, 'redirect-body', headers, 302)).toBe( + 'redirect-body' + ); + expect(measured.call(context, 'final-body', headers, 200)).toBe( + 'final-body' + ); + expect(transform).toHaveBeenCalledTimes(2); + expect( + events.map(({ boundary, phase }) => ({ boundary, phase })) + ).toEqual([ + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/events/xtream-performance.ts b/apps/electron-backend/src/app/events/xtream-performance.ts new file mode 100644 index 000000000..dfb58bcd8 --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream-performance.ts @@ -0,0 +1,262 @@ +import type { + AxiosResponseHeaders, + AxiosResponseTransformer, + InternalAxiosRequestConfig, +} from 'axios'; +import { + PERFORMANCE_PHASE_BOUNDARY, + XTREAM_MAIN_PERFORMANCE_PHASE, + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL, + type PerformancePhaseEvent, + type XtreamMainPerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { randomUUID } from 'node:crypto'; +import { channel, type Channel } from 'node:diagnostics_channel'; +import { performance } from 'node:perf_hooks'; + +export interface XtreamMainPerformanceCaptureRuntime { + readonly createRequestId: () => string; + readonly readEpochMs: () => number; + readonly readMonotonicMs: () => number; +} + +export interface XtreamMainPerformanceCapture { + measure( + phase: XtreamMainPerformancePhase, + execute: () => TResult + ): TResult; + measureAsync( + phase: XtreamMainPerformancePhase, + execute: () => Promise + ): Promise; +} + +interface CaptureOptions { + readonly emit?: (event: MainPhaseEvent) => void; + readonly enabled: boolean; + readonly requestId?: string; + readonly runtime?: XtreamMainPerformanceCaptureRuntime; +} + +interface PhaseStart { + readonly monotonicMs: number; + readonly phase: XtreamMainPerformancePhase; +} + +type MainPhaseEvent = PerformancePhaseEvent; +type TransformResponse = + AxiosResponseTransformer | AxiosResponseTransformer[] | undefined; + +const DEFAULT_RUNTIME: XtreamMainPerformanceCaptureRuntime = { + createRequestId: () => `xtream-main-${randomUUID()}`, + readEpochMs: () => performance.timeOrigin + performance.now(), + readMonotonicMs: () => performance.now(), +}; +const ENVIRONMENT_CAPTURE_ENABLED = + process.env['IPTVNATOR_PERF_CAPTURE'] === '1'; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const INTERNAL_REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const RENDERER_REQUEST_ID = /^xtream-[0-9a-z]+-[1-9][0-9]*$/; +const MAX_RENDERER_REQUEST_ID_LENGTH = 64; + +let fallbackRequestSequence = 0; +let performanceChannel: Channel | null = null; + +export function createXtreamMainPerformanceCaptureForRequest( + requestId?: string +): XtreamMainPerformanceCapture | null { + return ENVIRONMENT_CAPTURE_ENABLED + ? createXtreamMainPerformanceCapture({ + enabled: true, + requestId, + }) + : null; +} + +export function createXtreamMainPerformanceCapture( + options: CaptureOptions +): XtreamMainPerformanceCapture | null { + if (!options.enabled) { + return null; + } + const runtime = options.runtime ?? DEFAULT_RUNTIME; + const requestId = + normalizeRendererRequestId(options.requestId) ?? + createRequestId(runtime); + const emit = options.emit ?? publishXtreamMainPerformancePhaseEvent; + let lastEpochMs = 0; + const readEpochMs = (): number => { + lastEpochMs = safeReadEpochMs(runtime, lastEpochMs); + return lastEpochMs; + }; + + const begin = (phase: XtreamMainPerformancePhase): PhaseStart => { + const monotonicMs = safeReadMonotonicMs(runtime); + safeEmit(emit, { + boundary: PERFORMANCE_PHASE_BOUNDARY.START, + durationMs: null, + epochMs: readEpochMs(), + metadata: undefined, + phase, + requestId, + }); + return { monotonicMs, phase }; + }; + const end = (start: PhaseStart): void => { + const endedMonotonicMs = safeReadMonotonicMs(runtime); + const durationMs = Math.max(0, endedMonotonicMs - start.monotonicMs); + safeEmit(emit, { + boundary: PERFORMANCE_PHASE_BOUNDARY.END, + durationMs: Number.isFinite(durationMs) ? durationMs : 0, + epochMs: readEpochMs(), + metadata: undefined, + phase: start.phase, + requestId, + }); + }; + + return { + measure: (phase, execute) => { + const start = begin(phase); + try { + return execute(); + } finally { + end(start); + } + }, + measureAsync: async (phase, execute) => { + const start = begin(phase); + try { + return await execute(); + } finally { + end(start); + } + }, + }; +} + +export function createXtreamMeasuredTransformResponse( + capture: XtreamMainPerformanceCapture, + configuredTransforms: TransformResponse +): AxiosResponseTransformer { + const transforms = normalizeTransforms(configuredTransforms); + return function measuredTransformResponse(data, headers, status) { + const execute = () => + runTransformChain(transforms, this, data, headers, status); + return status !== undefined && REDIRECT_STATUSES.has(status) + ? execute() + : capture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + execute + ); + }; +} + +function runTransformChain( + transforms: readonly AxiosResponseTransformer[], + context: InternalAxiosRequestConfig, + initialData: unknown, + headers: AxiosResponseHeaders, + status: number | undefined +): unknown { + let data = initialData; + for (let index = 0; index < transforms.length; index += 1) { + const transform = transforms[index]; + const normalizedHeaders = + index === 0 ? headers : normalizeHeaders(headers); + data = transform.call(context, data, normalizedHeaders, status); + } + return data; +} + +function normalizeTransforms( + transforms: TransformResponse +): readonly AxiosResponseTransformer[] { + if (!transforms) { + return []; + } + return Array.isArray(transforms) ? transforms.slice() : [transforms]; +} + +function normalizeHeaders(headers: AxiosResponseHeaders): AxiosResponseHeaders { + const normalize = ( + headers as AxiosResponseHeaders & { + normalize?: () => AxiosResponseHeaders; + } + ).normalize; + return typeof normalize === 'function' ? normalize.call(headers) : headers; +} + +function publishXtreamMainPerformancePhaseEvent(event: MainPhaseEvent): void { + try { + performanceChannel ??= channel( + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + performanceChannel.publish(event); + } catch { + // Development-only instrumentation must never affect Xtream requests. + } +} + +function createRequestId(runtime: XtreamMainPerformanceCaptureRuntime): string { + try { + const requestId = normalizeInternalRequestId(runtime.createRequestId()); + if (requestId) { + return requestId; + } + } catch { + // Fall through to a process-local, credential-free identifier. + } + fallbackRequestSequence += 1; + return `xtream-main-fallback-${fallbackRequestSequence}`; +} + +function normalizeRendererRequestId(value: unknown): string | null { + return typeof value === 'string' && + value.length <= MAX_RENDERER_REQUEST_ID_LENGTH && + RENDERER_REQUEST_ID.test(value) + ? value + : null; +} + +function normalizeInternalRequestId(value: unknown): string | null { + return typeof value === 'string' && INTERNAL_REQUEST_ID.test(value) + ? value + : null; +} + +function safeReadEpochMs( + runtime: XtreamMainPerformanceCaptureRuntime, + previousEpochMs: number +): number { + try { + const value = runtime.readEpochMs(); + return Number.isFinite(value) && value > 0 + ? Math.max(previousEpochMs, value) + : Math.max(previousEpochMs, 1); + } catch { + return Math.max(previousEpochMs, 1); + } +} + +function safeReadMonotonicMs( + runtime: XtreamMainPerformanceCaptureRuntime +): number { + try { + const value = runtime.readMonotonicMs(); + return Number.isFinite(value) && value >= 0 ? value : 0; + } catch { + return 0; + } +} + +function safeEmit( + emit: (event: MainPhaseEvent) => void, + event: MainPhaseEvent +) { + try { + emit(event); + } catch { + // Instrumentation delivery is deliberately fail-neutral. + } +} diff --git a/apps/electron-backend/src/app/events/xtream-session-cancellation.ts b/apps/electron-backend/src/app/events/xtream-session-cancellation.ts new file mode 100644 index 000000000..5ff1f7637 --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream-session-cancellation.ts @@ -0,0 +1,26 @@ +interface ActiveXtreamRequest { + readonly controller: AbortController; + readonly sessionId?: string; +} + +interface XtreamCancellationResult { + readonly cancelled: number; + readonly success: boolean; +} + +export function cancelXtreamSessionRequests( + activeRequests: Iterable, + sessionId: string +): XtreamCancellationResult { + if (!sessionId) { + return { success: false, cancelled: 0 }; + } + let cancelled = 0; + for (const activeRequest of activeRequests) { + if (activeRequest.sessionId === sessionId) { + activeRequest.controller.abort(); + cancelled += 1; + } + } + return { success: cancelled > 0, cancelled }; +} diff --git a/apps/electron-backend/src/app/events/xtream.events.performance-error.spec.ts b/apps/electron-backend/src/app/events/xtream.events.performance-error.spec.ts new file mode 100644 index 000000000..724cdf49b --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream.events.performance-error.spec.ts @@ -0,0 +1,204 @@ +import type { AxiosRequestConfig } from 'axios'; +import { + XTREAM_MAIN_PERFORMANCE_PHASE, + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL, + type PerformancePhaseEvent, + type XtreamMainPerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { channel } from 'node:diagnostics_channel'; +import { + applyConfiguredTransforms, + axiosMock, + getHandler, + loadXtreamEvents, + mockParseTransform, +} from './xtream.events.performance.test-helpers'; + +type MainPhaseEvent = PerformancePhaseEvent; + +function expectFailedResponsePhases(events: MainPhaseEvent[]): void { + expect(events.map(({ boundary, phase }) => ({ boundary, phase }))).toEqual([ + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + ]); + expect( + events.some( + ({ phase }) => + phase === XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY + ) + ).toBe(false); +} + +describe('XtreamEvents failed-response performance phases', () => { + const originalCaptureValue = process.env['IPTVNATOR_PERF_CAPTURE']; + + afterEach(() => { + if (originalCaptureValue === undefined) { + delete process.env['IPTVNATOR_PERF_CAPTURE']; + } else { + process.env['IPTVNATOR_PERF_CAPTURE'] = originalCaptureValue; + } + }); + + it('closes JSON and network phases without marking an Axios error response ready', async () => { + await loadXtreamEvents('1'); + const events: MainPhaseEvent[] = []; + const phaseChannel = channel( + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + const subscriber = (event: unknown) => + events.push(event as MainPhaseEvent); + phaseChannel.subscribe(subscriber); + try { + let axiosError: Error & { + response?: { + data: unknown; + headers: object; + status: number; + statusText: string; + }; + }; + axiosMock.mockImplementation((config: AxiosRequestConfig) => { + axiosError = Object.assign(new Error('provider unavailable'), { + response: { + data: applyConfiguredTransforms( + config, + '{"message":"provider unavailable"}', + 503 + ), + headers: {}, + status: 503, + statusText: 'Unavailable', + }, + }); + axiosMock.isAxiosError.mockImplementation( + (error: unknown) => error === axiosError + ); + return Promise.reject(axiosError); + }); + + await expect( + getHandler('XTREAM_REQUEST')( + {}, + { + params: { + action: 'get_live_categories', + password: 'provider-password-secret', + username: 'provider-user-secret', + }, + requestId: 'xtream-mabc1007-7', + sessionId: 'xtream-session-error', + suppressErrorLog: true, + url: 'http://localhost:3211', + } + ) + ).rejects.toEqual({ + message: 'provider unavailable', + status: 503, + type: 'ERROR', + }); + + expectFailedResponsePhases(events); + } finally { + phaseChannel.unsubscribe(subscriber); + } + }); + + it('closes a fulfilled 4xx response without marking an IPC result ready', async () => { + await loadXtreamEvents('1'); + const events: MainPhaseEvent[] = []; + const phaseChannel = channel( + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + const subscriber = (event: unknown) => + events.push(event as MainPhaseEvent); + phaseChannel.subscribe(subscriber); + try { + axiosMock.mockImplementation((config: AxiosRequestConfig) => + Promise.resolve({ + config, + data: applyConfiguredTransforms( + config, + '{"message":"not found"}', + 404 + ), + headers: {}, + status: 404, + statusText: 'Not Found', + }) + ); + + await expect( + getHandler('XTREAM_REQUEST')( + {}, + { + params: { action: 'get_live_categories' }, + requestId: 'xtream-mabc1008-8', + suppressErrorLog: true, + url: 'http://localhost:3211', + } + ) + ).rejects.toEqual({ + message: 'HTTP Error: Not Found', + status: 404, + }); + expectFailedResponsePhases(events); + } finally { + phaseChannel.unsubscribe(subscriber); + } + }); + + it('preserves an exact default-transform error while closing JSON and network once', async () => { + await loadXtreamEvents('1'); + const transformError = new Error('default transform failed'); + mockParseTransform.mockImplementationOnce(() => { + throw transformError; + }); + axiosMock.mockImplementation((config: AxiosRequestConfig) => + Promise.resolve({ + config, + data: applyConfiguredTransforms(config, '{"items":[]}', 200), + headers: {}, + status: 200, + statusText: 'OK', + }) + ); + const events: MainPhaseEvent[] = []; + const phaseChannel = channel( + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + const subscriber = (event: unknown) => + events.push(event as MainPhaseEvent); + phaseChannel.subscribe(subscriber); + try { + await expect( + getHandler('XTREAM_REQUEST')( + {}, + { + params: { action: 'get_live_categories' }, + requestId: 'xtream-mabc1009-9', + suppressErrorLog: true, + url: 'http://localhost:3211', + } + ) + ).rejects.toBe(transformError); + expectFailedResponsePhases(events); + } finally { + phaseChannel.unsubscribe(subscriber); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/xtream.events.performance.spec.ts b/apps/electron-backend/src/app/events/xtream.events.performance.spec.ts new file mode 100644 index 000000000..be496f881 --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream.events.performance.spec.ts @@ -0,0 +1,254 @@ +import type { AxiosRequestConfig } from 'axios'; +import { + XTREAM_CANCEL_SESSION, + XTREAM_MAIN_PERFORMANCE_PHASE, + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL, + type PerformancePhaseEvent, + type XtreamMainPerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { channel } from 'node:diagnostics_channel'; +import { + applyConfiguredTransforms, + axiosMock, + createDeferred, + getDefaultsReadCount, + getHandler, + loadXtreamEvents, + mockFinalizeTransform, + mockParseTransform, +} from './xtream.events.performance.test-helpers'; + +type MainPhaseEvent = PerformancePhaseEvent; + +describe('XtreamEvents performance phases', () => { + const originalCaptureValue = process.env['IPTVNATOR_PERF_CAPTURE']; + + afterEach(() => { + if (originalCaptureValue === undefined) { + delete process.env['IPTVNATOR_PERF_CAPTURE']; + } else { + process.env['IPTVNATOR_PERF_CAPTURE'] = originalCaptureValue; + } + jest.restoreAllMocks(); + }); + + it.each([undefined, 'true', 'yes', 'on', ' 1 ', '0'])( + 'keeps the Axios path untouched for non-exact capture value %p', + async (captureValue) => { + const events: MainPhaseEvent[] = []; + const phaseChannel = channel( + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + const subscriber = (event: unknown) => + events.push(event as MainPhaseEvent); + phaseChannel.subscribe(subscriber); + try { + await loadXtreamEvents(captureValue); + axiosMock.mockImplementation((config: AxiosRequestConfig) => + Promise.resolve({ + config, + data: { same: 'identity' }, + headers: {}, + status: 200, + statusText: 'OK', + }) + ); + + await getHandler('XTREAM_REQUEST')( + {}, + { + params: { action: 'get_live_categories' }, + suppressErrorLog: true, + url: 'http://localhost:3211', + } + ); + + const config = axiosMock.mock.calls[0][0]; + expect( + Object.prototype.hasOwnProperty.call( + config, + 'transformResponse' + ) + ).toBe(false); + expect(getDefaultsReadCount()).toBe(0); + expect(events).toEqual([]); + } finally { + phaseChannel.unsubscribe(subscriber); + } + } + ); + + it('pairs one final JSON phase across redirects and marks the ready response', async () => { + await loadXtreamEvents('1'); + const events: MainPhaseEvent[] = []; + const phaseChannel = channel( + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + const subscriber = (event: unknown) => + events.push(event as MainPhaseEvent); + phaseChannel.subscribe(subscriber); + let finalPayload: unknown; + try { + axiosMock + .mockImplementationOnce((config: AxiosRequestConfig) => + Promise.resolve({ + config, + data: applyConfiguredTransforms( + config, + '{"redirect":true}', + 302 + ), + headers: { location: '/final' }, + status: 302, + statusText: 'Found', + }) + ) + .mockImplementationOnce((config: AxiosRequestConfig) => { + finalPayload = applyConfiguredTransforms( + config, + '{"items":[1,2,3]}', + 200 + ); + return Promise.resolve({ + config, + data: finalPayload, + headers: {}, + status: 200, + statusText: 'OK', + }); + }); + + const result = (await getHandler('XTREAM_REQUEST')( + {}, + { + params: { + action: 'get_live_categories', + password: 'provider-password-secret', + username: 'provider-user-secret', + }, + requestId: 'xtream-mabc1005-5', + sessionId: 'xtream-session-1', + suppressErrorLog: true, + url: 'http://localhost:3211', + } + )) as { action: string; payload: unknown }; + + expect(result.payload).toBe(finalPayload); + expect(result.action).toBe('get_live_categories'); + expect(getDefaultsReadCount()).toBe(1); + expect(mockParseTransform).toHaveBeenCalledTimes(2); + expect(mockFinalizeTransform).toHaveBeenCalledTimes(2); + expect( + events.map(({ boundary, phase }) => ({ boundary, phase })) + ).toEqual([ + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.JSON_TRANSFORM, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY, + }, + ]); + const serialized = JSON.stringify(events); + expect(serialized).not.toContain('localhost'); + expect(serialized).not.toContain('provider-user-secret'); + expect(serialized).not.toContain('provider-password-secret'); + expect(serialized).not.toContain('items'); + } finally { + phaseChannel.unsubscribe(subscriber); + } + }); + + it('closes network and cancel dispatch exactly once without a response-ready marker on abort', async () => { + await loadXtreamEvents('1'); + const enteredAxios = createDeferred(); + const cancelError = Object.assign(new Error('cancelled'), { + code: 'ERR_CANCELED', + }); + let signal: AbortSignal | undefined; + axiosMock.mockImplementation((config: AxiosRequestConfig) => { + signal = config.signal as AbortSignal; + enteredAxios.resolve(); + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(cancelError), { + once: true, + }); + }); + }); + axiosMock.isAxiosError.mockImplementation( + (error: unknown) => error === cancelError + ); + const events: MainPhaseEvent[] = []; + const phaseChannel = channel( + XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL + ); + const subscriber = (event: unknown) => + events.push(event as MainPhaseEvent); + phaseChannel.subscribe(subscriber); + try { + const request = getHandler('XTREAM_REQUEST')( + {}, + { + params: { + action: 'get_live_streams', + password: 'secret', + username: 'user', + }, + requestId: 'xtream-mabc1006-6', + sessionId: 'xtream-session-abort', + suppressErrorLog: true, + url: 'http://localhost:3211', + } + ) as Promise; + await enteredAxios.promise; + + await expect( + getHandler(XTREAM_CANCEL_SESSION)({}, 'xtream-session-abort') + ).resolves.toEqual({ cancelled: 1, success: true }); + await expect(request).rejects.toMatchObject({ + name: 'AbortError', + status: 499, + }); + expect(signal?.aborted).toBe(true); + expect( + events.map(({ boundary, phase }) => ({ boundary, phase })) + ).toEqual([ + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + { + boundary: 'start', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION, + }, + { + boundary: 'end', + phase: XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + }, + ]); + } finally { + phaseChannel.unsubscribe(subscriber); + } + }); +}); diff --git a/apps/electron-backend/src/app/events/xtream.events.performance.test-helpers.ts b/apps/electron-backend/src/app/events/xtream.events.performance.test-helpers.ts new file mode 100644 index 000000000..ed3b8c826 --- /dev/null +++ b/apps/electron-backend/src/app/events/xtream.events.performance.test-helpers.ts @@ -0,0 +1,118 @@ +import type { + AxiosRequestConfig, + AxiosResponseHeaders, + AxiosResponseTransformer, + InternalAxiosRequestConfig, +} from 'axios'; + +const registeredHandlers = new Map unknown>(); +export const mockParseTransform = jest.fn(function (data: unknown) { + return typeof data === 'string' ? JSON.parse(data) : data; +}); +export const mockFinalizeTransform = jest.fn(function (data: unknown) { + return data; +}); +const mockDefaultTransforms: AxiosResponseTransformer[] = [ + mockParseTransform, + mockFinalizeTransform, +]; +let mockDefaultsReadCount = 0; +export const axiosMock = Object.assign(jest.fn(), { + isAxiosError: jest.fn(), +}); +Object.defineProperty(axiosMock, 'defaults', { + configurable: true, + get: () => { + mockDefaultsReadCount += 1; + return { transformResponse: mockDefaultTransforms }; + }, +}); + +jest.mock('electron', () => ({ + ipcMain: { + handle: jest.fn( + (name: string, handler: (...args: unknown[]) => unknown) => { + registeredHandlers.set(name, handler); + } + ), + }, +})); + +jest.mock('axios', () => ({ + __esModule: true, + default: axiosMock, +})); + +jest.mock('./portal-debug.events', () => ({ + emitPortalDebugEvent: jest.fn(), +})); + +export function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +export function applyConfiguredTransforms( + config: AxiosRequestConfig, + data: unknown, + status: number +): unknown { + const configured = config.transformResponse; + if (!configured) { + return data; + } + const headers = { + normalize: jest.fn(function () { + return this; + }), + } as unknown as AxiosResponseHeaders & { + normalize: () => AxiosResponseHeaders; + }; + const transforms = Array.isArray(configured) ? configured : [configured]; + let transformed = data; + for (const transform of transforms) { + transformed = transform.call( + config as InternalAxiosRequestConfig, + transformed, + headers.normalize(), + status + ); + } + headers.normalize(); + return transformed; +} + +export async function loadXtreamEvents( + captureValue: string | undefined +): Promise { + jest.resetModules(); + registeredHandlers.clear(); + axiosMock.mockReset(); + axiosMock.isAxiosError.mockReset(); + mockParseTransform.mockClear(); + mockFinalizeTransform.mockClear(); + mockDefaultsReadCount = 0; + if (captureValue === undefined) { + delete process.env['IPTVNATOR_PERF_CAPTURE']; + } else { + process.env['IPTVNATOR_PERF_CAPTURE'] = captureValue; + } + await import('./xtream.events'); +} + +export function getHandler(name: string): (...args: unknown[]) => unknown { + const handler = registeredHandlers.get(name); + if (!handler) { + throw new Error(`Missing IPC handler: ${name}`); + } + return handler; +} + +export function getDefaultsReadCount(): number { + return mockDefaultsReadCount; +} diff --git a/apps/electron-backend/src/app/events/xtream.events.spec.ts b/apps/electron-backend/src/app/events/xtream.events.spec.ts index 5a6840fe0..7fb7a6a3e 100644 --- a/apps/electron-backend/src/app/events/xtream.events.spec.ts +++ b/apps/electron-backend/src/app/events/xtream.events.spec.ts @@ -4,6 +4,8 @@ const registeredHandlers = new Map unknown>(); const axiosMock = Object.assign(jest.fn(), { isAxiosError: jest.fn(), }); +const PERF_CAPTURE_ENV = 'IPTVNATOR_PERF_CAPTURE'; +const originalPerformanceCaptureValue = process.env[PERF_CAPTURE_ENV]; function createDeferred() { let resolve!: (value: T) => void; @@ -40,6 +42,7 @@ describe('XtreamEvents session cancellation', () => { beforeEach(async () => { jest.resetModules(); + delete process.env[PERF_CAPTURE_ENV]; registeredHandlers.clear(); axiosMock.mockReset(); axiosMock.isAxiosError.mockReset(); @@ -52,6 +55,14 @@ describe('XtreamEvents session cancellation', () => { consoleErrorSpy.mockRestore(); }); + afterAll(() => { + if (originalPerformanceCaptureValue === undefined) { + delete process.env[PERF_CAPTURE_ENV]; + } else { + process.env[PERF_CAPTURE_ENV] = originalPerformanceCaptureValue; + } + }); + it('normalizes full Xtream API URLs before appending player_api.php', async () => { const requestHandler = registeredHandlers.get('XTREAM_REQUEST'); expect(requestHandler).toBeDefined(); diff --git a/apps/electron-backend/src/app/events/xtream.events.ts b/apps/electron-backend/src/app/events/xtream.events.ts index 0d63e0523..87ae81574 100644 --- a/apps/electron-backend/src/app/events/xtream.events.ts +++ b/apps/electron-backend/src/app/events/xtream.events.ts @@ -8,12 +8,18 @@ import { ipcMain } from 'electron'; import { PortalDebugEvent, XTREAM_CANCEL_SESSION, + XTREAM_MAIN_PERFORMANCE_PHASE, normalizeXtreamServerUrl, } from '@iptvnator/shared/interfaces'; import { redactSensitiveData } from '@iptvnator/shared/logging'; import { emitPortalDebugEvent } from './portal-debug.events'; import { UnsafeUrlError } from './url-safety'; import { requestWithValidatedRedirects } from '../util/validated-axios'; +import { + createXtreamMainPerformanceCaptureForRequest, + createXtreamMeasuredTransformResponse, +} from './xtream-performance'; +import { cancelXtreamSessionRequests } from './xtream-session-cancellation'; // Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to // challenge generic browser-looking User-Agents while allowlisting known @@ -101,6 +107,9 @@ ipcMain.handle( } ) => { const startedAt = Date.now(); + const performanceCapture = createXtreamMainPerformanceCaptureForRequest( + payload.requestId + ); let activeRequestKey: string | null = null; let requestUrlForLog = payload.url; try { @@ -132,13 +141,29 @@ ipcMain.handle( validateStatus: (status) => status < 500, // Don't throw on 4xx errors signal: controller.signal, }; + if (performanceCapture) { + config.transformResponse = + createXtreamMeasuredTransformResponse( + performanceCapture, + axios.defaults.transformResponse + ); + } - const response = await requestWithValidatedRedirects( - apiUrl.toString(), - config, - { allowPrivateNetworks: true } - ); - + const response = performanceCapture + ? await performanceCapture.measureAsync( + XTREAM_MAIN_PERFORMANCE_PHASE.NETWORK_TOTAL, + () => + requestWithValidatedRedirects( + apiUrl.toString(), + config, + { allowPrivateNetworks: true } + ) + ) + : await requestWithValidatedRedirects( + apiUrl.toString(), + config, + { allowPrivateNetworks: true } + ); // Check if response is successful if (response.status >= 400) { throw { @@ -169,10 +194,16 @@ ipcMain.handle( } // Xtream API returns JSON data - return { + const result = { payload: response.data, action: params.action, }; + return performanceCapture + ? performanceCapture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.RESPONSE_READY, + () => result + ) + : result; } catch (error) { const requestId = payload.requestId; if (requestId) { @@ -269,24 +300,20 @@ ipcMain.handle( _event, sessionId: string ): Promise<{ success: boolean; cancelled: number }> => { - if (!sessionId) { - return { success: false, cancelled: 0 }; - } - - let cancelled = 0; - for (const activeRequest of activeXtreamRequests.values()) { - if (activeRequest.sessionId !== sessionId) { - continue; - } - - activeRequest.controller.abort(); - cancelled += 1; - } - - return { - success: cancelled > 0, - cancelled, - }; + const capture = createXtreamMainPerformanceCaptureForRequest(); + return capture + ? capture.measure( + XTREAM_MAIN_PERFORMANCE_PHASE.CANCEL_SESSION, + () => + cancelXtreamSessionRequests( + activeXtreamRequests.values(), + sessionId + ) + ) + : cancelXtreamSessionRequests( + activeXtreamRequests.values(), + sessionId + ); } ); type ActiveXtreamRequest = { @@ -329,8 +356,7 @@ ipcMain.handle( } ); const responseBody = response.data as - | { destroy?: () => void } - | undefined; + { destroy?: () => void } | undefined; responseBody?.destroy?.(); return { status: response.status, diff --git a/apps/electron-backend/src/app/services/database-worker-client.spec.ts b/apps/electron-backend/src/app/services/database-worker-client.spec.ts index 429cb7e4f..cfffa699a 100644 --- a/apps/electron-backend/src/app/services/database-worker-client.spec.ts +++ b/apps/electron-backend/src/app/services/database-worker-client.spec.ts @@ -148,6 +148,40 @@ describe('DatabaseWorkerClient', () => { }); }); + it('ignores profiling-only cancel receipts without settling or exposing the pending request', async () => { + const client = createClient(); + const onEvent = jest.fn(); + const requestPromise = client.request( + 'DB_DELETE_XTREAM_CONTENT', + { operationId: 'operation-1', playlistId: 'xtream-1' }, + { onEvent } + ); + const worker = mockWorkerInstances[0]; + + worker.emit('message', { type: 'ready' }); + await flushPromises(); + + const request = worker.postMessage.mock.calls[0][0]; + worker.emit('message', { + type: 'performance-cancel-received', + operationId: 'operation-1', + requestId: request.requestId, + epochMs: 123, + }); + + expect(onEvent).not.toHaveBeenCalled(); + + const resultIdentity = { success: true }; + worker.emit('message', { + type: 'response', + requestId: request.requestId, + success: true, + result: resultIdentity, + }); + + await expect(requestPromise).resolves.toBe(resultIdentity); + }); + it('turns serialized worker errors into rejected Error instances', async () => { const client = createClient(); const requestPromise = client.request('DB_DELETE_PLAYLIST', { diff --git a/apps/electron-backend/src/app/services/database-worker-client.ts b/apps/electron-backend/src/app/services/database-worker-client.ts index a7b8294d5..033aa99b0 100644 --- a/apps/electron-backend/src/app/services/database-worker-client.ts +++ b/apps/electron-backend/src/app/services/database-worker-client.ts @@ -194,7 +194,13 @@ export class DatabaseWorkerClient { requestId: message.requestId, }); } - this.pendingRequests.get(message.requestId)?.onEvent?.(message.event); + this.pendingRequests + .get(message.requestId) + ?.onEvent?.(message.event); + return; + } + + if (message.type === 'performance-cancel-received') { return; } diff --git a/apps/electron-backend/src/app/workers/database-worker-cancel-receipt.spec.ts b/apps/electron-backend/src/app/workers/database-worker-cancel-receipt.spec.ts new file mode 100644 index 000000000..48ff97c6b --- /dev/null +++ b/apps/electron-backend/src/app/workers/database-worker-cancel-receipt.spec.ts @@ -0,0 +1,120 @@ +import { publishDatabaseWorkerCancelReceipt } from './database-worker-cancel-receipt'; + +const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; +const originalProfilingValue = process.env[PROFILING_ENV]; + +describe('database worker performance cancellation receipt', () => { + beforeEach(() => { + delete process.env[PROFILING_ENV]; + }); + + afterAll(() => { + if (originalProfilingValue === undefined) { + delete process.env[PROFILING_ENV]; + } else { + process.env[PROFILING_ENV] = originalProfilingValue; + } + }); + + it('does no clock or transport work without the exact profiling opt-in', () => { + const publish = jest.fn(); + const readEpochMs = jest.fn(() => 123); + process.env[PROFILING_ENV] = 'true'; + + publishDatabaseWorkerCancelReceipt( + 'operation-1', + 'request-1', + publish, + { readEpochMs } + ); + + expect(readEpochMs).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + }); + + it('publishes only safe correlation IDs and a finite epoch', () => { + const publish = jest.fn(); + process.env[PROFILING_ENV] = '1'; + + publishDatabaseWorkerCancelReceipt( + 'playlist-delete:123_abc', + '08da28c4-cc14-4a79-95ec-dff6bba10db0', + publish, + { readEpochMs: () => 1_234.5 } + ); + + expect(publish).toHaveBeenCalledWith({ + type: 'performance-cancel-received', + operationId: 'playlist-delete:123_abc', + requestId: '08da28c4-cc14-4a79-95ec-dff6bba10db0', + epochMs: 1_234.5, + }); + expect( + Object.keys(publish.mock.calls[0][0] as Record) + ).toEqual(['type', 'operationId', 'requestId', 'epochMs']); + }); + + it.each([ + [' leading-space', 'request-1'], + ['trailing-space ', 'request-1'], + ['provider/password', 'request-1'], + ['operation-1', 'request with spaces'], + ['operation-1', 'request/with/slashes'], + [`a${'b'.repeat(128)}`, 'request-1'], + ])( + 'rejects unsafe operation/request correlation without transport work', + (operationId, requestId) => { + const publish = jest.fn(); + const readEpochMs = jest.fn(() => 123); + process.env[PROFILING_ENV] = '1'; + + publishDatabaseWorkerCancelReceipt( + operationId, + requestId, + publish, + { readEpochMs } + ); + + expect(readEpochMs).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + } + ); + + it('fails neutrally when the clock or diagnostic transport fails', () => { + process.env[PROFILING_ENV] = '1'; + const publish = jest.fn(() => { + throw new Error('transport unavailable'); + }); + + expect(() => + publishDatabaseWorkerCancelReceipt( + 'operation-1', + 'request-1', + publish, + { readEpochMs: () => 123 } + ) + ).not.toThrow(); + expect(() => + publishDatabaseWorkerCancelReceipt( + 'operation-1', + 'request-1', + publish, + { + readEpochMs: () => { + throw new Error('clock unavailable'); + }, + } + ) + ).not.toThrow(); + expect(() => + publishDatabaseWorkerCancelReceipt( + 'operation-1', + 'request-1', + publish, + { readEpochMs: () => Number.NaN } + ) + ).not.toThrow(); + + expect(publish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/workers/database-worker-cancel-receipt.ts b/apps/electron-backend/src/app/workers/database-worker-cancel-receipt.ts new file mode 100644 index 000000000..4c03b576b --- /dev/null +++ b/apps/electron-backend/src/app/workers/database-worker-cancel-receipt.ts @@ -0,0 +1,63 @@ +import { performance } from 'node:perf_hooks'; +import type { DbWorkerPerformanceCancelReceivedMessage } from './database-worker.types'; +import { WORKER_PROFILING_ENV } from './worker-performance-capture.runtime'; + +const MAX_IDENTIFIER_LENGTH = 128; +const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; + +interface CancelReceiptRuntime { + readonly readEpochMs: () => number; +} + +type CancelReceiptPublisher = ( + message: DbWorkerPerformanceCancelReceivedMessage +) => void; + +const DEFAULT_RUNTIME: CancelReceiptRuntime = { + readEpochMs: () => performance.timeOrigin + performance.now(), +}; + +export function publishDatabaseWorkerCancelReceipt( + operationId: unknown, + requestId: unknown, + publish: CancelReceiptPublisher, + runtime: CancelReceiptRuntime = DEFAULT_RUNTIME +): void { + if (process.env[WORKER_PROFILING_ENV] !== '1') { + return; + } + if (!isSafeIdentifier(operationId) || !isSafeIdentifier(requestId)) { + return; + } + + let epochMs: number; + try { + epochMs = runtime.readEpochMs(); + } catch { + return; + } + if (!Number.isFinite(epochMs) || epochMs <= 0) { + return; + } + + try { + publish({ + type: 'performance-cancel-received', + operationId, + requestId, + epochMs, + }); + } catch { + // Development-only instrumentation must not affect cancellation. + } +} + +function isSafeIdentifier(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= MAX_IDENTIFIER_LENGTH && + value === value.trim() && + SAFE_IDENTIFIER_PATTERN.test(value) + ); +} diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index d312ae21a..40f9d787a 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -88,11 +88,7 @@ export const DB_OPERATION_PHASES = { } as const; export type DbOperationStatus = - | 'started' - | 'progress' - | 'completed' - | 'cancelled' - | 'error'; + 'started' | 'progress' | 'completed' | 'cancelled' | 'error'; export interface DbOperationEvent { operationId?: string; @@ -134,6 +130,13 @@ export interface DbWorkerEventMessage { event: DbOperationEvent; } +export interface DbWorkerPerformanceCancelReceivedMessage { + type: 'performance-cancel-received'; + operationId: string; + requestId: string; + epochMs: number; +} + export interface DbWorkerResponseMessage { type: 'response'; requestId: string; @@ -144,10 +147,10 @@ export interface DbWorkerResponseMessage { } export type DbWorkerIncomingMessage = - | DbWorkerRequestMessage - | DbWorkerCancelMessage; + DbWorkerRequestMessage | DbWorkerCancelMessage; export type DbWorkerMessage = | DbWorkerReadyMessage | DbWorkerEventMessage + | DbWorkerPerformanceCancelReceivedMessage | DbWorkerResponseMessage; diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 7ac3e6501..003dcb037 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -102,11 +102,13 @@ import { import { captureWorkerPerformancePhase, captureWorkerPerformancePhaseAsync, + createWorkerPerformancePhaseAdapter, } from './worker-performance-phase'; import { handleDatabaseWorkerPostGcHeapRequest, isDatabaseWorkerPostGcHeapRequest, } from './database-worker-post-gc-heap'; +import { publishDatabaseWorkerCancelReceipt } from './database-worker-cancel-receipt'; const loggerLabel = '[DB Worker]'; const batchDelayMs = Number.parseInt( @@ -116,6 +118,7 @@ const batchDelayMs = Number.parseInt( type ActiveOperationState = { cancelled: boolean; + performanceRequestId?: string; }; type PreRegisteredActiveOperation = { @@ -376,7 +379,14 @@ async function executeRequest( playlistId: string; type: 'live' | 'movies' | 'series'; }; - return getCategories(db, payload.playlistId, payload.type); + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); + return getCategories( + db, + payload.playlistId, + payload.type, + capturePhase + ); } case 'DB_SAVE_CATEGORIES': { @@ -389,12 +399,15 @@ async function executeRequest( type: 'live' | 'movies' | 'series'; hiddenCategoryXtreamIds?: number[]; }; + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); return saveCategories( db, payload.playlistId, payload.categories, payload.type, - payload.hiddenCategoryXtreamIds + payload.hiddenCategoryXtreamIds, + capturePhase ); } @@ -431,7 +444,14 @@ async function executeRequest( playlistId: string; type: 'live' | 'movie' | 'series'; }; - return getContent(db, payload.playlistId, payload.type); + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); + return getContent( + db, + payload.playlistId, + payload.type, + capturePhase + ); } case 'DB_GET_GLOBAL_RECENTLY_ADDED': { @@ -456,6 +476,8 @@ async function executeRequest( type: 'live' | 'movie' | 'series'; operationId?: string; }; + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); return executeTrackedOperation( { @@ -476,7 +498,8 @@ async function executeRequest( payload.playlistId, payload.streams, payload.type, - controller.control + controller.control, + capturePhase ); controller.emitCompleted({ @@ -496,8 +519,15 @@ async function executeRequest( playlistId: string; type: 'live' | 'movie' | 'series'; }; + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); - return clearXtreamImportCache(db, payload.playlistId, payload.type); + return clearXtreamImportCache( + db, + payload.playlistId, + payload.type, + capturePhase + ); } case 'DB_GET_CONTENT_BY_XTREAM_ID': { @@ -533,12 +563,15 @@ async function executeRequest( types: string[]; excludeHidden?: boolean; }; + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); return searchContent( db, payload.playlistId, payload.searchTerm, payload.types, - payload.excludeHidden + payload.excludeHidden, + capturePhase ); } @@ -672,6 +705,8 @@ async function executeRequest( playlistId: string; operationId?: string; }; + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); return executeTrackedOperation( { @@ -689,7 +724,8 @@ async function executeRequest( const result = await deletePlaylist( db, payload.playlistId, - controller.control + controller.control, + capturePhase ); controller.emitCompleted({ @@ -784,6 +820,8 @@ async function executeRequest( playlistId: string; operationId?: string; }; + const capturePhase = + createWorkerPerformancePhaseAdapter(performanceCapture); return executeTrackedOperation( { @@ -801,7 +839,8 @@ async function executeRequest( const result = await deleteXtreamContent( db, payload.playlistId, - controller.control + controller.control, + capturePhase ); controller.emitCompleted({ @@ -1049,6 +1088,13 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { const activeOperation = activeOperations.get(message.operationId); if (activeOperation) { activeOperation.cancelled = true; + if (activeOperation.performanceRequestId !== undefined) { + publishDatabaseWorkerCancelReceipt( + message.operationId, + activeOperation.performanceRequestId, + postMessage + ); + } } return; } @@ -1060,6 +1106,10 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { performanceCapture = startWorkerPerformanceCapture({ requestId: message.requestId, }); + if (performanceCapture && preRegisteredOperation) { + preRegisteredOperation.state.performanceRequestId = + message.requestId; + } registerDatabaseWorkerPerformanceCapture( activePerformanceCaptures, performanceCapture diff --git a/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts index eadd2617f..ba613ca64 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts @@ -6,91 +6,17 @@ import type { PlaylistRefreshWorkerIncomingMessage, PlaylistRefreshWorkerMessage, } from './playlist-refresh.worker.types'; - -type WorkerMessageHandler = ( - message: TMessage -) => Promise | void; - -interface ArmGate { - readonly promise: Promise; - readonly release: () => void; -} - -function createArmGate(): ArmGate { - let release = (): void => undefined; - const promise = new Promise((resolvePromise) => { - release = resolvePromise; - }); - return { promise, release }; -} - -function createParentPortHarness() { - let messageHandler: WorkerMessageHandler | null = null; - const postMessage = jest.fn(); - const parentPort = { - on: jest.fn( - (event: string, handler: WorkerMessageHandler): void => { - if (event === 'message') { - messageHandler = handler; - } - } - ), - postMessage, - }; - - return { - getMessageHandler: (): WorkerMessageHandler => { - if (!messageHandler) { - throw new Error('Worker message handler was not registered'); - } - return messageHandler; - }, - parentPort, - postMessage, - }; -} - -function mockPerformanceCapture( - armGate: ArmGate, - failureGates?: { - readonly observed: ArmGate; - readonly profilingFinished: ArmGate; - } -): void { - jest.doMock('./worker-performance-capture', () => ({ - armWorkerPerformanceCapture: jest.fn(() => armGate.promise), - executeWithWorkerPerformanceCapture: jest.fn( - async (_capture: unknown, execute: () => Promise) => { - try { - return { - error: null, - performance: undefined, - result: await execute(), - success: true, - }; - } catch (error) { - failureGates?.observed.release(); - await failureGates?.profilingFinished.promise; - return { - error, - performance: undefined, - result: undefined, - success: false, - }; - } - } - ), - registerDatabaseWorkerPerformanceCapture: jest.fn(), - releaseDatabaseWorkerPerformanceCapture: jest.fn(), - stampWorkerPerformanceResponsePostedEpoch: jest.fn( - (_capture: unknown, performance: unknown) => performance - ), - startWorkerPerformanceCapture: jest.fn(() => ({})), - })); -} +import { + createArmGate, + createParentPortHarness, + mockPerformanceCapture, + restoreWorkerProfilingEnvironment, + WORKER_PROFILING_ENV, +} from './worker-performance-cancellation.test-helpers'; describe('worker cancellation while performance capture arms', () => { afterEach(() => { + restoreWorkerProfilingEnvironment(); jest.restoreAllMocks(); jest.resetModules(); }); @@ -175,7 +101,8 @@ describe('worker cancellation while performance capture arms', () => { ); }); - it('cancels cancellable database work when cancel arrives during arming', async () => { + it('acknowledges receipt separately before cancellable database work terminates', async () => { + process.env[WORKER_PROFILING_ENV] = '1'; const armGate = createArmGate(); const port = createParentPortHarness< DbWorkerIncomingMessage, @@ -225,6 +152,24 @@ describe('worker cancellation while performance capture arms', () => { type: 'cancel', operationId: 'database-operation-1', }); + + const receipt = port.postMessage.mock.calls.find( + ([message]) => message.type === 'performance-cancel-received' + )?.[0]; + expect(receipt).toEqual({ + type: 'performance-cancel-received', + operationId: 'database-operation-1', + requestId: 'request-1', + epochMs: expect.any(Number), + }); + expect( + port.postMessage.mock.calls.some( + ([message]) => + message.type === 'event' && + message.event.status === 'cancelled' + ) + ).toBe(false); + armGate.release(); await requestPromise; @@ -249,6 +194,79 @@ describe('worker cancellation while performance capture arms', () => { ); }); + it('does not acknowledge receipt when request capture is unavailable', async () => { + process.env[WORKER_PROFILING_ENV] = '1'; + const armGate = createArmGate(); + const port = createParentPortHarness< + DbWorkerIncomingMessage, + DbWorkerMessage + >(); + const saveContent = jest.fn( + async ( + _db: unknown, + _playlistId: string, + _streams: unknown[], + _type: string, + control: { checkpoint: () => Promise } + ) => { + await control.checkpoint(); + return { count: 0 }; + } + ); + mockPerformanceCapture(armGate, undefined, null); + jest.doMock('worker_threads', () => ({ + parentPort: port.parentPort, + })); + jest.doMock('./database.worker-connection', () => ({ + closeWorkerDatabase: jest.fn(), + getWorkerDatabase: jest.fn().mockResolvedValue({}), + })); + jest.doMock('../database/operations/content.operations', () => ({ + saveContent, + })); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await import('./database.worker'); + port.postMessage.mockClear(); + const handleMessage = port.getMessageHandler(); + const requestPromise = handleMessage({ + type: 'request', + operation: 'DB_SAVE_CONTENT', + payload: { + operationId: 'database-operation-no-capture', + playlistId: 'playlist-1', + streams: [], + type: 'movie', + }, + requestId: 'request-no-capture', + }); + + await handleMessage({ + type: 'cancel', + operationId: 'database-operation-no-capture', + }); + + expect( + port.postMessage.mock.calls.some( + ([message]) => message.type === 'performance-cancel-received' + ) + ).toBe(false); + + armGate.release(); + await requestPromise; + + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + operationId: 'database-operation-no-capture', + status: 'cancelled', + }), + requestId: 'request-no-capture', + type: 'event', + }) + ); + }); + it('does not make an explicitly non-cancellable database operation cancellable during arming', async () => { const armGate = createArmGate(); const port = createParentPortHarness< @@ -317,6 +335,7 @@ describe('worker cancellation while performance capture arms', () => { describe('database worker performance control messages', () => { afterEach(() => { + restoreWorkerProfilingEnvironment(); jest.restoreAllMocks(); jest.resetModules(); }); diff --git a/apps/electron-backend/src/app/workers/worker-performance-cancellation.test-helpers.ts b/apps/electron-backend/src/app/workers/worker-performance-cancellation.test-helpers.ts new file mode 100644 index 000000000..bf3d2ac3c --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-cancellation.test-helpers.ts @@ -0,0 +1,94 @@ +type WorkerMessageHandler = ( + message: TMessage +) => Promise | void; + +export interface ArmGate { + readonly promise: Promise; + readonly release: () => void; +} + +export const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; + +const originalWorkerProfilingValue = process.env[WORKER_PROFILING_ENV]; + +export function restoreWorkerProfilingEnvironment(): void { + if (originalWorkerProfilingValue === undefined) { + delete process.env[WORKER_PROFILING_ENV]; + } else { + process.env[WORKER_PROFILING_ENV] = originalWorkerProfilingValue; + } +} + +export function createArmGate(): ArmGate { + let release = (): void => undefined; + const promise = new Promise((resolvePromise) => { + release = resolvePromise; + }); + return { promise, release }; +} + +export function createParentPortHarness() { + let messageHandler: WorkerMessageHandler | null = null; + const postMessage = jest.fn(); + const parentPort = { + on: jest.fn( + (event: string, handler: WorkerMessageHandler): void => { + if (event === 'message') { + messageHandler = handler; + } + } + ), + postMessage, + }; + + return { + getMessageHandler: (): WorkerMessageHandler => { + if (!messageHandler) { + throw new Error('Worker message handler was not registered'); + } + return messageHandler; + }, + parentPort, + postMessage, + }; +} + +export function mockPerformanceCapture( + armGate: ArmGate, + failureGates?: { + readonly observed: ArmGate; + readonly profilingFinished: ArmGate; + }, + capture: unknown = {} +): void { + jest.doMock('./worker-performance-capture', () => ({ + armWorkerPerformanceCapture: jest.fn(() => armGate.promise), + executeWithWorkerPerformanceCapture: jest.fn( + async (_capture: unknown, execute: () => Promise) => { + try { + return { + error: null, + performance: undefined, + result: await execute(), + success: true, + }; + } catch (error) { + failureGates?.observed.release(); + await failureGates?.profilingFinished.promise; + return { + error, + performance: undefined, + result: undefined, + success: false, + }; + } + } + ), + registerDatabaseWorkerPerformanceCapture: jest.fn(), + releaseDatabaseWorkerPerformanceCapture: jest.fn(), + stampWorkerPerformanceResponsePostedEpoch: jest.fn( + (_capture: unknown, performance: unknown) => performance + ), + startWorkerPerformanceCapture: jest.fn(() => capture), + })); +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts index dff916076..f9430c601 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts @@ -1,6 +1,6 @@ import type { EventLoopUtilization } from 'node:perf_hooks'; import type { - AppPlaylistPerformancePhase, + DatabaseWorkerPerformancePhase, PerformancePhaseEvent, } from '@iptvnator/shared/interfaces'; @@ -50,7 +50,7 @@ export interface WorkerPerformanceCaptureResult { eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; histogramFlushedEpochMs: number | null; invalidReason: WorkerPerformanceInvalidReason | null; - phaseEvents?: readonly PerformancePhaseEvent[]; + phaseEvents?: readonly PerformancePhaseEvent[]; requestReceivedEpochMs: number; responsePostedEpochMs?: number; threadCpuSystemMicros: number | null; @@ -91,7 +91,7 @@ export interface WorkerPerformanceCapture { histogramDisabled: boolean; histogramFlushedEpochMs: number | null; invalidReason: WorkerPerformanceInvalidReason | null; - phaseEvents: PerformancePhaseEvent[]; + phaseEvents: PerformancePhaseEvent[]; requestReceivedEpochMs: number; requestId: string | null; runtime: WorkerPerformanceCaptureRuntime; diff --git a/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts index f10fe3af9..84981304f 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts @@ -30,9 +30,12 @@ describe('worker performance integration order', () => { expect( handler.indexOf('executeWithWorkerPerformanceCapture') ).toBeLessThan(handler.indexOf('console.error')); - expect( - handler.indexOf('executeWithWorkerPerformanceCapture') - ).toBeLessThan(handler.indexOf('postMessage')); + const executionIndex = handler.indexOf( + 'executeWithWorkerPerformanceCapture' + ); + expect(executionIndex).toBeLessThan( + handler.indexOf('postMessage', executionIndex) + ); expect( handler.match(/stampWorkerPerformanceResponsePostedEpoch/g) ).toHaveLength(2); diff --git a/apps/electron-backend/src/app/workers/worker-performance-phase-adapter.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-phase-adapter.spec.ts new file mode 100644 index 000000000..b2769df13 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-phase-adapter.spec.ts @@ -0,0 +1,76 @@ +import { XTREAM_DATABASE_PERFORMANCE_PHASE } from '@iptvnator/shared/interfaces'; +import { + finishWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; +import { createWorkerPerformancePhaseAdapter } from './worker-performance-phase'; + +describe('database operation worker performance adapter', () => { + it('returns no adapter when profiling is disabled', () => { + expect(createWorkerPerformancePhaseAdapter(null)).toBeUndefined(); + }); + + it('delegates Xtream phases into the request capture with count-only metadata', async () => { + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'xtream-db-request', + runtime: createFakeRuntime().runtime, + }); + const adapter = createWorkerPerformancePhaseAdapter(capture); + const rows = [{ id: 1 }, { id: 2 }]; + + await expect( + adapter?.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CATEGORIES_READ, + () => Promise.resolve(rows), + (result) => ({ itemCount: result.length }) + ) + ).resolves.toBe(rows); + + const result = await finishWorkerPerformanceCapture(capture); + expect(result?.phaseEvents).toEqual([ + expect.objectContaining({ + boundary: 'start', + metadata: undefined, + phase: 'sqlite.categories.read', + requestId: 'xtream-db-request', + }), + expect.objectContaining({ + boundary: 'end', + metadata: { itemCount: 2 }, + phase: 'sqlite.categories.read', + requestId: 'xtream-db-request', + }), + ]); + }); + + it('closes rejected phases without metadata and preserves error identity', async () => { + const capture = startWorkerPerformanceCapture({ + enabled: true, + requestId: 'xtream-db-error', + runtime: createFakeRuntime().runtime, + }); + const adapter = createWorkerPerformancePhaseAdapter(capture); + const businessError = new Error('read failed'); + + await expect( + adapter?.captureAsync( + XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_CONTENT_READ, + () => Promise.reject(businessError), + () => ({ itemCount: 99 }) + ) + ).rejects.toBe(businessError); + + const result = await finishWorkerPerformanceCapture(capture); + expect( + result?.phaseEvents?.map(({ boundary, metadata }) => ({ + boundary, + metadata, + })) + ).toEqual([ + { boundary: 'start', metadata: undefined }, + { boundary: 'end', metadata: undefined }, + ]); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-phase.ts b/apps/electron-backend/src/app/workers/worker-performance-phase.ts index 85fca3209..ae57d41da 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-phase.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-phase.ts @@ -1,15 +1,16 @@ import { PERFORMANCE_PHASE_BOUNDARY, - type AppPlaylistPerformancePhase, + type DatabaseWorkerPerformancePhase, type PerformancePhaseEvent, type PerformancePhaseMetadata, } from '@iptvnator/shared/interfaces'; +import type { DatabaseOperationPerformancePhaseCapture } from '../database/operations/performance-phase-capture'; import type { WorkerPerformanceCapture } from './worker-performance-capture.model'; import { safeReadEpochMs } from './worker-performance-capture.runtime'; export function captureWorkerPerformancePhase( capture: WorkerPerformanceCapture | null, - phase: AppPlaylistPerformancePhase, + phase: DatabaseWorkerPerformancePhase, execute: () => TResult, metadata?: (result: TResult) => PerformancePhaseMetadata ): TResult { @@ -55,7 +56,7 @@ export function captureWorkerPerformancePhase( export function captureWorkerPerformancePhaseAsync( capture: WorkerPerformanceCapture | null, - phase: AppPlaylistPerformancePhase, + phase: DatabaseWorkerPerformancePhase, execute: () => Promise, metadata?: (result: TResult) => PerformancePhaseMetadata ): Promise { @@ -70,7 +71,7 @@ export function captureWorkerPerformancePhaseAsync( async function captureAsync( capture: WorkerPerformanceCapture, requestId: string, - phase: AppPlaylistPerformancePhase, + phase: DatabaseWorkerPerformancePhase, execute: () => Promise, metadata?: (result: TResult) => PerformancePhaseMetadata ): Promise { @@ -91,9 +92,7 @@ async function captureAsync( boundary: PERFORMANCE_PHASE_BOUNDARY.END, durationMs: safeDuration(endMonotonicMs - startMonotonicMs), epochMs: safeReadEpochMs(capture.runtime), - metadata: sanitizeMetadata( - safelyCreateMetadata(metadata, result) - ), + metadata: sanitizeMetadata(safelyCreateMetadata(metadata, result)), phase, requestId, }); @@ -162,7 +161,7 @@ function isCount(value: unknown): value is number { function appendPhaseEvent( capture: WorkerPerformanceCapture, - event: PerformancePhaseEvent + event: PerformancePhaseEvent ): void { try { capture.phaseEvents.push(event); @@ -170,3 +169,23 @@ function appendPhaseEvent( // Phase instrumentation is deliberately fail-neutral. } } + +export function createWorkerPerformancePhaseAdapter( + capture: WorkerPerformanceCapture | null +): DatabaseOperationPerformancePhaseCapture | undefined { + if (!capture?.requestId) { + return undefined; + } + + return { + captureAsync: (phase, execute, metadata) => + captureWorkerPerformancePhaseAsync( + capture, + phase, + execute, + metadata + ), + captureSync: (phase, execute, metadata) => + captureWorkerPerformancePhase(capture, phase, execute, metadata), + }; +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-xtream-wiring.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-xtream-wiring.spec.ts new file mode 100644 index 000000000..14872eb63 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-xtream-wiring.spec.ts @@ -0,0 +1,58 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +function readCase(source: string, operation: string, nextOperation: string) { + return source.slice( + source.indexOf(`case '${operation}':`), + source.indexOf(`case '${nextOperation}':`) + ); +} + +describe('Xtream database performance phase wiring', () => { + const source = readFileSync( + join( + process.cwd(), + 'apps/electron-backend/src/app/workers/database.worker.ts' + ), + 'utf8' + ); + const executeRequestSource = source.slice( + source.indexOf('async function executeRequest') + ); + + it.each([ + ['DB_GET_CATEGORIES', 'DB_SAVE_CATEGORIES', 'getCategories'], + ['DB_SAVE_CATEGORIES', 'DB_GET_ALL_CATEGORIES', 'saveCategories'], + ['DB_GET_CONTENT', 'DB_GET_GLOBAL_RECENTLY_ADDED', 'getContent'], + ['DB_SAVE_CONTENT', 'DB_CLEAR_XTREAM_IMPORT_CACHE', 'saveContent'], + [ + 'DB_CLEAR_XTREAM_IMPORT_CACHE', + 'DB_GET_CONTENT_BY_XTREAM_ID', + 'clearXtreamImportCache', + ], + ['DB_SEARCH_CONTENT', 'DB_GLOBAL_SEARCH', 'searchContent'], + ['DB_DELETE_PLAYLIST', 'DB_GET_APP_STATE', 'deletePlaylist'], + [ + 'DB_DELETE_XTREAM_CONTENT', + 'DB_RESTORE_XTREAM_USER_DATA', + 'deleteXtreamContent', + ], + ])('passes the optional adapter through %s', (operation, next, call) => { + const branch = readCase(executeRequestSource, operation, next); + + expect(branch).toContain('createWorkerPerformancePhaseAdapter'); + expect(branch).toContain('performanceCapture'); + expect(branch).toContain(call); + }); + + it('leaves mixed global search uninstrumented', () => { + const branch = readCase( + executeRequestSource, + 'DB_GLOBAL_SEARCH', + 'DB_CREATE_PLAYLIST' + ); + + expect(branch).not.toContain('createWorkerPerformancePhaseAdapter'); + expect(branch).not.toContain('performanceCapture'); + }); +}); diff --git a/apps/xtream-mock-server/README.md b/apps/xtream-mock-server/README.md index d0d7096b1..8f2416235 100644 --- a/apps/xtream-mock-server/README.md +++ b/apps/xtream-mock-server/README.md @@ -24,20 +24,99 @@ pnpm run serve:marketing-demo:web --- +## Local Performance Mode + +The benchmark control plane is opt-in and binds only to an explicit loopback +address. Use a dedicated port; do not reuse the normal E2E server on `3211`. + +```bash +HOST=127.0.0.1 \ +PORT=3221 \ +IPTVNATOR_XTREAM_MOCK_CONTROL=1 \ +IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN=local-benchmark-token \ +pnpm nx run xtream-mock-server:serve +``` + +When enabled, every `/__control/*` request requires the exact +`x-iptvnator-performance-token` header, including `OPTIONS` preflight +requests. The server refuses non-loopback binds and an empty token. The control +routes do not exist when the flag is absent or is any value other than `1`. +The Nx serve targets preserve an explicit shell `PORT`; when it is omitted, the +server parser still defaults to `3211`. + +Prepare the fixed synthetic fixture before starting a capture: + +```bash +curl -X POST http://127.0.0.1:3221/__control/prepare \ + -H 'content-type: application/json' \ + -H 'x-iptvnator-performance-token: local-benchmark-token' \ + -d '{"scenario":"performance-100k"}' +``` + +The response contains only `epoch`, `scenario`, `seed`, `counts`, `bytes`, and +`catalogSha256`. `counts.categories` is `60/20/20` live/VOD/series (100 total) +and `counts.items` is `60000/20000/20000` (100,000 total). `bytes` and the +SHA-256 cover fixed-order UTF-8 JSON containing, in order, the live, VOD, and +series category arrays followed by the live, VOD, and series catalogs. The hash +input contains no credentials or server origin. + +Control endpoints: + +| Method | Path | Purpose | +| ------ | --------------------------------- | ---------------------------------------------------------------------- | +| POST | `/__control/prepare` | Materialize `performance-100k` and return its safe manifest | +| POST | `/__control/reset` | Reset `observations` or `all` state | +| POST | `/__control/barriers` | Add a one-shot, abort-aware request barrier | +| POST | `/__control/barriers/:id/release` | Release a request that reached a barrier | +| POST | `/__control/delays` | Add a one-shot delay of `0..5000` ms for control/smoke tests only | +| GET | `/__control/state` | Read bounded rules, held IDs, occurrences, lifecycle ledger, and epoch | + +Rules match the exact tuple +`(epoch, scenario, transport, action, categoryId, occurrence)`. Occurrences are +counted independently per tuple without `occurrence`, so parallel category +arrival order cannot change which rule matches. Empty Xtream actions are +recorded as `get_account_info`; unknown actions are recorded only as `unknown`. +Numeric category IDs use canonical decimal spelling: signs, whitespace, +leading zeroes, non-decimal notation, and unsafe integers are rejected. The +state never includes the token, credentials, raw URL/query, response payloads, +titles, or catalog arrays. + +`reset` with mode `observations` clears rules, occurrences, held requests, and +the ledger while preserving the prepared manifest and epoch. Mode `all` also +clears fixture caches and the prepared manifest, then increments the epoch. +Held clients are settled during either reset. The legacy unauthenticated +`POST /reset` returns `410` in performance mode so it cannot invalidate fixture +caches without also invalidating the prepared control manifest; use the +token-authenticated control reset instead. + +JSON bodies are strict and limited to 16 KiB. A state epoch accepts at most 32 +rules; the ledger retains 128 entries, and occurrence state has 512 slots for +the complete allowlisted identity domain without eviction or counter restart. +Duplicate IDs/matches, past occurrences, arbitrary scenarios/actions/category +IDs, unknown fields, and invalid delays are rejected. + +Performance mode is local-only: `/playlist.m3u` and every performance-fixture +stream/timeshift URL return `410` without redirecting or contacting external +media. Barriers and delays are coordination tools, not timing inputs. +**Formal benchmark captures must start with zero barrier and delay rules.** + +--- + ## Available Scenarios (credential pairs) -| Username | Password | Scenario | Live cats | VOD cats | Series cats | Items/cat | Status | -| ----------- | ----------- | ---------------------- | --------- | -------- | ----------- | --------- | -------- | -| `user1` | `pass1` | default | 8 | 8 | 8 | 40 | active | -| `large` | `large` | large catalog | 20 | 20 | 20 | 200 | active | -| `stress` | `stress` | stress catalog | 16 | 16 | 16 | 120 | active | -| `series` | `series` | series-heavy | 3 | 4 | 15 | 30 | active | -| `minimal` | `minimal` | minimal (edge cases) | 2 | 2 | 2 | 5 | active | -| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active | -| `emptyvod` | `emptyvod` | empty VOD metadata | 2 | 2 | 2 | 5 | active | -| `marketing` | `marketing` | fictional release demo | 4 | 4 | 4 | curated | active | -| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired | -| `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled | +| Username | Password | Scenario | Live cats | VOD cats | Series cats | Items/cat | Status | +| ------------- | ------------- | ---------------------- | --------- | -------- | ----------- | --------- | -------- | +| `user1` | `pass1` | default | 8 | 8 | 8 | 40 | active | +| `large` | `large` | large catalog | 20 | 20 | 20 | 200 | active | +| `stress` | `stress` | stress catalog | 16 | 16 | 16 | 120 | active | +| `performance` | `performance` | performance-100k | 60 | 20 | 20 | 1,000 | active | +| `series` | `series` | series-heavy | 3 | 4 | 15 | 30 | active | +| `minimal` | `minimal` | minimal (edge cases) | 2 | 2 | 2 | 5 | active | +| `epg` | `epg` | EPG fixture | 2 | 1 | 1 | 3 | active | +| `emptyvod` | `emptyvod` | empty VOD metadata | 2 | 2 | 2 | 5 | active | +| `marketing` | `marketing` | fictional release demo | 4 | 4 | 4 | curated | active | +| `expired` | `expired` | expired account | 4 | 4 | 4 | 10 | Expired | +| `inactive` | `inactive` | disabled account | 4 | 4 | 4 | 10 | Disabled | Any other credential pair is auto-generated using a hash of `username:password` as the faker seed (6 categories, 30 items each, active account). @@ -145,6 +224,9 @@ application code. - **EPG**: Titles and descriptions are base64-encoded (matches real Xtream API) - **Dedicated EPG fixture**: `epg:epg` returns stable live channels plus deterministic `get_short_epg` and `get_simple_data_table` payloads for timezone-focused tests - **Release screenshot fixture**: `marketing:marketing` returns fictional live, VOD, and series data with local generated artwork under `apps/xtream-mock-server/public/marketing` +- **Performance fixture**: `performance:performance` returns exactly 100,000 + local-only catalog items from index-derived values; it does not use Faker, + `Date.now()`, `Math.random()`, external artwork, or external media URLs - **Timestamp precedence coverage**: The `epg:epg` scenario intentionally shifts raw `start` / `end` strings away from `start_timestamp` / `stop_timestamp` so UI tests can prove timestamps drive rendering - **Stream IDs**: Live 10,000+, VOD 20,000+, Series 30,000+ - **Category IDs**: Live 101+, VOD 201+, Series 301+ diff --git a/apps/xtream-mock-server/jest.config.ts b/apps/xtream-mock-server/jest.config.ts new file mode 100644 index 000000000..7e75dc2c3 --- /dev/null +++ b/apps/xtream-mock-server/jest.config.ts @@ -0,0 +1,13 @@ +export default { + displayName: 'xtream-mock-server', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.spec.json' }, + ], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/apps/xtream-mock-server', +}; diff --git a/apps/xtream-mock-server/project.json b/apps/xtream-mock-server/project.json index f43ed7394..63793b31e 100644 --- a/apps/xtream-mock-server/project.json +++ b/apps/xtream-mock-server/project.json @@ -13,7 +13,6 @@ "cwd": "{workspaceRoot}", "color": true, "env": { - "PORT": "3211", "NODE_ENV": "development" } } @@ -26,13 +25,20 @@ "cwd": "{workspaceRoot}", "color": true, "env": { - "PORT": "3211", "NODE_ENV": "development" } } }, "lint": { "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "apps/xtream-mock-server/jest.config.ts", + "tsConfig": "apps/xtream-mock-server/tsconfig.spec.json" + } } } } diff --git a/apps/xtream-mock-server/src/app/data-store.ts b/apps/xtream-mock-server/src/app/data-store.ts index c0b07513e..409cafa94 100644 --- a/apps/xtream-mock-server/src/app/data-store.ts +++ b/apps/xtream-mock-server/src/app/data-store.ts @@ -8,14 +8,29 @@ import { RawEpgListing, RawLiveStream, } from './generators/live.generator.js'; -import { generateVodStreams, generateVodDetails, RawVodStream, RawVodDetails } from './generators/vod.generator.js'; -import { generateSeriesItems, generateSeriesInfo, RawSeriesItem, RawSeriesInfo } from './generators/series.generator.js'; +import { + generateVodStreams, + generateVodDetails, + RawVodStream, + RawVodDetails, +} from './generators/vod.generator.js'; +import { + generateSeriesItems, + generateSeriesInfo, + RawSeriesItem, + RawSeriesInfo, +} from './generators/series.generator.js'; import { RawCategory } from './generators/categories.generator.js'; import { buildMarketingPortalFixture, buildMarketingSeriesInfo, buildMarketingVodDetails, } from './generators/marketing.generator.js'; +import { + buildPerformancePortalFixture, + buildPerformanceSeriesInfo, + buildPerformanceVodDetails, +} from './generators/performance.generator.js'; import { getScenario, ScenarioConfig } from './scenarios.js'; export interface PortalData { @@ -30,21 +45,31 @@ export interface PortalData { } const portalCache = new Map(); -const vodDetailsCache = new Map(); -const seriesInfoCache = new Map(); +const vodDetailsCache = new Map(); +const seriesInfoCache = new Map(); function generatePortalData(username: string, password: string): PortalData { const scenario = getScenario(username, password); + if (scenario.performanceFixture === 'catalog-100k') { + return { + scenario, + ...buildPerformancePortalFixture(), + }; + } faker.seed(scenario.seed); - - const { categoryCount, itemsPerCategory, seasonsPerSeries, episodesPerSeason } = scenario; - + const { + categoryCount, + itemsPerCategory, + seasonsPerSeries, + episodesPerSeason, + } = scenario; if (scenario.marketingFixture) { const marketingFixture = buildMarketingPortalFixture(); for (const s of marketingFixture.seriesItems) { - if (!seriesInfoCache.has(s.series_id)) { + const cacheKey = detailCacheKey(username, password, s.series_id); + if (!seriesInfoCache.has(cacheKey)) { seriesInfoCache.set( - s.series_id, + cacheKey, buildMarketingSeriesInfo( s, seasonsPerSeries, @@ -53,22 +78,18 @@ function generatePortalData(username: string, password: string): PortalData { ); } } - return { scenario, ...marketingFixture, }; } - let liveCategories = generateCategories('live', categoryCount.live); const vodCategories = generateCategories('vod', categoryCount.vod); const seriesCategories = generateCategories('series', categoryCount.series); - const epgListingsByStreamId = new Map(); let liveStreams = generateLiveStreams(liveCategories, itemsPerCategory); const vodStreams = generateVodStreams(vodCategories, itemsPerCategory); const seriesItems = generateSeriesItems(seriesCategories, itemsPerCategory); - if (scenario.epgFixture === 'timezone-focus') { const timezoneFixture = buildTimezoneFixture(); liveCategories = timezoneFixture.liveCategories; @@ -77,14 +98,18 @@ function generatePortalData(username: string, password: string): PortalData { epgListingsByStreamId.set(streamId, listings); }); } - // Pre-populate series info cache - for (const s of seriesItems) { - if (!seriesInfoCache.has(s.series_id)) { - seriesInfoCache.set(s.series_id, generateSeriesInfo(s, seasonsPerSeries, episodesPerSeason)); + if (!scenario.deferSeriesDetails) { + for (const s of seriesItems) { + const cacheKey = detailCacheKey(username, password, s.series_id); + if (!seriesInfoCache.has(cacheKey)) { + seriesInfoCache.set( + cacheKey, + generateSeriesInfo(s, seasonsPerSeries, episodesPerSeason) + ); + } } } - return { scenario, liveCategories, @@ -105,47 +130,60 @@ export function getPortalData(username: string, password: string): PortalData { return portalCache.get(key)!; } -export function getVodDetails(username: string, password: string, vodId: number): RawVodDetails | null { - if (!vodDetailsCache.has(vodId)) { +export function getVodDetails( + username: string, + password: string, + vodId: number +): RawVodDetails | null { + const cacheKey = detailCacheKey(username, password, vodId); + if (!vodDetailsCache.has(cacheKey)) { const data = getPortalData(username, password); - const stream = data.vodStreams.find(v => v.stream_id === vodId); + const stream = data.vodStreams.find((v) => v.stream_id === vodId); if (!stream) return null; if (data.scenario.vodDetailsFixture === 'empty-metadata') { - vodDetailsCache.set(vodId, { info: [] }); - return vodDetailsCache.get(vodId) ?? null; + vodDetailsCache.set(cacheKey, { info: [] }); + return vodDetailsCache.get(cacheKey) ?? null; } vodDetailsCache.set( - vodId, - data.scenario.marketingFixture - ? buildMarketingVodDetails(stream) - : generateVodDetails(stream) + cacheKey, + data.scenario.performanceFixture === 'catalog-100k' + ? buildPerformanceVodDetails(stream) + : data.scenario.marketingFixture + ? buildMarketingVodDetails(stream) + : generateVodDetails(stream) ); } - return vodDetailsCache.get(vodId) ?? null; + return vodDetailsCache.get(cacheKey) ?? null; } -export function getSeriesInfo(username: string, password: string, seriesId: number): RawSeriesInfo | null { - if (!seriesInfoCache.has(seriesId)) { +export function getSeriesInfo( + username: string, + password: string, + seriesId: number +): RawSeriesInfo | null { + const cacheKey = detailCacheKey(username, password, seriesId); + if (!seriesInfoCache.has(cacheKey)) { const data = getPortalData(username, password); - const series = data.seriesItems.find(s => s.series_id === seriesId); + const series = data.seriesItems.find((s) => s.series_id === seriesId); if (!series) return null; - const scenario = getScenario(username, password); seriesInfoCache.set( - seriesId, - scenario.marketingFixture - ? buildMarketingSeriesInfo( - series, - scenario.seasonsPerSeries, - scenario.episodesPerSeason - ) - : generateSeriesInfo( - series, - scenario.seasonsPerSeries, - scenario.episodesPerSeason - ) + cacheKey, + data.scenario.performanceFixture === 'catalog-100k' + ? buildPerformanceSeriesInfo(series) + : data.scenario.marketingFixture + ? buildMarketingSeriesInfo( + series, + data.scenario.seasonsPerSeries, + data.scenario.episodesPerSeason + ) + : generateSeriesInfo( + series, + data.scenario.seasonsPerSeries, + data.scenario.episodesPerSeason + ) ); } - return seriesInfoCache.get(seriesId) ?? null; + return seriesInfoCache.get(cacheKey) ?? null; } export function getEpgListings( @@ -183,6 +221,25 @@ export function resetAll(): void { seriesInfoCache.clear(); } +/** Test-only cache sizes; never exposes credential-bearing keys or fixture data. */ +export function getDetailCacheCardinalityForTesting(): Readonly<{ + vodDetails: number; + seriesInfo: number; +}> { + return { + vodDetails: vodDetailsCache.size, + seriesInfo: seriesInfoCache.size, + }; +} + +function detailCacheKey( + username: string, + password: string, + itemId: number +): string { + return `${username}:${password}:${itemId}`; +} + function buildTimezoneFixture(): Pick< PortalData, 'liveCategories' | 'liveStreams' | 'epgListingsByStreamId' @@ -258,42 +315,48 @@ function buildTimezoneNewsEpg(streamId: number): RawEpgListing[] { { id: `${streamId}-past`, title: 'Earlier Bulletin', - description: 'Past schedule item used to anchor current-program detection.', + description: + 'Past schedule item used to anchor current-program detection.', startTimestamp: roundedNow - 75 * 60, stopTimestamp: roundedNow - 15 * 60, }, { id: `${streamId}-current`, title: 'Global Headlines', - description: 'Current program for list-row and detail EPG assertions.', + description: + 'Current program for list-row and detail EPG assertions.', startTimestamp: roundedNow - 15 * 60, stopTimestamp: roundedNow + 15 * 60, }, { id: `${streamId}-next`, title: 'Market Wrap', - description: 'Immediate next program for short-EPG preview assertions.', + description: + 'Immediate next program for short-EPG preview assertions.', startTimestamp: roundedNow + 15 * 60, stopTimestamp: roundedNow + 45 * 60, }, { id: `${streamId}-later`, title: 'Overnight Update', - description: 'Later same-day program for selected-channel list coverage.', + description: + 'Later same-day program for selected-channel list coverage.', startTimestamp: roundedNow + 45 * 60, stopTimestamp: roundedNow + 75 * 60, }, { id: `${streamId}-boundary-1`, title: 'Late Edition', - description: 'Boundary program spanning UTC midnight for timezone edge cases.', + description: + 'Boundary program spanning UTC midnight for timezone edge cases.', startTimestamp: nextUtcMidnight - 30 * 60, stopTimestamp: nextUtcMidnight + 30 * 60, }, { id: `${streamId}-boundary-2`, title: 'After Midnight', - description: 'Post-midnight program used for next-day navigation checks.', + description: + 'Post-midnight program used for next-day navigation checks.', startTimestamp: nextUtcMidnight + 30 * 60, stopTimestamp: nextUtcMidnight + 90 * 60, }, diff --git a/apps/xtream-mock-server/src/app/generators/performance.generator.spec.ts b/apps/xtream-mock-server/src/app/generators/performance.generator.spec.ts new file mode 100644 index 000000000..fb471d6ab --- /dev/null +++ b/apps/xtream-mock-server/src/app/generators/performance.generator.spec.ts @@ -0,0 +1,388 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import type { Request, Response } from 'express'; +import * as dataStore from '../data-store.js'; +import { dispatchAction } from '../routes/dispatch.js'; +import { getScenario } from '../scenarios.js'; + +jest.mock('@faker-js/faker', () => { + let accessEnabled = false; + const fixedDate = new Date('2020-01-01T00:00:00.000Z'); + const fixedText = 'Fixture value'; + const deterministicFaker = { + seed: jest.fn(), + company: { + name: () => fixedText, + catchPhrase: () => fixedText, + }, + date: { + past: () => fixedDate, + recent: () => fixedDate, + }, + location: { country: () => fixedText }, + lorem: { + paragraph: () => fixedText, + sentence: () => fixedText, + words: () => fixedText, + }, + music: { + genre: () => fixedText, + songName: () => fixedText, + }, + number: { + int: ({ min = 0 }: { min?: number }) => min, + }, + person: { fullName: () => fixedText }, + }; + return { + faker: new Proxy(deterministicFaker, { + get(target, property, receiver) { + if (!accessEnabled) { + throw new Error( + `Performance fixture accessed Faker.${String(property)}` + ); + } + return Reflect.get(target, property, receiver); + }, + }), + setFakerAccessForTesting(enabled: boolean) { + accessEnabled = enabled; + }, + }; +}); +const [USERNAME, PASSWORD] = ['performance', 'performance']; +const LOOPBACK_ORIGIN = 'http://127.0.0.1:3211'; +jest.setTimeout(30_000); +type PortalData = ReturnType; +type DispatchResult = { + body: unknown; + jsonCalls: number; + statusCode: number; +}; +let initialPortal!: PortalData; +function setFakerAccess(enabled: boolean): void { + const fakerModule = jest.requireMock('@faker-js/faker') as { + setFakerAccessForTesting(value: boolean): void; + }; + fakerModule.setFakerAccessForTesting(enabled); +} +function withGenericFaker(run: () => void): void { + setFakerAccess(true); + try { + run(); + } finally { + dataStore.resetAll(); + setFakerAccess(false); + } +} +function portalFingerprint(data: PortalData): { + byteCount: number; + sha256: string; +} { + const serialized = JSON.stringify({ + scenario: data.scenario, + liveCategories: data.liveCategories, + vodCategories: data.vodCategories, + seriesCategories: data.seriesCategories, + liveStreams: data.liveStreams, + vodStreams: data.vodStreams, + seriesItems: data.seriesItems, + epgListingsByStreamId: [...data.epgListingsByStreamId.entries()], + }); + return { + byteCount: Buffer.byteLength(serialized), + sha256: createHash('sha256').update(serialized).digest('hex'), + }; +} +function dispatch(query: Record): DispatchResult { + let body: unknown; + let jsonCalls = 0; + let statusCode = 200; + const response = { + json(value: unknown) { + body = value; + jsonCalls += 1; + return response; + }, + status(value: number) { + statusCode = value; + return response; + }, + }; + dispatchAction( + { query } as unknown as Request, + response as unknown as Response + ); + return { body, jsonCalls, statusCode }; +} +function expectLocalOrEmpty(values: string[]): void { + for (const value of values) { + expect(value === '' || value.startsWith(`${LOOPBACK_ORIGIN}/`)).toBe( + true + ); + } +} +function expectOneThousandItemsPerCategory( + items: ReadonlyArray<{ category_id: number | string }> +): void { + const counts = new Map(); + for (const item of items) { + const categoryId = String(item.category_id); + counts.set(categoryId, (counts.get(categoryId) ?? 0) + 1); + } + expect([...counts.values()].every((count) => count === 1_000)).toBe(true); +} + +describe('performance Xtream fixture', () => { + beforeAll(() => { + dataStore.resetAll(); + initialPortal = dataStore.getPortalData(USERNAME, PASSWORD); + }); + afterAll(() => { + dataStore.resetAll(); + setFakerAccess(false); + }); + it('selects the committed performance scenario', () => { + expect(getScenario(USERNAME, PASSWORD)).toMatchObject({ + name: 'performance-100k', + description: 'Deterministic local-only 100k performance catalog', + seed: 91_001, + categoryCount: { live: 60, vod: 20, series: 20 }, + itemsPerCategory: 1_000, + seasonsPerSeries: 1, + episodesPerSeason: 1, + accountStatus: 'Active', + expiryDate: '2099-12-31', + performanceFixture: 'catalog-100k', + deferSeriesDetails: true, + }); + }); + + it('contains exactly 100 categories and 100,000 catalog items', () => { + expect([ + initialPortal.liveCategories.length, + initialPortal.vodCategories.length, + initialPortal.seriesCategories.length, + ]).toEqual([60, 20, 20]); + expect(initialPortal.liveStreams).toHaveLength(60_000); + expect(initialPortal.vodStreams).toHaveLength(20_000); + expect(initialPortal.seriesItems).toHaveLength(20_000); + expectOneThousandItemsPerCategory(initialPortal.liveStreams); + expectOneThousandItemsPerCategory(initialPortal.vodStreams); + expectOneThousandItemsPerCategory(initialPortal.seriesItems); + }); + + it('uses unique item IDs and valid category references', () => { + const itemIds = [ + ...initialPortal.liveStreams.map((item) => item.stream_id), + ...initialPortal.vodStreams.map((item) => item.stream_id), + ...initialPortal.seriesItems.map((item) => item.series_id), + ]; + const liveCategoryIds = new Set( + initialPortal.liveCategories.map((item) => item.category_id) + ); + const vodCategoryIds = new Set( + initialPortal.vodCategories.map((item) => item.category_id) + ); + const seriesCategoryIds = new Set( + initialPortal.seriesCategories.map((item) => item.category_id) + ); + expect(new Set(itemIds).size).toBe(100_000); + expect( + initialPortal.liveStreams.every((item) => + liveCategoryIds.has(item.category_id) + ) + ).toBe(true); + expect( + initialPortal.vodStreams.every((item) => + vodCategoryIds.has(item.category_id) + ) + ).toBe(true); + expect( + initialPortal.seriesItems.every((item) => + seriesCategoryIds.has(String(item.category_id)) + ) + ).toBe(true); + }); + + it('rebuilds a distinct portal to the identical byte count and SHA-256', () => { + const source = readFileSync( + `${__dirname}/performance.generator.ts`, + 'utf8' + ); + expect(source).not.toMatch( + /@faker-js\/faker|\bfaker\b|Date\.now|Math\.random/ + ); + const dateNow = jest.spyOn(Date, 'now').mockImplementation(() => { + throw new Error('performance fixture used Date.now'); + }); + const mathRandom = jest.spyOn(Math, 'random').mockImplementation(() => { + throw new Error('performance fixture used Math.random'); + }); + try { + const firstFingerprint = portalFingerprint(initialPortal); + dataStore.resetAll(); + const rebuiltPortal = dataStore.getPortalData(USERNAME, PASSWORD); + + expect(rebuiltPortal).not.toBe(initialPortal); + expect(portalFingerprint(rebuiltPortal)).toEqual(firstFingerprint); + } finally { + dateNow.mockRestore(); + mathRandom.mockRestore(); + } + }); + + it('keeps catalog and detail URL fields local-only', () => { + const data = dataStore.getPortalData(USERNAME, PASSWORD); + const vodDetails = dataStore.getVodDetails( + USERNAME, + PASSWORD, + data.vodStreams[0].stream_id + ); + const seriesInfo = dataStore.getSeriesInfo( + USERNAME, + PASSWORD, + data.seriesItems[0].series_id + ); + + expect(vodDetails).not.toBeNull(); + expect(seriesInfo).not.toBeNull(); + expectLocalOrEmpty([ + ...data.liveStreams.flatMap((item) => [ + item.stream_icon, + item.direct_source, + ]), + ...data.vodStreams.flatMap((item) => [ + item.stream_icon, + item.direct_source, + ]), + ...data.seriesItems.flatMap((item) => [ + item.cover, + item.youtube_trailer, + ...item.backdrop_path, + ]), + ...(vodDetails && !Array.isArray(vodDetails.info) + ? [ + vodDetails.info.kinopoisk_url, + vodDetails.info.cover_big, + vodDetails.info.movie_image, + vodDetails.info.youtube_trailer, + ...vodDetails.info.backdrop_path, + vodDetails.movie_data?.direct_source ?? '', + ] + : []), + ...(seriesInfo + ? [ + seriesInfo.info.cover, + seriesInfo.info.youtube_trailer, + ...seriesInfo.info.backdrop_path, + ...seriesInfo.seasons.flatMap((season) => [ + season.cover, + season.cover_big, + ]), + ...Object.values(seriesInfo.episodes).flatMap( + (episodes) => + episodes.flatMap((episode) => [ + episode.info.movie_image, + episode.direct_source, + ]) + ), + ] + : []), + ]); + }); + + it('uses numeric fixed-epoch semantics for catalog timestamps', () => { + const firstSeries = initialPortal.seriesItems[0]; + + expect(initialPortal.liveStreams[0].added).toBe('1767225600'); + expect(initialPortal.vodStreams[0].added).toBe('1767225600'); + expect( + initialPortal.seriesItems.every((item) => + /^\d+$/.test(item.last_modified) + ) + ).toBe(true); + expect(firstSeries.last_modified).toBe('1767225600'); + expect( + new Date(Number(firstSeries.last_modified) * 1_000).toISOString() + ).toBe('2026-01-01T00:00:00.000Z'); + expect(firstSeries.releaseDate).toBe('2026-01-01'); + }); + + it('keeps account-info lazy and materializes one series detail through dispatch', () => { + dataStore.resetAll(); + const accountResult = dispatch({ + action: 'get_account_info', + username: USERNAME, + password: PASSWORD, + }); + + expect([accountResult.statusCode, accountResult.jsonCalls]).toEqual([ + 200, 1, + ]); + expect( + (accountResult.body as { user_info?: { username?: string } }) + .user_info?.username + ).toBe(USERNAME); + expect(dataStore.getDetailCacheCardinalityForTesting()).toEqual({ + vodDetails: 0, + seriesInfo: 0, + }); + + const seriesId = dataStore.getPortalData(USERNAME, PASSWORD) + .seriesItems[0].series_id; + const seriesResult = dispatch({ + action: 'get_series_info', + username: USERNAME, + password: PASSWORD, + series_id: String(seriesId), + }); + const seriesBody = seriesResult.body as { + seasons?: unknown[]; + episodes?: Record; + }; + + expect([seriesResult.statusCode, seriesResult.jsonCalls]).toEqual([ + 200, 1, + ]); + expect(seriesBody.seasons).toHaveLength(1); + expect(seriesBody.episodes?.['1']).toHaveLength(1); + expect(dataStore.getDetailCacheCardinalityForTesting()).toEqual({ + vodDetails: 0, + seriesInfo: 1, + }); + }); + + it('keys VOD and series details by both username and password', () => { + withGenericFaker(() => { + dataStore.resetAll(); + const portals = [ + ['emptyvod', 'emptyvod'], + ['emptyvod', 'alternate'], + ['alternate', 'emptyvod'], + ] as const; + const firstPortal = dataStore.getPortalData(...portals[0]); + const vodId = firstPortal.vodStreams[0].stream_id; + const seriesId = firstPortal.seriesItems[0].series_id; + const vodDetails = portals.map(([username, password]) => + dataStore.getVodDetails(username, password, vodId) + ); + const seriesDetails = portals.map(([username, password]) => + dataStore.getSeriesInfo(username, password, seriesId) + ); + expect(vodDetails[0]?.info).toEqual([]); + expect( + vodDetails.slice(1).every((item) => !Array.isArray(item?.info)) + ).toBe(true); + expect(seriesDetails.map((item) => item?.seasons.length)).toEqual([ + 2, 3, 3, + ]); + expect( + seriesDetails.map((item) => item?.episodes['1'].length) + ).toEqual([4, 8, 8]); + expect( + dataStore.getDetailCacheCardinalityForTesting().vodDetails + ).toBe(3); + }); + }); +}); diff --git a/apps/xtream-mock-server/src/app/generators/performance.generator.ts b/apps/xtream-mock-server/src/app/generators/performance.generator.ts new file mode 100644 index 000000000..20879833c --- /dev/null +++ b/apps/xtream-mock-server/src/app/generators/performance.generator.ts @@ -0,0 +1,294 @@ +import type { RawCategory } from './categories.generator.js'; +import type { RawEpgListing, RawLiveStream } from './live.generator.js'; +import type { + RawEpisode, + RawSeriesInfo, + RawSeriesItem, + RawSeason, +} from './series.generator.js'; +import type { RawVodDetails, RawVodStream } from './vod.generator.js'; + +interface PerformancePortalFixture { + liveCategories: RawCategory[]; + vodCategories: RawCategory[]; + seriesCategories: RawCategory[]; + liveStreams: RawLiveStream[]; + vodStreams: RawVodStream[]; + seriesItems: RawSeriesItem[]; + epgListingsByStreamId: Map; +} + +const FIXED_EPOCH_SECONDS = 1_767_225_600; +const DAY_SECONDS = 86_400; +const ITEMS_PER_CATEGORY = 1_000; +const LIVE_CATEGORY_COUNT = 60; +const VOD_CATEGORY_COUNT = 20; +const SERIES_CATEGORY_COUNT = 20; +const LIVE_CATEGORY_ID_BASE = 91_100; +const VOD_CATEGORY_ID_BASE = 92_100; +const SERIES_CATEGORY_ID_BASE = 93_100; +const LIVE_STREAM_ID_BASE = 1_000_000; +const VOD_STREAM_ID_BASE = 2_000_000; +const SERIES_ID_BASE = 3_000_000; +const EPISODE_ID_BASE = 4_000_000; +const CONTAINER_EXTENSIONS = ['mp4', 'mkv', 'avi'] as const; + +export function buildPerformancePortalFixture(): PerformancePortalFixture { + const liveCategories = buildCategories( + 'Live', + LIVE_CATEGORY_ID_BASE, + LIVE_CATEGORY_COUNT + ); + const vodCategories = buildCategories( + 'VOD', + VOD_CATEGORY_ID_BASE, + VOD_CATEGORY_COUNT + ); + const seriesCategories = buildCategories( + 'Series', + SERIES_CATEGORY_ID_BASE, + SERIES_CATEGORY_COUNT + ); + + return { + liveCategories, + vodCategories, + seriesCategories, + liveStreams: buildLiveStreams(liveCategories), + vodStreams: buildVodStreams(vodCategories), + seriesItems: buildSeriesItems(seriesCategories), + epgListingsByStreamId: new Map(), + }; +} + +export function buildPerformanceVodDetails( + stream: RawVodStream +): RawVodDetails { + const index = stream.stream_id - VOD_STREAM_ID_BASE; + const durationSeconds = 5_400 + (index % 1_800); + const hours = Math.floor(durationSeconds / 3_600); + const minutes = Math.floor((durationSeconds % 3_600) / 60); + + return { + info: { + kinopoisk_url: '', + tmdb_id: stream.stream_id, + name: stream.name, + o_name: stream.name, + cover_big: '', + movie_image: '', + releasedate: dateFromIndex(index), + episode_run_time: durationSeconds, + youtube_trailer: '', + director: `Performance Director ${formatIndex(index)}`, + actors: `Performance Actor ${formatIndex(index)}`, + cast: `Performance Actor ${formatIndex(index)}`, + description: `Synthetic movie ${formatIndex(index)}.`, + plot: `Synthetic movie ${formatIndex(index)}.`, + age: '12', + mpaa_rating: 'PG-13', + rating_count_kinopoisk: 1_000 + index, + country: 'Fixtureland', + genre: `Performance Genre ${index % 10}`, + backdrop_path: [], + duration_secs: durationSeconds, + duration: `${hours}h ${minutes}min`, + video: ['H.264'], + audio: ['AAC'], + bitrate: 2_000 + (index % 4_000), + rating: stream.rating, + rating_kinopoisk: stream.rating_imdb, + rating_imdb: stream.rating_imdb, + }, + movie_data: { + stream_id: stream.stream_id, + name: stream.name, + added: stream.added, + category_id: stream.category_id, + container_extension: stream.container_extension, + custom_sid: '', + direct_source: '', + }, + }; +} + +export function buildPerformanceSeriesInfo( + series: RawSeriesItem +): RawSeriesInfo { + const index = series.series_id - SERIES_ID_BASE; + const releaseDate = dateFromIndex(index); + const durationSeconds = 1_500 + (index % 1_800); + const season: RawSeason = { + air_date: releaseDate, + episode_count: 1, + id: series.series_id * 10 + 1, + name: 'Season 1', + overview: `Synthetic season for ${series.name}.`, + season_number: 1, + cover: '', + cover_big: '', + }; + const episode: RawEpisode = { + id: String(EPISODE_ID_BASE + index), + episode_num: 1, + title: `${series.name} S01E01`, + container_extension: 'mkv', + info: { + tmdb_id: series.series_id, + releasedate: releaseDate, + plot: `Synthetic episode for ${series.name}.`, + duration_secs: durationSeconds, + duration: `${Math.floor(durationSeconds / 60)}min`, + movie_image: '', + bitrate: 1_500 + (index % 5_000), + rating: ratingFromIndex(index), + }, + custom_sid: '', + added: addedFromIndex(index), + season: 1, + direct_source: '', + }; + + return { + seasons: [season], + info: { + name: series.name, + cover: '', + plot: series.plot, + cast: series.cast, + director: series.director, + genre: series.genre, + releaseDate: series.releaseDate, + last_modified: series.last_modified, + rating: series.rating, + rating_5based: series.rating_5based, + backdrop_path: [], + youtube_trailer: '', + episode_run_time: series.episode_run_time, + category_id: String(series.category_id), + }, + episodes: { '1': [episode] }, + }; +} + +function buildCategories( + label: string, + idBase: number, + count: number +): RawCategory[] { + return Array.from({ length: count }, (_, index) => ({ + category_id: String(idBase + index + 1), + category_name: `Performance ${label} ${String(index + 1).padStart( + 2, + '0' + )}`, + parent_id: 0, + })); +} + +function buildLiveStreams(categories: RawCategory[]): RawLiveStream[] { + const streams: RawLiveStream[] = []; + + for (const category of categories) { + for (let itemIndex = 0; itemIndex < ITEMS_PER_CATEGORY; itemIndex++) { + const index = streams.length; + const streamId = LIVE_STREAM_ID_BASE + index; + streams.push({ + num: index + 1, + name: `Performance Live ${formatIndex(index)}`, + stream_type: 'live', + stream_id: streamId, + stream_icon: '', + epg_channel_id: `performance-channel-${streamId}`, + added: addedFromIndex(index), + category_id: category.category_id, + custom_sid: '', + direct_source: '', + tv_archive: index % 2, + tv_archive_duration: index % 2 === 0 ? 0 : 3, + rating_imdb: ratingFromIndex(index).toFixed(1), + }); + } + } + + return streams; +} + +function buildVodStreams(categories: RawCategory[]): RawVodStream[] { + const streams: RawVodStream[] = []; + + for (const category of categories) { + for (let itemIndex = 0; itemIndex < ITEMS_PER_CATEGORY; itemIndex++) { + const index = streams.length; + const rating = ratingFromIndex(index); + streams.push({ + num: index + 1, + name: `Performance Movie ${formatIndex(index)}`, + stream_type: 'movie', + stream_id: VOD_STREAM_ID_BASE + index, + stream_icon: '', + added: addedFromIndex(index), + category_id: category.category_id, + custom_sid: '', + direct_source: '', + rating, + rating_5based: Number((rating / 2).toFixed(1)), + rating_imdb: rating.toFixed(1), + container_extension: + CONTAINER_EXTENSIONS[index % CONTAINER_EXTENSIONS.length], + type: 'movie', + }); + } + } + + return streams; +} + +function buildSeriesItems(categories: RawCategory[]): RawSeriesItem[] { + const items: RawSeriesItem[] = []; + + for (const category of categories) { + for (let itemIndex = 0; itemIndex < ITEMS_PER_CATEGORY; itemIndex++) { + const index = items.length; + const rating = ratingFromIndex(index); + items.push({ + num: index + 1, + name: `Performance Series ${formatIndex(index)}`, + series_id: SERIES_ID_BASE + index, + cover: '', + plot: `Synthetic series ${formatIndex(index)}.`, + cast: `Performance Actor ${formatIndex(index)}`, + director: `Performance Director ${formatIndex(index)}`, + genre: `Performance Genre ${index % 10}`, + releaseDate: dateFromIndex(index), + last_modified: addedFromIndex(index), + rating: rating.toFixed(1), + rating_5based: Number((rating / 2).toFixed(1)), + backdrop_path: [], + youtube_trailer: '', + episode_run_time: String(24 + (index % 37)), + category_id: Number(category.category_id), + }); + } + } + + return items; +} + +function addedFromIndex(index: number): string { + return String(FIXED_EPOCH_SECONDS - index); +} + +function dateFromIndex(index: number, dayRange = 3_650): string { + const timestampSeconds = + FIXED_EPOCH_SECONDS - (index % dayRange) * DAY_SECONDS; + return new Date(timestampSeconds * 1_000).toISOString().slice(0, 10); +} + +function formatIndex(index: number): string { + return String(index + 1).padStart(6, '0'); +} + +function ratingFromIndex(index: number): number { + return Number((6 + (index % 30) / 10).toFixed(1)); +} diff --git a/apps/xtream-mock-server/src/app/performance-category-validation.spec.ts b/apps/xtream-mock-server/src/app/performance-category-validation.spec.ts new file mode 100644 index 000000000..93855c955 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-category-validation.spec.ts @@ -0,0 +1,45 @@ +import { + isAllowedPerformanceCategory, + parseRuleBody, + PerformanceControlError, +} from './performance-control-validation.js'; + +describe('Xtream performance category validation', () => { + it.each([ + '091101', + '+91101', + ' 91101', + '91101 ', + '9.1101e4', + '9007199254740993', + '99999999999999999999999999999999999999999999999999', + ])('rejects the non-canonical category ID %p', (categoryId) => { + expect( + isAllowedPerformanceCategory('get_live_streams', categoryId) + ).toBe(false); + + expect(() => + parseRuleBody('barrier', { + id: 'non-canonical-category', + match: { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_live_streams', + occurrence: 1, + categoryId, + }, + }) + ).toThrow(PerformanceControlError); + }); + + it.each([ + ['get_live_streams', '91101'], + ['get_live_streams', '91160'], + ['get_vod_streams', '92101'], + ['get_vod_streams', '92120'], + ['get_series', '93101'], + ['get_series', '93120'], + ] as const)('accepts canonical %s category %s', (action, categoryId) => { + expect(isAllowedPerformanceCategory(action, categoryId)).toBe(true); + }); +}); diff --git a/apps/xtream-mock-server/src/app/performance-control-abort.spec.ts b/apps/xtream-mock-server/src/app/performance-control-abort.spec.ts new file mode 100644 index 000000000..b5789c9a7 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control-abort.spec.ts @@ -0,0 +1,149 @@ +import { resetAll } from './data-store.js'; +import type { PerformanceControlState } from './performance-control.js'; +import { createXtreamMockApp } from './server.js'; +import { + openAbortableGet, + postJson, + requestJson, + startLoopbackServer, + waitFor, +} from './testing/http-server.fixture.js'; + +jest.mock('@faker-js/faker', () => ({ + faker: { seed: jest.fn() }, +})); + +const TOKEN = 'delay-abort-token'; +const AUTH_HEADERS = { + 'x-iptvnator-performance-token': TOKEN, +}; + +describe('Xtream performance delay cancellation', () => { + it('aborts an active delay without later dispatch or response lifecycle', async () => { + resetAll(); + const running = await startLoopbackServer( + createXtreamMockApp({ + control: { enabled: true, token: TOKEN }, + host: '127.0.0.1', + port: 0, + }) + ); + try { + const rule = await postJson( + running.origin, + '/__control/delays', + TOKEN, + { + id: 'abort-delay', + match: { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_live_streams', + occurrence: 1, + categoryId: '91101', + }, + milliseconds: 100, + } + ); + expect(rule.response.status).toBe(201); + const request = openAbortableGet( + running.origin, + '/player_api.php?username=performance&password=performance&action=get_live_streams&category_id=91101' + ); + await waitFor(async () => { + expect( + (await state(running.origin)).ledger.map( + (entry) => entry.status + ) + ).toEqual(expect.arrayContaining(['arrived', 'delayed'])); + }); + + request.abort(); + await request.completed; + await waitFor(async () => { + const snapshot = await state(running.origin); + expect(snapshot.heldCount).toBe(0); + expect(snapshot.rules).toEqual([]); + expect(snapshot.ledger.map((entry) => entry.status)).toEqual( + expect.arrayContaining(['aborted']) + ); + }); + await new Promise((resolve) => setTimeout(resolve, 125)); + const terminal = await state(running.origin); + expect( + terminal.ledger.some((entry) => entry.status === 'responded') + ).toBe(false); + expect(terminal.occurrences).toEqual([ + expect.objectContaining({ + action: 'get_live_streams', + categoryId: '91101', + count: 1, + }), + ]); + } finally { + await running.close(); + resetAll(); + } + }); + + it('settles an active delay when observations reset', async () => { + resetAll(); + const running = await startLoopbackServer( + createXtreamMockApp({ + control: { enabled: true, token: TOKEN }, + host: '127.0.0.1', + port: 0, + }) + ); + try { + await postJson(running.origin, '/__control/delays', TOKEN, { + id: 'reset-delay', + match: { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_live_streams', + occurrence: 1, + categoryId: '91101', + }, + milliseconds: 100, + }); + const delayedResponse = fetch( + `${running.origin}/player_api.php?username=performance&password=performance&action=get_live_streams&category_id=91101` + ); + await waitFor(async () => { + expect( + (await state(running.origin)).ledger.map( + (entry) => entry.status + ) + ).toContain('delayed'); + }); + + const reset = await postJson( + running.origin, + '/__control/reset', + TOKEN, + { mode: 'observations' } + ); + expect(reset.response.status).toBe(200); + expect((await delayedResponse).status).toBe(409); + await new Promise((resolve) => setTimeout(resolve, 125)); + expect(await state(running.origin)).toMatchObject({ + heldCount: 0, + ledger: [], + occurrences: [], + rules: [], + }); + } finally { + await running.close(); + resetAll(); + } + }); +}); + +async function state(origin: string): Promise { + const result = await requestJson(origin, '/__control/state', { + headers: AUTH_HEADERS, + }); + expect(result.response.status).toBe(200); + return result.body as PerformanceControlState; +} diff --git a/apps/xtream-mock-server/src/app/performance-control-lifecycle.spec.ts b/apps/xtream-mock-server/src/app/performance-control-lifecycle.spec.ts new file mode 100644 index 000000000..0ec9938e8 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control-lifecycle.spec.ts @@ -0,0 +1,83 @@ +import { EventEmitter } from 'node:events'; +import type { Request, Response } from 'express'; +import { XtreamPerformanceController } from './performance-control.js'; + +jest.mock('@faker-js/faker', () => ({ + faker: { seed: jest.fn() }, +})); + +describe('Xtream performance controller lifecycle', () => { + it.each([ + ['observations', 1], + ['all', 2], + ] as const)( + 'invalidates an unmatched pre-reset response on %s reset', + async (mode, expectedEpoch) => { + const controller = new XtreamPerformanceController(); + const request = createRequest(); + const response = createResponse(); + + await controller.intercept( + request, + response as unknown as Response, + 'direct', + () => undefined + ); + expect(controller.snapshot().ledger).toHaveLength(1); + + controller.reset(mode); + + expect(response.listenerCount('finish')).toBe(0); + expect(response.listenerCount('close')).toBe(0); + expect(response.destroyed).toBe(true); + + response.writableFinished = true; + response.emit('finish'); + response.emit('close'); + + expect(controller.snapshot()).toMatchObject({ + epoch: expectedEpoch, + heldIds: [], + heldCount: 0, + occurrences: [], + ledger: [], + }); + } + ); +}); + +function createRequest(): Request { + return Object.assign(new EventEmitter(), { + aborted: false, + query: { + username: 'performance', + password: 'performance', + }, + }) as unknown as Request; +} + +function createResponse(): EventEmitter & { + destroyed: boolean; + headersSent: boolean; + writableFinished: boolean; + destroy(): void; + json(): unknown; + status(): unknown; +} { + const response = Object.assign(new EventEmitter(), { + destroyed: false, + headersSent: false, + writableFinished: false, + destroy() { + response.destroyed = true; + response.emit('close'); + }, + json() { + return response; + }, + status() { + return response; + }, + }); + return response; +} diff --git a/apps/xtream-mock-server/src/app/performance-control-routes.ts b/apps/xtream-mock-server/src/app/performance-control-routes.ts new file mode 100644 index 000000000..6c0df33eb --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control-routes.ts @@ -0,0 +1,131 @@ +import { timingSafeEqual } from 'node:crypto'; +import cors from 'cors'; +import express, { + type ErrorRequestHandler, + type NextFunction, + type Request, + type Response, +} from 'express'; +import { + PERFORMANCE_CONTROL_LIMITS, + XtreamPerformanceController, +} from './performance-control.js'; +import { + assertSafeRuleId, + parsePrepareBody, + parseReleaseBody, + parseResetBody, + parseRuleBody, + PerformanceControlError, +} from './performance-control-validation.js'; + +const TOKEN_HEADER = 'x-iptvnator-performance-token'; + +export function installPerformanceControlRoutes( + app: express.Express, + controller: XtreamPerformanceController, + token: string +): void { + const router = express.Router(); + router.use(authorize(token)); + router.use(cors()); + router.use( + express.json({ + limit: PERFORMANCE_CONTROL_LIMITS.jsonBytes, + strict: true, + }) + ); + + router.post('/prepare', (request, response) => { + executeControl(response, () => { + parsePrepareBody(request.body); + response.json(controller.prepare()); + }); + }); + router.post('/reset', (request, response) => { + executeControl(response, () => { + const { mode } = parseResetBody(request.body); + response.json(controller.reset(mode)); + }); + }); + router.post('/barriers', (request, response) => { + executeControl(response, () => { + const rule = controller.addRule( + parseRuleBody('barrier', request.body) + ); + response.status(201).json(rule); + }); + }); + router.post('/barriers/:id/release', (request, response) => { + executeControl(response, () => { + const id = request.params['id'] ?? ''; + assertSafeRuleId(id); + parseReleaseBody(request.body); + if (!controller.releaseBarrier(id)) { + throw new PerformanceControlError( + 404, + 'held-barrier-not-found' + ); + } + response.json({ id, released: true }); + }); + }); + router.post('/delays', (request, response) => { + executeControl(response, () => { + const rule = controller.addRule( + parseRuleBody('delay', request.body) + ); + response.status(201).json(rule); + }); + }); + router.get('/state', (_request, response) => { + response.json(controller.snapshot()); + }); + router.use(jsonErrorHandler); + app.use('/__control', router); +} + +function authorize(token: string) { + const expected = Buffer.from(token, 'utf8'); + return (request: Request, response: Response, next: NextFunction) => { + const provided = request.get(TOKEN_HEADER); + if (!provided || !constantTimeEqual(expected, provided)) { + response.status(401).json({ error: 'unauthorized' }); + return; + } + next(); + }; +} + +function constantTimeEqual(expected: Buffer, provided: string): boolean { + const candidate = Buffer.from(provided, 'utf8'); + return ( + candidate.length === expected.length && + timingSafeEqual(candidate, expected) + ); +} + +function executeControl(response: Response, execute: () => void): void { + try { + execute(); + } catch (error) { + if (error instanceof PerformanceControlError) { + response.status(error.status).json({ error: error.code }); + return; + } + response.status(500).json({ error: 'control-internal-error' }); + } +} + +const jsonErrorHandler: ErrorRequestHandler = ( + error: Error & { status?: number; type?: string }, + _request, + response, + _next +) => { + void _next; + const tooLarge = error.status === 413 || error.type === 'entity.too.large'; + response + .status(tooLarge ? 413 : 400) + .json({ error: tooLarge ? 'control-json-too-large' : 'invalid-json' }); +}; diff --git a/apps/xtream-mock-server/src/app/performance-control-security.spec.ts b/apps/xtream-mock-server/src/app/performance-control-security.spec.ts new file mode 100644 index 000000000..8164a235d --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control-security.spec.ts @@ -0,0 +1,129 @@ +import { getPortalData, resetAll } from './data-store.js'; +import { type PerformanceControlState } from './performance-control.js'; +import { createXtreamMockApp } from './server.js'; +import { + postJson, + requestJson, + startLoopbackServer, + type RunningTestServer, +} from './testing/http-server.fixture.js'; + +jest.mock('@faker-js/faker', () => ({ + faker: { seed: jest.fn() }, +})); + +const TOKEN = 'security-token'; +const ORIGIN = 'http://127.0.0.1:4200'; + +describe('Xtream performance control request boundary', () => { + let running: RunningTestServer; + + beforeEach(async () => { + resetAll(); + running = await startLoopbackServer( + createXtreamMockApp({ + control: { enabled: true, token: TOKEN }, + host: '127.0.0.1', + port: 0, + }) + ); + }); + + afterEach(async () => { + await running.close(); + resetAll(); + }); + + it('requires the exact token before handling control preflight', async () => { + const missing = await controlOptions(running); + const wrong = await controlOptions(running, `${TOKEN}-wrong`); + const valid = await controlOptions(running, TOKEN); + + expect(missing.status).toBe(401); + expect(wrong.status).toBe(401); + expect(valid.status).toBe(204); + expect(valid.headers.get('access-control-allow-origin')).toBe('*'); + }); + + it('preserves unauthenticated CORS preflight for ordinary routes', async () => { + const response = await fetch(`${running.origin}/player_api.php`, { + method: 'OPTIONS', + headers: { + origin: ORIGIN, + 'access-control-request-method': 'GET', + }, + }); + + expect(response.status).toBe(204); + expect(response.headers.get('access-control-allow-origin')).toBe('*'); + }); + + it('rejects legacy reset without invalidating prepared control state', async () => { + const prepared = await postJson( + running.origin, + '/__control/prepare', + TOKEN, + { scenario: 'performance-100k' } + ); + const cachedBefore = getPortalData('performance', 'performance'); + + const reset = await requestJson(running.origin, '/reset', { + method: 'POST', + }); + const state = await requestJson(running.origin, '/__control/state', { + headers: { + 'x-iptvnator-performance-token': TOKEN, + }, + }); + + expect(reset.response.status).toBe(410); + expect(reset.body).toEqual({ + error: 'use-performance-control-reset', + }); + expect((state.body as PerformanceControlState).prepared).toEqual( + prepared.body + ); + expect(getPortalData('performance', 'performance')).toBe(cachedBefore); + }); +}); + +describe('Xtream normal reset compatibility', () => { + afterEach(() => resetAll()); + + it('keeps the legacy reset available outside control mode', async () => { + const running = await startLoopbackServer( + createXtreamMockApp({ host: '127.0.0.1', port: 0 }) + ); + const cachedBefore = getPortalData('performance', 'performance'); + try { + const reset = await requestJson(running.origin, '/reset', { + method: 'POST', + }); + + expect(reset.response.status).toBe(200); + expect(reset.body).toEqual({ status: 'reset' }); + expect(getPortalData('performance', 'performance')).not.toBe( + cachedBefore + ); + } finally { + await running.close(); + } + }); +}); + +function controlOptions( + running: RunningTestServer, + token?: string +): Promise { + return fetch(`${running.origin}/__control/state`, { + method: 'OPTIONS', + headers: { + origin: ORIGIN, + 'access-control-request-method': 'GET', + 'access-control-request-headers': 'x-iptvnator-performance-token', + ...(token === undefined + ? {} + : { 'x-iptvnator-performance-token': token }), + }, + }); +} diff --git a/apps/xtream-mock-server/src/app/performance-control-validation.spec.ts b/apps/xtream-mock-server/src/app/performance-control-validation.spec.ts new file mode 100644 index 000000000..621c5cd54 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control-validation.spec.ts @@ -0,0 +1,341 @@ +import { + PERFORMANCE_CONTROL_LIMITS, + type PerformanceControlState, +} from './performance-control.js'; +import { resetAll } from './data-store.js'; +import { createXtreamMockApp } from './server.js'; +import { + postJson, + requestJson, + startLoopbackServer, + type RunningTestServer, +} from './testing/http-server.fixture.js'; + +jest.mock('@faker-js/faker', () => ({ + faker: { seed: jest.fn() }, +})); + +const TOKEN = 'validation-token'; +const AUTH_HEADERS = { + 'x-iptvnator-performance-token': TOKEN, +}; +jest.setTimeout(60_000); + +describe('Xtream performance control validation and bounds', () => { + let running: RunningTestServer; + + beforeEach(async () => { + resetAll(); + running = await startLoopbackServer( + createXtreamMockApp({ + control: { enabled: true, token: TOKEN }, + host: '127.0.0.1', + port: 0, + }) + ); + }); + + afterEach(async () => { + await running.close(); + resetAll(); + }); + + it.each([ + ['/__control/prepare', {}, 400], + [ + '/__control/prepare', + { scenario: 'performance-100k', extra: true }, + 400, + ], + ['/__control/prepare', { scenario: 'large' }, 400], + ['/__control/reset', { mode: 'observations', extra: true }, 400], + ['/__control/reset', { mode: 'wrong' }, 400], + ['/__control/barriers', { id: '../unsafe', match: validMatch(1) }, 400], + [ + '/__control/barriers', + { + id: 'bad-scenario', + match: { ...validMatch(1), scenario: 'large' }, + }, + 400, + ], + [ + '/__control/barriers', + { + id: 'bad-transport', + match: { ...validMatch(1), transport: 'remote' }, + }, + 400, + ], + [ + '/__control/barriers', + { + id: 'bad-action', + match: { ...validMatch(1), action: 'delete_everything' }, + }, + 400, + ], + [ + '/__control/barriers', + { id: 'zero-occurrence', match: validMatch(0) }, + 400, + ], + [ + '/__control/barriers', + { id: 'fraction-occurrence', match: validMatch(1.5) }, + 400, + ], + [ + '/__control/barriers', + { + id: 'bad-category', + match: { ...validMatch(1), categoryId: '99999' }, + }, + 400, + ], + [ + '/__control/barriers', + { + id: 'unknown-field', + match: { ...validMatch(1), url: 'http://forbidden.invalid' }, + }, + 400, + ], + [ + '/__control/delays', + { id: 'negative-delay', match: validMatch(1), milliseconds: -1 }, + 400, + ], + [ + '/__control/delays', + { id: 'long-delay', match: validMatch(1), milliseconds: 5001 }, + 400, + ], + [ + '/__control/delays', + { + id: 'fraction-delay', + match: validMatch(1), + milliseconds: 1.5, + }, + 400, + ], + ])('rejects invalid body %#', async (path, body, status) => { + const result = await postJson(running.origin, path, TOKEN, body); + expect(result.response.status).toBe(status); + }); + + it('rejects malformed and oversized JSON safely', async () => { + const malformed = await fetch(`${running.origin}/__control/prepare`, { + method: 'POST', + headers: { + ...AUTH_HEADERS, + 'content-type': 'application/json', + }, + body: '{"scenario":', + }); + const oversized = await fetch(`${running.origin}/__control/prepare`, { + method: 'POST', + headers: { + ...AUTH_HEADERS, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + scenario: 'performance-100k', + padding: 'x'.repeat(PERFORMANCE_CONTROL_LIMITS.jsonBytes + 1), + }), + }); + + expect(malformed.status).toBe(400); + expect(oversized.status).toBe(413); + expect(await malformed.text()).not.toContain('scenario'); + expect(await oversized.text()).not.toContain('padding'); + }); + + it('rejects arbitrary release bodies before looking up a barrier', async () => { + const response = await fetch( + `${running.origin}/__control/barriers/not-held/release`, + { + method: 'POST', + headers: { + ...AUTH_HEADERS, + 'content-type': 'application/json', + }, + body: JSON.stringify({ url: 'http://forbidden.invalid' }), + } + ); + expect(response.status).toBe(400); + expect(await response.text()).not.toContain('forbidden'); + }); + + it('rejects duplicate IDs, duplicate matches, and past occurrences', async () => { + const first = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { id: 'first', match: validMatch(1) } + ); + const duplicateId = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { + id: 'first', + match: { + ...validMatch(1), + categoryId: '91102', + }, + } + ); + const duplicateMatch = await postJson( + running.origin, + '/__control/delays', + TOKEN, + { id: 'same-match', match: validMatch(1), milliseconds: 0 } + ); + expect(first.response.status).toBe(201); + expect(duplicateId.response.status).toBe(409); + expect(duplicateMatch.response.status).toBe(409); + + await postJson(running.origin, '/__control/reset', TOKEN, { + mode: 'observations', + }); + await fetch( + `${running.origin}/player_api.php?username=performance&password=performance&action=get_live_streams&category_id=91101` + ); + const past = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { id: 'past', match: validMatch(1) } + ); + expect(past.response.status).toBe(409); + }); + + it('caps accepted rules and the serialized ledger', async () => { + for ( + let occurrence = 1; + occurrence <= PERFORMANCE_CONTROL_LIMITS.rules; + occurrence++ + ) { + const result = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { + id: `rule-${occurrence}`, + match: validMatch(occurrence), + } + ); + expect(result.response.status).toBe(201); + } + const overflow = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { + id: 'rule-overflow', + match: validMatch(PERFORMANCE_CONTROL_LIMITS.rules + 1), + } + ); + expect(overflow.response.status).toBe(409); + + await postJson(running.origin, '/__control/reset', TOKEN, { + mode: 'observations', + }); + for ( + let index = 0; + index < PERFORMANCE_CONTROL_LIMITS.ledger; + index++ + ) { + await fetch( + `${running.origin}/player_api.php?username=u${index}&password=p${index}&action=unknown-${index}` + ); + } + const snapshot = await state(running); + expect(snapshot.ledger).toHaveLength(PERFORMANCE_CONTROL_LIMITS.ledger); + expect(snapshot.occurrences.length).toBeLessThanOrEqual( + PERFORMANCE_CONTROL_LIMITS.occurrences + ); + }); + + it('never serializes credentials, raw queries, URLs, or response payloads', async () => { + const username = 'SECRET_USER_MARKER'; + const password = 'SECRET_PASSWORD_MARKER'; + const rawUrl = 'http://RAW_URL_MARKER.invalid/private'; + const responseMarker = 'RESPONSE_PAYLOAD_MARKER'; + await postJson(running.origin, '/__control/prepare', TOKEN, { + scenario: 'performance-100k', + }); + const response = await fetch( + `${running.origin}/xtream?url=${encodeURIComponent( + rawUrl + )}&username=${username}&password=${password}&action=${responseMarker}&query=RAW_QUERY_MARKER` + ); + expect(response.status).toBe(400); + expect(await response.text()).toContain(responseMarker); + + const serialized = JSON.stringify(await state(running)); + for (const marker of [ + username, + password, + rawUrl, + 'RAW_URL_MARKER', + 'RAW_QUERY_MARKER', + responseMarker, + TOKEN, + 'Performance Live 000001', + ]) { + expect(serialized).not.toContain(marker); + } + }); + + it('accepts the legacy action alias but rejects arbitrary cardinalities', async () => { + const alias = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { + id: 'legacy-alias', + match: { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_simple_date_table', + occurrence: 1, + categoryId: 'all', + }, + } + ); + const arbitraryCategory = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { + id: 'arbitrary-category', + match: { ...validMatch(2), categoryId: '91161' }, + } + ); + expect(alias.response.status).toBe(201); + expect(arbitraryCategory.response.status).toBe(400); + }); +}); + +function validMatch(occurrence: number) { + return { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_live_streams', + occurrence, + categoryId: '91101', + }; +} + +async function state( + running: RunningTestServer +): Promise { + const result = await requestJson(running.origin, '/__control/state', { + headers: AUTH_HEADERS, + }); + expect(result.response.status).toBe(200); + return result.body as PerformanceControlState; +} diff --git a/apps/xtream-mock-server/src/app/performance-control-validation.ts b/apps/xtream-mock-server/src/app/performance-control-validation.ts new file mode 100644 index 000000000..629cf5b92 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control-validation.ts @@ -0,0 +1,185 @@ +import type { + PerformanceAction, + PerformanceRuleInput, + PerformanceRuleMatch, + PerformanceTransport, +} from './performance-control.types.js'; + +const RULE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; +const PERFORMANCE_SCENARIO = 'performance-100k'; +const TRANSPORTS: readonly PerformanceTransport[] = ['direct', 'proxy']; +const ACTIONS: readonly PerformanceAction[] = [ + 'get_account_info', + 'get_live_categories', + 'get_vod_categories', + 'get_series_categories', + 'get_live_streams', + 'get_vod_streams', + 'get_series', + 'get_vod_info', + 'get_series_info', + 'get_short_epg', + 'get_simple_data_table', + 'get_simple_date_table', +]; + +export class PerformanceControlError extends Error { + constructor( + readonly status: number, + readonly code: string + ) { + super(code); + } +} + +export function parsePrepareBody(value: unknown): { + scenario: typeof PERFORMANCE_SCENARIO; +} { + requireExactObject(value, ['scenario']); + if (value['scenario'] !== PERFORMANCE_SCENARIO) { + invalid(); + } + return { scenario: PERFORMANCE_SCENARIO }; +} + +export function parseResetBody(value: unknown): { + mode: 'all' | 'observations'; +} { + requireExactObject(value, ['mode']); + const mode = value['mode']; + if (mode !== 'all' && mode !== 'observations') invalid(); + return { mode }; +} + +export function parseRuleBody( + kind: 'barrier' | 'delay', + value: unknown +): PerformanceRuleInput { + const keys = + kind === 'barrier' ? ['id', 'match'] : ['id', 'match', 'milliseconds']; + requireExactObject(value, keys); + const id = value['id']; + if (typeof id !== 'string' || !RULE_ID_PATTERN.test(id)) invalid(); + const match = parseMatch(value['match']); + + if (kind === 'barrier') { + return { id, kind, match }; + } + const milliseconds = value['milliseconds']; + if ( + !Number.isInteger(milliseconds) || + (milliseconds as number) < 0 || + (milliseconds as number) > 5_000 + ) { + invalid(); + } + return { id, kind, match, milliseconds: milliseconds as number }; +} + +export function parseReleaseBody(value: unknown): void { + if (value === undefined) return; + requireExactObject(value, []); +} + +export function assertSafeRuleId(value: string): void { + if (!RULE_ID_PATTERN.test(value)) invalid(); +} + +export function isPerformanceAction(value: string): value is PerformanceAction { + return ACTIONS.includes(value as PerformanceAction); +} + +export function canonicalObservedAction( + value: unknown +): PerformanceAction | 'unknown' { + const raw = typeof value === 'string' ? value : ''; + if (raw === '') return 'get_account_info'; + return isPerformanceAction(raw) ? raw : 'unknown'; +} + +function parseMatch(value: unknown): PerformanceRuleMatch { + requireExactObject(value, [ + 'scenario', + 'transport', + 'action', + 'occurrence', + 'categoryId', + ]); + const scenario = value['scenario']; + const transport = value['transport']; + const action = value['action']; + const occurrence = value['occurrence']; + const categoryId = value['categoryId']; + + if (scenario !== PERFORMANCE_SCENARIO) invalid(); + if ( + typeof transport !== 'string' || + !TRANSPORTS.includes(transport as PerformanceTransport) + ) { + invalid(); + } + if (typeof action !== 'string' || !isPerformanceAction(action)) invalid(); + if (!Number.isInteger(occurrence) || (occurrence as number) <= 0) invalid(); + if ( + typeof categoryId !== 'string' || + !isAllowedPerformanceCategory(action, categoryId) + ) { + invalid(); + } + + return { + scenario, + transport: transport as PerformanceTransport, + action, + occurrence: occurrence as number, + categoryId, + }; +} + +export function isAllowedPerformanceCategory( + action: PerformanceAction, + categoryId: string +): boolean { + if (categoryId === 'all') return true; + if (!/^\d+$/.test(categoryId)) return false; + const numericId = Number(categoryId); + if (!Number.isSafeInteger(numericId) || categoryId !== String(numericId)) { + return false; + } + if (action === 'get_live_streams') { + return numericId >= 91_101 && numericId <= 91_160; + } + if (action === 'get_vod_streams') { + return numericId >= 92_101 && numericId <= 92_120; + } + if (action === 'get_series') { + return numericId >= 93_101 && numericId <= 93_120; + } + return false; +} + +function requireExactObject( + value: unknown, + expectedKeys: readonly string[] +): asserts value is Record { + if ( + value === null || + typeof value !== 'object' || + Array.isArray(value) || + Object.getPrototypeOf(value) !== Object.prototype + ) { + invalid(); + } + const actualKeys = Object.keys(value).sort(); + const sortedExpected = [...expectedKeys].sort(); + if ( + actualKeys.length !== sortedExpected.length || + actualKeys.some((key, index) => key !== sortedExpected[index]) + ) { + invalid(); + } +} + +function invalid(): never { + throw new PerformanceControlError(400, 'invalid-control-request'); +} diff --git a/apps/xtream-mock-server/src/app/performance-control.spec.ts b/apps/xtream-mock-server/src/app/performance-control.spec.ts new file mode 100644 index 000000000..d09273d5f --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control.spec.ts @@ -0,0 +1,380 @@ +import { resetAll } from './data-store.js'; +import { type PerformanceControlState } from './performance-control.js'; +import { createXtreamMockApp } from './server.js'; +import { + openAbortableGet, + postJson, + requestJson, + startLoopbackServer, + waitFor, + type RunningTestServer, +} from './testing/http-server.fixture.js'; + +jest.mock('@faker-js/faker', () => ({ + faker: { seed: jest.fn() }, +})); + +const TOKEN = 'benchmark-token'; +const AUTH_HEADERS = { + 'x-iptvnator-performance-token': TOKEN, +}; +jest.setTimeout(120_000); + +describe('Xtream performance control API', () => { + let running: RunningTestServer; + + beforeEach(async () => { + resetAll(); + running = await startLoopbackServer( + createXtreamMockApp({ + control: { enabled: true, token: TOKEN }, + host: '127.0.0.1', + port: 0, + }) + ); + }); + + afterEach(async () => { + await running.close(); + resetAll(); + }); + + it('requires the exact token for every control route', async () => { + const missing = await fetch(`${running.origin}/__control/state`); + const incorrect = await fetch(`${running.origin}/__control/state`, { + headers: { + 'x-iptvnator-performance-token': `${TOKEN}-wrong`, + }, + }); + const valid = await fetch(`${running.origin}/__control/state`, { + headers: AUTH_HEADERS, + }); + + expect([missing.status, incorrect.status, valid.status]).toEqual([ + 401, 401, 200, + ]); + }); + + it('prepares the exact 100k fixture with a stable redacted manifest', async () => { + const first = await postJson( + running.origin, + '/__control/prepare', + TOKEN, + { scenario: 'performance-100k' } + ); + expect(first.response.status).toBe(200); + expect(Object.keys(first.body as object)).toEqual([ + 'epoch', + 'scenario', + 'seed', + 'counts', + 'bytes', + 'catalogSha256', + ]); + expect(first.body).toMatchObject({ + epoch: 1, + scenario: 'performance-100k', + seed: 91_001, + counts: { + categories: { live: 60, vod: 20, series: 20, total: 100 }, + items: { + live: 60_000, + vod: 20_000, + series: 20_000, + total: 100_000, + }, + }, + }); + expect((first.body as { bytes: number }).bytes).toBeGreaterThan(0); + expect((first.body as { catalogSha256: string }).catalogSha256).toMatch( + /^[a-f0-9]{64}$/ + ); + + const reset = await postJson( + running.origin, + '/__control/reset', + TOKEN, + { mode: 'all' } + ); + expect(reset.response.status).toBe(200); + const second = await postJson( + running.origin, + '/__control/prepare', + TOKEN, + { scenario: 'performance-100k' } + ); + expect(second.body).toMatchObject({ + epoch: 2, + bytes: (first.body as { bytes: number }).bytes, + catalogSha256: (first.body as { catalogSha256: string }) + .catalogSha256, + }); + }); + + it('distinguishes observation reset from all-state reset', async () => { + const prepared = await prepare(running); + await postJson(running.origin, '/__control/barriers', TOKEN, { + id: 'future-live', + match: liveMatch('direct', '91101', 1), + }); + await fetch( + `${running.origin}/player_api.php?username=performance&password=performance&action=get_account_info` + ); + const before = await state(running); + expect(before.prepared).toEqual(prepared); + expect(before.rules).toHaveLength(1); + expect(before.ledger.length).toBeGreaterThan(0); + + await postJson(running.origin, '/__control/reset', TOKEN, { + mode: 'observations', + }); + const observationsReset = await state(running); + expect(observationsReset).toMatchObject({ + epoch: 1, + prepared, + rules: [], + heldIds: [], + heldCount: 0, + occurrences: [], + ledger: [], + }); + + await postJson(running.origin, '/__control/reset', TOKEN, { + mode: 'all', + }); + const allReset = await state(running); + expect(allReset).toMatchObject({ + epoch: 2, + prepared: null, + rules: [], + heldIds: [], + heldCount: 0, + occurrences: [], + ledger: [], + }); + }); + + it('matches category barriers independently of parallel arrival order', async () => { + await prepare(running); + await createBarrier( + running, + 'category-a', + liveMatch('direct', '91101', 1) + ); + await createBarrier( + running, + 'category-b', + liveMatch('direct', '91102', 1) + ); + + const categoryB = fetch(catalogUrl(running, '91102', 'direct')); + await expectHeld(running, ['category-b']); + const categoryA = fetch(catalogUrl(running, '91101', 'direct')); + await expectHeld(running, ['category-a', 'category-b']); + await release(running, 'category-a'); + await release(running, 'category-b'); + + const [bodyA, bodyB] = await Promise.all([ + categoryA.then((response) => response.json()), + categoryB.then((response) => response.json()), + ]); + expect(bodyA).toHaveLength(1_000); + expect(bodyB).toHaveLength(1_000); + const snapshot = await state(running); + expect( + snapshot.occurrences.filter( + (entry) => + entry.action === 'get_live_streams' && + ['91101', '91102'].includes(entry.categoryId) + ) + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ categoryId: '91101', count: 1 }), + expect.objectContaining({ categoryId: '91102', count: 1 }), + ]) + ); + }); + + it('releases a barrier and records the terminal response', async () => { + await prepare(running); + await createBarrier(running, 'account-gate', { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_account_info', + occurrence: 1, + categoryId: 'all', + }); + const request = fetch( + `${running.origin}/player_api.php?username=performance&password=performance` + ); + await expectHeld(running, ['account-gate']); + const releaseResponse = await release(running, 'account-gate'); + expect(releaseResponse.status).toBe(200); + expect((await request).status).toBe(200); + await waitFor(async () => { + const snapshot = await state(running); + expect(snapshot.heldCount).toBe(0); + expect(snapshot.ledger.map((entry) => entry.status)).toEqual( + expect.arrayContaining(['arrived', 'blocked', 'responded']) + ); + }); + }); + + it('settles an aborted held request without leaking it', async () => { + await prepare(running); + await createBarrier( + running, + 'abort-gate', + liveMatch('direct', '91101', 1) + ); + const request = openAbortableGet( + running.origin, + '/player_api.php?username=performance&password=performance&action=get_live_streams&category_id=91101' + ); + await expectHeld(running, ['abort-gate']); + request.abort(); + await request.completed; + + await waitFor(async () => { + const snapshot = await state(running); + expect(snapshot.heldCount).toBe(0); + expect( + snapshot.ledger.some( + (entry) => + entry.action === 'get_live_streams' && + entry.status === 'aborted' + ) + ).toBe(true); + }); + }); + + it('settles held requests when observations are reset', async () => { + await prepare(running); + await createBarrier( + running, + 'reset-gate', + liveMatch('direct', '91101', 1) + ); + const heldResponse = fetch( + `${running.origin}/player_api.php?username=performance&password=performance&action=get_live_streams&category_id=91101` + ); + await expectHeld(running, ['reset-gate']); + const reset = await postJson( + running.origin, + '/__control/reset', + TOKEN, + { mode: 'observations' } + ); + + expect(reset.response.status).toBe(200); + expect((await heldResponse).status).toBe(409); + expect(await state(running)).toMatchObject({ + epoch: 1, + heldIds: [], + heldCount: 0, + occurrences: [], + ledger: [], + rules: [], + }); + }); + + it('applies a configured short delay and records its lifecycle', async () => { + await prepare(running); + const created = await postJson( + running.origin, + '/__control/delays', + TOKEN, + { + id: 'short-delay', + match: liveMatch('proxy', '91101', 1), + milliseconds: 1, + } + ); + expect(created.response.status).toBe(201); + const response = await fetch(catalogUrl(running, '91101', 'proxy')); + expect(response.status).toBe(200); + const snapshot = await state(running); + expect(snapshot.heldCount).toBe(0); + expect(snapshot.ledger.map((entry) => entry.status)).toEqual( + expect.arrayContaining(['arrived', 'delayed', 'responded']) + ); + }); +}); + +async function prepare( + running: RunningTestServer +): Promise { + const result = await postJson(running.origin, '/__control/prepare', TOKEN, { + scenario: 'performance-100k', + }); + expect(result.response.status).toBe(200); + return result.body as PerformanceControlState['prepared']; +} + +async function state( + running: RunningTestServer +): Promise { + const result = await requestJson(running.origin, '/__control/state', { + headers: AUTH_HEADERS, + }); + expect(result.response.status).toBe(200); + return result.body as PerformanceControlState; +} + +function liveMatch( + transport: 'direct' | 'proxy', + categoryId: string, + occurrence: number +) { + return { + scenario: 'performance-100k', + transport, + action: 'get_live_streams', + occurrence, + categoryId, + }; +} + +async function createBarrier( + running: RunningTestServer, + id: string, + match: ReturnType | Record +) { + const result = await postJson( + running.origin, + '/__control/barriers', + TOKEN, + { id, match } + ); + expect(result.response.status).toBe(201); +} + +async function release( + running: RunningTestServer, + id: string +): Promise { + return fetch(`${running.origin}/__control/barriers/${id}/release`, { + method: 'POST', + headers: AUTH_HEADERS, + }); +} + +async function expectHeld( + running: RunningTestServer, + ids: string[] +): Promise { + await waitFor(async () => { + const snapshot = await state(running); + expect(snapshot.heldIds.sort()).toEqual([...ids].sort()); + expect(snapshot.heldCount).toBe(ids.length); + }); +} + +function catalogUrl( + running: RunningTestServer, + categoryId: string, + transport: 'direct' | 'proxy' +): string { + const route = transport === 'direct' ? '/player_api.php' : '/xtream'; + return `${running.origin}${route}?url=ignored&username=performance&password=performance&action=get_live_streams&category_id=${categoryId}`; +} diff --git a/apps/xtream-mock-server/src/app/performance-control.ts b/apps/xtream-mock-server/src/app/performance-control.ts new file mode 100644 index 000000000..2a2362aed --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control.ts @@ -0,0 +1,299 @@ +import { performance } from 'node:perf_hooks'; +import type { Request, Response } from 'express'; +import { resetAll } from './data-store.js'; +import { PerformanceInterceptionLifecycle } from './performance-interception-lifecycle.js'; +import { buildPerformanceManifest } from './performance-manifest.js'; +import { PerformanceControlError } from './performance-control-validation.js'; +import { + PERFORMANCE_CONTROL_LIMITS, + type PerformanceControlState, + type PerformanceIdentity, + type PerformanceLedgerEntry, + type PerformanceLifecycleStatus, + type PerformanceManifest, + type PerformanceRuleInput, + type PerformanceTransport, +} from './performance-control.types.js'; +import { + identityKey, + PerformanceOccurrenceTracker, + ruleMatchKey, +} from './performance-occurrences.js'; + +export { + PERFORMANCE_CONTROL_LIMITS, + type PerformanceAction, + type PerformanceControlState, + type PerformanceLedgerEntry, + type PerformanceLifecycleStatus, + type PerformanceManifest, + type PerformanceOccurrenceState, + type PerformanceRuleInput, + type PerformanceRuleMatch, + type PerformanceTransport, +} from './performance-control.types.js'; + +type HeldOutcome = 'released' | 'aborted' | 'reset' | 'shutdown'; +interface PendingRequest { + readonly kind: 'barrier' | 'delay'; + settle(outcome: HeldOutcome): void; +} + +export class XtreamPerformanceController { + private epoch = 1; + private prepared: PerformanceManifest | null = null; + private readonly rules = new Map(); + private readonly matchKeys = new Set(); + private readonly seenRuleIds = new Set(); + private acceptedRuleCount = 0; + private readonly pending = new Map(); + private readonly interceptionLifecycle = + new PerformanceInterceptionLifecycle(); + private readonly occurrences = new PerformanceOccurrenceTracker(); + private readonly ledger: PerformanceLedgerEntry[] = []; + + prepare(): PerformanceManifest { + this.prepared ??= buildPerformanceManifest(this.epoch); + return this.prepared; + } + + reset(mode: 'all' | 'observations'): { epoch: number; mode: string } { + this.interceptionLifecycle.reset(); + this.settlePending('reset'); + this.clearObservations(); + if (mode === 'all') { + resetAll(); + this.prepared = null; + this.epoch += 1; + } + return { epoch: this.epoch, mode }; + } + + shutdown(): PerformanceControlState { + if (this.interceptionLifecycle.shutdown()) { + this.settlePending('shutdown'); + this.clearObservations(); + } + return this.snapshot(); + } + + addRule(input: PerformanceRuleInput): PerformanceRuleInput { + if (this.acceptedRuleCount >= PERFORMANCE_CONTROL_LIMITS.rules) { + conflict('control-rule-limit'); + } + if (this.seenRuleIds.has(input.id)) conflict('duplicate-rule-id'); + const matchKey = ruleMatchKey(input.match); + if (this.matchKeys.has(matchKey)) conflict('duplicate-rule-match'); + if (input.match.occurrence <= this.occurrences.count(input.match)) { + conflict('past-rule-occurrence'); + } + + this.acceptedRuleCount += 1; + this.seenRuleIds.add(input.id); + this.matchKeys.add(matchKey); + this.rules.set(input.id, input); + return input; + } + + releaseBarrier(id: string): boolean { + const pending = this.pending.get(id); + if (!pending || pending.kind !== 'barrier') return false; + this.settleOnePending(id, 'released'); + return true; + } + + snapshot(): PerformanceControlState { + const heldIds = [...this.pending.entries()] + .filter(([, request]) => request.kind === 'barrier') + .map(([id]) => id); + return { + epoch: this.epoch, + prepared: this.prepared, + rules: [...this.rules.values()], + heldIds, + heldCount: heldIds.length, + occurrences: this.occurrences.snapshot(), + ledger: [...this.ledger], + }; + } + + async intercept( + request: Request, + response: Response, + transport: PerformanceTransport, + dispatch: () => void | Promise + ): Promise { + if (this.interceptionLifecycle.shuttingDown) { + if (!response.destroyed) response.destroy(); + return; + } + const generation = this.interceptionLifecycle.generation; + const identity = this.occurrences.next(request, transport, this.epoch); + this.record(identity, 'arrived'); + const activeId = Symbol('performance-interception'); + let terminal = false; + let suppressTerminal = false; + let pendingId: string | undefined; + + const cleanup = () => { + request.off('aborted', onAborted); + response.off('finish', onFinished); + response.off('close', onClosed); + this.interceptionLifecycle.unregister(activeId); + }; + const finish = (status: 'responded' | 'aborted') => { + if (terminal) return; + terminal = true; + cleanup(); + if (pendingId) { + this.settleOnePending(pendingId, 'aborted'); + pendingId = undefined; + } + if ( + !suppressTerminal && + this.interceptionLifecycle.isCurrent(generation) + ) { + this.record(identity, status); + } + }; + const onAborted = () => finish('aborted'); + const onFinished = () => finish('responded'); + const onClosed = () => { + if (!response.writableFinished) finish('aborted'); + }; + request.once('aborted', onAborted); + response.once('finish', onFinished); + response.once('close', onClosed); + this.interceptionLifecycle.register(activeId, (abortHeldResponse) => { + if (terminal) return; + terminal = true; + suppressTerminal = true; + cleanup(); + if ( + (abortHeldResponse || pendingId === undefined) && + !response.writableFinished && + !response.destroyed + ) { + response.destroy(); + } + }); + + const rule = this.consumeRule(identity); + if (rule?.kind === 'barrier') { + this.record(identity, 'blocked'); + pendingId = rule.id; + const outcome = await new Promise((resolve) => { + this.pending.set(rule.id, { + kind: 'barrier', + settle: (value) => { + if (value === 'reset') { + suppressTerminal = true; + cleanup(); + } + resolve(value); + }, + }); + }); + pendingId = undefined; + if (this.finishControlledWait(outcome, response)) return; + } else if (rule?.kind === 'delay') { + this.record(identity, 'delayed'); + pendingId = rule.id; + const outcome = await new Promise((resolve) => { + const timer = setTimeout( + () => this.settleOnePending(rule.id, 'released'), + rule.milliseconds + ); + this.pending.set(rule.id, { + kind: 'delay', + settle: (value) => { + clearTimeout(timer); + if (value === 'reset') { + suppressTerminal = true; + cleanup(); + } + resolve(value); + }, + }); + }); + pendingId = undefined; + if (this.finishControlledWait(outcome, response)) return; + } + + if (request.aborted || response.destroyed) { + finish('aborted'); + return; + } + await dispatch(); + } + + private consumeRule( + identity: PerformanceIdentity + ): PerformanceRuleInput | undefined { + for (const rule of this.rules.values()) { + if (ruleMatchKey(rule.match) !== identityKey(identity)) continue; + this.rules.delete(rule.id); + this.matchKeys.delete(ruleMatchKey(rule.match)); + return rule; + } + return undefined; + } + + private record( + identity: PerformanceIdentity, + status: PerformanceLifecycleStatus + ): void { + this.ledger.push({ + ...identity, + status, + atMs: Number(performance.now().toFixed(3)), + }); + if (this.ledger.length > PERFORMANCE_CONTROL_LIMITS.ledger) { + this.ledger.splice( + 0, + this.ledger.length - PERFORMANCE_CONTROL_LIMITS.ledger + ); + } + } + + private finishControlledWait( + outcome: HeldOutcome, + response: Response + ): boolean { + if (outcome === 'released') return false; + if ( + outcome === 'reset' && + !response.headersSent && + !response.destroyed + ) { + response.status(409).json({ error: 'control-reset' }); + } + return true; + } + + private settlePending(outcome: HeldOutcome): void { + for (const id of [...this.pending.keys()]) { + this.settleOnePending(id, outcome); + } + } + + private settleOnePending(id: string, outcome: HeldOutcome): void { + const pending = this.pending.get(id); + if (!pending) return; + this.pending.delete(id); + pending.settle(outcome); + } + + private clearObservations(): void { + this.rules.clear(); + this.matchKeys.clear(); + this.seenRuleIds.clear(); + this.acceptedRuleCount = 0; + this.occurrences.clear(); + this.ledger.length = 0; + } +} + +function conflict(code: string): never { + throw new PerformanceControlError(409, code); +} diff --git a/apps/xtream-mock-server/src/app/performance-control.types.ts b/apps/xtream-mock-server/src/app/performance-control.types.ts new file mode 100644 index 000000000..e11a5662a --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-control.types.ts @@ -0,0 +1,98 @@ +export const PERFORMANCE_CONTROL_LIMITS = { + jsonBytes: 16 * 1024, + ledger: 128, + occurrences: 512, + rules: 32, +} as const; + +export type PerformanceTransport = 'direct' | 'proxy'; +export type PerformanceAction = + | 'get_account_info' + | 'get_live_categories' + | 'get_vod_categories' + | 'get_series_categories' + | 'get_live_streams' + | 'get_vod_streams' + | 'get_series' + | 'get_vod_info' + | 'get_series_info' + | 'get_short_epg' + | 'get_simple_data_table' + | 'get_simple_date_table'; +export type PerformanceLifecycleStatus = + 'arrived' | 'blocked' | 'delayed' | 'responded' | 'aborted'; + +export interface PerformanceRuleMatch { + readonly scenario: 'performance-100k'; + readonly transport: PerformanceTransport; + readonly action: PerformanceAction; + readonly occurrence: number; + readonly categoryId: string; +} + +export type PerformanceRuleInput = + | { + readonly id: string; + readonly kind: 'barrier'; + readonly match: PerformanceRuleMatch; + } + | { + readonly id: string; + readonly kind: 'delay'; + readonly match: PerformanceRuleMatch; + readonly milliseconds: number; + }; + +export interface PerformanceManifest { + readonly epoch: number; + readonly scenario: 'performance-100k'; + readonly seed: number; + readonly counts: { + readonly categories: { + readonly live: number; + readonly vod: number; + readonly series: number; + readonly total: number; + }; + readonly items: { + readonly live: number; + readonly vod: number; + readonly series: number; + readonly total: number; + }; + }; + readonly bytes: number; + readonly catalogSha256: string; +} + +export interface PerformanceIdentity { + readonly epoch: number; + readonly scenario: string; + readonly transport: PerformanceTransport; + readonly action: PerformanceAction | 'unknown'; + readonly categoryId: string; + readonly occurrence: number; +} + +export interface PerformanceOccurrenceState { + readonly scenario: string; + readonly transport: PerformanceTransport; + readonly action: PerformanceAction | 'unknown'; + readonly categoryId: string; + readonly count: number; +} + +export interface PerformanceLedgerEntry extends PerformanceIdentity { + readonly status: PerformanceLifecycleStatus; + readonly atMs: number; +} + +export interface PerformanceControlState { + readonly epoch: number; + readonly prepared: PerformanceManifest | null; + readonly rules: ReadonlyArray; + readonly heldIds: string[]; + readonly heldCount: number; + readonly occurrences: ReadonlyArray; + readonly ledger: ReadonlyArray; +} diff --git a/apps/xtream-mock-server/src/app/performance-interception-lifecycle.ts b/apps/xtream-mock-server/src/app/performance-interception-lifecycle.ts new file mode 100644 index 000000000..8bf169958 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-interception-lifecycle.ts @@ -0,0 +1,45 @@ +type InvalidateInterception = (abortHeldResponse: boolean) => void; + +export class PerformanceInterceptionLifecycle { + private currentGeneration = 1; + private stopped = false; + private readonly active = new Map(); + + get generation(): number { + return this.currentGeneration; + } + + get shuttingDown(): boolean { + return this.stopped; + } + + isCurrent(generation: number): boolean { + return generation === this.currentGeneration; + } + + register(id: symbol, invalidate: InvalidateInterception): void { + this.active.set(id, invalidate); + } + + unregister(id: symbol): void { + this.active.delete(id); + } + + reset(): void { + this.invalidate(false); + } + + shutdown(): boolean { + if (this.stopped) return false; + this.stopped = true; + this.invalidate(true); + return true; + } + + private invalidate(abortHeldResponses: boolean): void { + this.currentGeneration += 1; + for (const invalidate of [...this.active.values()]) { + invalidate(abortHeldResponses); + } + } +} diff --git a/apps/xtream-mock-server/src/app/performance-manifest.ts b/apps/xtream-mock-server/src/app/performance-manifest.ts new file mode 100644 index 000000000..4af2cd847 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-manifest.ts @@ -0,0 +1,56 @@ +import { createHash } from 'node:crypto'; +import { getPortalData } from './data-store.js'; +import type { PerformanceManifest } from './performance-control.types.js'; + +const PERFORMANCE_USERNAME = 'performance'; +const PERFORMANCE_PASSWORD = 'performance'; + +/** + * The property insertion order is part of the fixture identity contract. + * Never add credentials, server origins, or response envelopes to this input. + */ +export function buildPerformanceManifest(epoch: number): PerformanceManifest { + const data = getPortalData(PERFORMANCE_USERNAME, PERFORMANCE_PASSWORD); + const catalog = { + liveCategories: data.liveCategories, + vodCategories: data.vodCategories, + seriesCategories: data.seriesCategories, + liveCatalog: data.liveStreams, + vodCatalog: data.vodStreams, + seriesCatalog: data.seriesItems, + }; + const serialized = JSON.stringify(catalog); + const categoryCounts = { + live: data.liveCategories.length, + vod: data.vodCategories.length, + series: data.seriesCategories.length, + }; + const itemCounts = { + live: data.liveStreams.length, + vod: data.vodStreams.length, + series: data.seriesItems.length, + }; + + return { + epoch, + scenario: 'performance-100k', + seed: data.scenario.seed, + counts: { + categories: { + ...categoryCounts, + total: + categoryCounts.live + + categoryCounts.vod + + categoryCounts.series, + }, + items: { + ...itemCounts, + total: itemCounts.live + itemCounts.vod + itemCounts.series, + }, + }, + bytes: Buffer.byteLength(serialized, 'utf8'), + catalogSha256: createHash('sha256') + .update(serialized, 'utf8') + .digest('hex'), + }; +} diff --git a/apps/xtream-mock-server/src/app/performance-occurrences.spec.ts b/apps/xtream-mock-server/src/app/performance-occurrences.spec.ts new file mode 100644 index 000000000..e53d2ac88 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-occurrences.spec.ts @@ -0,0 +1,108 @@ +import type { Request } from 'express'; +import { PerformanceOccurrenceTracker } from './performance-occurrences.js'; +import { PERFORMANCE_CONTROL_LIMITS } from './performance-control.types.js'; + +describe('PerformanceOccurrenceTracker', () => { + it('does not restart an old valid identity after more than 128 peers', () => { + const tracker = new PerformanceOccurrenceTracker(); + const firstRequest = requestFor('get_live_streams', '91101'); + + expect(tracker.next(firstRequest, 'direct', 1).occurrence).toBe(1); + for (let category = 91_102; category <= 91_160; category++) { + tracker.next( + requestFor('get_live_streams', String(category)), + 'direct', + 1 + ); + } + for (let category = 91_101; category <= 91_160; category++) { + tracker.next( + requestFor('get_live_streams', String(category)), + 'proxy', + 1 + ); + } + for (let category = 92_101; category <= 92_120; category++) { + tracker.next( + requestFor('get_vod_streams', String(category)), + 'direct', + 1 + ); + } + + expect(tracker.snapshot()).toHaveLength(140); + expect(tracker.next(firstRequest, 'direct', 1).occurrence).toBe(2); + }); + + it('collapses non-canonical category aliases into one bounded identity', () => { + const tracker = new PerformanceOccurrenceTracker(); + const canonical = tracker.next( + requestFor('get_live_streams', '91101'), + 'direct', + 1 + ); + const aliases = [ + '+91101', + ' 91101', + '91101 ', + '9.1101e4', + '9007199254740993', + ]; + + expect(canonical).toMatchObject({ + categoryId: '91101', + occurrence: 1, + }); + aliases.forEach((categoryId, index) => { + expect( + tracker.next( + requestFor('get_live_streams', categoryId), + 'direct', + 1 + ) + ).toMatchObject({ + categoryId: 'all', + occurrence: index + 1, + }); + }); + for ( + let zeroCount = 1; + zeroCount <= PERFORMANCE_CONTROL_LIMITS.occurrences + 1; + zeroCount++ + ) { + tracker.next( + requestFor('get_live_streams', `${'0'.repeat(zeroCount)}91101`), + 'direct', + 1 + ); + } + + expect(tracker.snapshot()).toHaveLength(2); + expect(tracker.snapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + categoryId: '91101', + count: 1, + }), + expect.objectContaining({ + categoryId: 'all', + count: + aliases.length + + PERFORMANCE_CONTROL_LIMITS.occurrences + + 1, + }), + ]) + ); + }); +}); + +function requestFor(action: string, categoryId: string): Request { + return { + query: { + username: 'performance', + password: 'performance', + action, + category_id: categoryId, + }, + } as unknown as Request; +} diff --git a/apps/xtream-mock-server/src/app/performance-occurrences.ts b/apps/xtream-mock-server/src/app/performance-occurrences.ts new file mode 100644 index 000000000..dfffe7a19 --- /dev/null +++ b/apps/xtream-mock-server/src/app/performance-occurrences.ts @@ -0,0 +1,104 @@ +import type { Request } from 'express'; +import { + canonicalObservedAction, + isAllowedPerformanceCategory, + PerformanceControlError, +} from './performance-control-validation.js'; +import { + PERFORMANCE_CONTROL_LIMITS, + type PerformanceAction, + type PerformanceIdentity, + type PerformanceOccurrenceState, + type PerformanceRuleMatch, + type PerformanceTransport, +} from './performance-control.types.js'; +import { getScenario } from './scenarios.js'; + +export class PerformanceOccurrenceTracker { + private readonly entries = new Map(); + + next( + request: Request, + transport: PerformanceTransport, + epoch: number + ): PerformanceIdentity { + const username = stringQuery(request.query['username']); + const password = stringQuery(request.query['password']); + const scenario = getScenario(username, password).name; + const action = canonicalObservedAction(request.query['action']); + const rawCategoryId = stringQuery(request.query['category_id']); + const categoryId = + scenario === 'performance-100k' && + action !== 'unknown' && + rawCategoryId !== 'all' && + isAllowedPerformanceCategory(action, rawCategoryId) + ? rawCategoryId + : 'all'; + const base = { scenario, transport, action, categoryId }; + const key = baseIdentityKey(base); + const previous = this.entries.get(key); + if ( + !previous && + this.entries.size >= PERFORMANCE_CONTROL_LIMITS.occurrences + ) { + throw new PerformanceControlError(503, 'control-occurrence-limit'); + } + const count = (previous?.count ?? 0) + 1; + this.entries.set(key, { ...base, count }); + return { epoch, ...base, occurrence: count }; + } + + count(match: PerformanceRuleMatch): number { + return this.entries.get(baseIdentityKey(match))?.count ?? 0; + } + + snapshot(): PerformanceOccurrenceState[] { + return [...this.entries.values()]; + } + + clear(): void { + this.entries.clear(); + } +} + +export function ruleMatchKey(match: PerformanceRuleMatch): string { + return JSON.stringify([ + match.scenario, + match.transport, + match.action, + match.categoryId, + match.occurrence, + ]); +} + +export function identityKey(identity: PerformanceIdentity): string { + return JSON.stringify([ + identity.scenario, + identity.transport, + identity.action, + identity.categoryId, + identity.occurrence, + ]); +} + +function baseIdentityKey( + identity: + | Omit + | { + scenario: string; + transport: PerformanceTransport; + action: PerformanceAction | 'unknown'; + categoryId: string; + } +): string { + return JSON.stringify([ + identity.scenario, + identity.transport, + identity.action, + identity.categoryId, + ]); +} + +function stringQuery(value: unknown): string { + return typeof value === 'string' ? value : ''; +} diff --git a/apps/xtream-mock-server/src/app/project-config.spec.ts b/apps/xtream-mock-server/src/app/project-config.spec.ts new file mode 100644 index 000000000..ace0639fb --- /dev/null +++ b/apps/xtream-mock-server/src/app/project-config.spec.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +describe('Xtream mock Nx serve environment', () => { + it.each(['serve', 'serve-with-watch'])( + 'lets the caller select PORT for the %s target', + (targetName) => { + const project = JSON.parse( + readFileSync( + join(process.cwd(), 'apps/xtream-mock-server/project.json'), + 'utf8' + ) + ) as { + targets: Record< + string, + { options: { env?: Record } } + >; + }; + + expect(project.targets[targetName]?.options.env).not.toHaveProperty( + 'PORT' + ); + expect(project.targets[targetName]?.options.env).toMatchObject({ + NODE_ENV: 'development', + }); + } + ); +}); diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index 94c683108..8212b9212 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -17,6 +17,10 @@ export interface ScenarioConfig { vodDetailsFixture?: 'empty-metadata'; /** Optional fictional release-marketing dataset with local demo artwork. */ marketingFixture?: true; + /** Optional deterministic catalog profile reserved for performance tests. */ + performanceFixture?: 'catalog-100k'; + /** Build series details on demand instead of during portal initialization. */ + deferSeriesDetails?: true; } /** @@ -59,9 +63,23 @@ export const SCENARIOS: Record = { accountStatus: 'Active', expiryDate: '2099-12-31', }, + 'performance:performance': { + name: 'performance-100k', + description: 'Deterministic local-only 100k performance catalog', + seed: 91001, + categoryCount: { live: 60, vod: 20, series: 20 }, + itemsPerCategory: 1000, + seasonsPerSeries: 1, + episodesPerSeason: 1, + accountStatus: 'Active', + expiryDate: '2099-12-31', + performanceFixture: 'catalog-100k', + deferSeriesDetails: true, + }, 'series:series': { name: 'series-heavy', - description: 'Series-heavy — 15 series categories, 6 seasons × 10 episodes', + description: + 'Series-heavy — 15 series categories, 6 seasons × 10 episodes', seed: 2002, categoryCount: { live: 3, vod: 4, series: 15 }, itemsPerCategory: 30, @@ -147,11 +165,18 @@ export const SCENARIOS: Record = { /** Convert credential pair to a numeric seed for unknown credentials. */ export function credentialsToSeed(username: string, password: string): number { const str = `${username}:${password}`; - return str.split('').reduce((acc, ch) => (acc * 31 + ch.charCodeAt(0)) | 0, 0) >>> 0; + return ( + str + .split('') + .reduce((acc, ch) => (acc * 31 + ch.charCodeAt(0)) | 0, 0) >>> 0 + ); } /** Return the scenario config for a given username+password pair. */ -export function getScenario(username: string, password: string): ScenarioConfig { +export function getScenario( + username: string, + password: string +): ScenarioConfig { const key = `${username}:${password}`; if (SCENARIOS[key]) return SCENARIOS[key]; return { diff --git a/apps/xtream-mock-server/src/app/server-lifecycle.ts b/apps/xtream-mock-server/src/app/server-lifecycle.ts new file mode 100644 index 000000000..28ab336d8 --- /dev/null +++ b/apps/xtream-mock-server/src/app/server-lifecycle.ts @@ -0,0 +1,55 @@ +import type { Server } from 'node:http'; + +const SHUTDOWN_DEADLINE_MS = 1_000; + +export interface XtreamMockShutdownApplication { + shutdown(): unknown; +} + +export function createXtreamMockServerShutdown( + server: Server, + application: XtreamMockShutdownApplication +): () => Promise { + let shutdownPromise: Promise | undefined; + return () => { + shutdownPromise ??= beginShutdown(server, application); + return shutdownPromise; + }; +} + +function beginShutdown( + server: Server, + application: XtreamMockShutdownApplication +): Promise { + try { + application.shutdown(); + } catch { + // HTTP shutdown must remain bounded even if an application hook fails. + } + return closeHttpServer(server); +} + +function closeHttpServer(server: Server): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(deadline); + resolve(); + }; + const deadline = setTimeout(() => { + server.closeAllConnections(); + finish(); + }, SHUTDOWN_DEADLINE_MS); + deadline.unref(); + + try { + server.close(() => finish()); + server.closeIdleConnections(); + server.closeAllConnections(); + } catch { + finish(); + } + }); +} diff --git a/apps/xtream-mock-server/src/app/server-shutdown.spec.ts b/apps/xtream-mock-server/src/app/server-shutdown.spec.ts new file mode 100644 index 000000000..c37324c85 --- /dev/null +++ b/apps/xtream-mock-server/src/app/server-shutdown.spec.ts @@ -0,0 +1,160 @@ +import { type AddressInfo } from 'node:net'; +import { createServer, type Server, type ServerResponse } from 'node:http'; +import type { PerformanceControlState } from './performance-control.js'; +import { + createXtreamMockApp, + createXtreamMockServerShutdown, +} from './server.js'; +import { + openAbortableGet, + postJson, + requestJson, + waitFor, +} from './testing/http-server.fixture.js'; + +jest.mock('@faker-js/faker', () => ({ + faker: { seed: jest.fn() }, +})); + +const TOKEN = 'shutdown-token'; +const AUTH_HEADERS = { + 'x-iptvnator-performance-token': TOKEN, +}; + +describe('Xtream mock HTTP shutdown', () => { + it.each([ + ['barrier', 'blocked'], + ['delay', 'delayed'], + ] as const)( + 'settles an active %s and releases its listener', + async (kind, lifecycleStatus) => { + const app = createXtreamMockApp({ + control: { enabled: true, token: TOKEN }, + host: '127.0.0.1', + port: 0, + }); + const server = createServer(app); + await listen(server); + const address = server.address() as AddressInfo; + const origin = `http://127.0.0.1:${address.port}`; + let heldClient: ReturnType | undefined; + + try { + await createRule(origin, kind); + heldClient = openAbortableGet(origin, catalogPath()); + await waitFor(async () => { + const snapshot = await state(origin); + expect( + snapshot.ledger.map((entry) => entry.status) + ).toContain(lifecycleStatus); + }); + + let shutdownState: PerformanceControlState | null | undefined; + const applicationShutdown = app.shutdown.bind(app); + app.shutdown = () => { + shutdownState = applicationShutdown(); + return shutdownState; + }; + const shutdown = createXtreamMockServerShutdown(server, app); + const first = shutdown(); + const second = shutdown(); + + expect(second).toBe(first); + await within(first, 2_000); + await within(heldClient.completed, 2_000); + expect(shutdownState).toMatchObject({ + heldIds: [], + heldCount: 0, + rules: [], + occurrences: [], + ledger: [], + }); + + const rebound = createServer( + (_request, response: ServerResponse) => response.end('ok') + ); + try { + await listen(rebound, address.port); + } finally { + await close(rebound); + } + } finally { + heldClient?.abort(); + server.closeAllConnections(); + if (server.listening) await close(server); + } + } + ); +}); + +async function createRule( + origin: string, + kind: 'barrier' | 'delay' +): Promise { + const result = await postJson( + origin, + kind === 'barrier' ? '/__control/barriers' : '/__control/delays', + TOKEN, + { + id: `shutdown-${kind}`, + match: { + scenario: 'performance-100k', + transport: 'direct', + action: 'get_live_streams', + occurrence: 1, + categoryId: '91101', + }, + ...(kind === 'delay' ? { milliseconds: 5_000 } : {}), + } + ); + expect(result.response.status).toBe(201); +} + +async function state(origin: string): Promise { + const result = await requestJson(origin, '/__control/state', { + headers: AUTH_HEADERS, + }); + expect(result.response.status).toBe(200); + return result.body as PerformanceControlState; +} + +function catalogPath(): string { + return '/player_api.php?username=performance&password=performance&action=get_live_streams&category_id=91101'; +} + +function listen(server: Server, port = 0): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); +} + +function close(server: Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); +} + +async function within( + promise: Promise, + milliseconds: number +): Promise { + let timer: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error('operation exceeded deadline')), + milliseconds + ); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts new file mode 100644 index 000000000..a4e899137 --- /dev/null +++ b/apps/xtream-mock-server/src/app/server.spec.ts @@ -0,0 +1,225 @@ +import { connect } from 'node:net'; +import { resetAll } from './data-store.js'; +import { + createXtreamMockApp, + parseXtreamMockServerEnvironment, +} from './server.js'; +import { startLoopbackServer } from './testing/http-server.fixture.js'; + +jest.mock('@faker-js/faker', () => ({ + faker: { seed: jest.fn() }, +})); + +jest.setTimeout(60_000); + +describe('Xtream mock server factory', () => { + afterEach(() => { + resetAll(); + jest.restoreAllMocks(); + }); + + it('keeps performance controls absent unless explicitly enabled', async () => { + const running = await startLoopbackServer( + createXtreamMockApp({ host: '127.0.0.1', port: 0 }) + ); + try { + const response = await fetch(`${running.origin}/__control/state`); + expect(response.status).toBe(404); + } finally { + await running.close(); + } + }); + + it('reports the configured port and preserves direct/PWA responses', async () => { + const running = await startLoopbackServer( + createXtreamMockApp({ host: '127.0.0.1', port: 0 }) + ); + try { + const health = await fetch(`${running.origin}/health`).then((r) => + r.json() + ); + const direct = await fetch( + `${running.origin}/player_api.php?username=performance&password=performance&action=get_live_categories` + ); + const directBody = await direct.json(); + const proxy = await fetch( + `${running.origin}/xtream?url=http%3A%2F%2Fignored.invalid&username=performance&password=performance&action=get_live_categories` + ); + const proxyBody = await proxy.json(); + + expect(health).toEqual({ + status: 'ok', + server: 'xtream-mock-server', + port: 0, + }); + expect(direct.status).toBe(200); + expect(directBody).toHaveLength(60); + expect(proxy.status).toBe(200); + expect(proxyBody).toEqual({ + payload: directBody, + action: 'get_live_categories', + }); + } finally { + await running.close(); + } + }); + + it('preserves the basic M3U fixture and ordinary stream redirects', async () => { + const running = await startLoopbackServer( + createXtreamMockApp({ host: '127.0.0.1', port: 0 }) + ); + try { + const playlist = await fetch(`${running.origin}/playlist.m3u`); + const stream = await fetch( + `${running.origin}/live/user1/pass1/10000.m3u8`, + { redirect: 'manual' } + ); + + expect(playlist.status).toBe(200); + expect(playlist.headers.get('content-type')).toContain( + 'audio/x-mpegurl' + ); + expect(await playlist.text()).toContain('#EXTM3U'); + expect(stream.status).toBe(302); + expect(stream.headers.get('location')).toBe( + 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8' + ); + } finally { + await running.close(); + } + }); + + it('rejects non-loopback performance binds before opening a listener', () => { + expect(() => + createXtreamMockApp({ + control: { enabled: true, token: 'test-token' }, + host: '0.0.0.0', + port: 0, + }) + ).toThrow(/loopback/i); + }); + + it('makes local-only performance media routes terminal without outbound requests', async () => { + const running = await startLoopbackServer( + createXtreamMockApp({ + control: { enabled: true, token: 'test-token' }, + host: '127.0.0.1', + port: 0, + }) + ); + try { + const httpModule = + jest.requireActual('node:http'); + const httpsModule = + jest.requireActual('node:https'); + const httpRequest = jest.spyOn(httpModule, 'request'); + const httpsRequest = jest.spyOn(httpsModule, 'request'); + const globalFetch = jest.spyOn(globalThis, 'fetch'); + const mediaResponses = await Promise.all( + [ + '/playlist.m3u', + '/live/performance/performance/1000000.m3u8', + '/live/performance/performance/1000000.ts', + '/movie/performance/performance/2000000.mkv', + '/series/performance/performance/3000000.mkv', + '/timeshift/performance/performance/60/start/1000000.ts', + '/streaming/timeshift.php?username=performance&password=performance', + ].map((path) => rawLoopbackGet(running.origin, path)) + ); + + expect(mediaResponses.map(({ status }) => status)).toEqual( + Array(mediaResponses.length).fill(410) + ); + expect( + mediaResponses.every( + ({ headers }) => !headers.includes('location:') + ) + ).toBe(true); + expect(httpRequest).not.toHaveBeenCalled(); + expect(httpsRequest).not.toHaveBeenCalled(); + expect(globalFetch).not.toHaveBeenCalled(); + } finally { + await running.close(); + } + }); +}); + +describe('Xtream mock environment parsing', () => { + it('uses safe defaults and enables control only for the exact flag', () => { + expect(parseXtreamMockServerEnvironment({})).toEqual({ + host: '0.0.0.0', + port: 3211, + }); + expect( + parseXtreamMockServerEnvironment({ + IPTVNATOR_XTREAM_MOCK_CONTROL: '1', + IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'secret-token', + }) + ).toEqual({ + control: { enabled: true, token: 'secret-token' }, + host: '127.0.0.1', + port: 3211, + }); + expect( + parseXtreamMockServerEnvironment({ + HOST: '::1', + PORT: '4321', + IPTVNATOR_XTREAM_MOCK_CONTROL: '1', + IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'secret-token', + }) + ).toEqual({ + control: { enabled: true, token: 'secret-token' }, + host: '::1', + port: 4321, + }); + expect( + parseXtreamMockServerEnvironment({ + IPTVNATOR_XTREAM_MOCK_CONTROL: 'true', + IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'ignored', + }) + ).toEqual({ host: '0.0.0.0', port: 3211 }); + }); + + it.each([ + [{ PORT: '-1' }, /port/i], + [{ PORT: '12x' }, /port/i], + [{ PORT: '65536' }, /port/i], + [{ IPTVNATOR_XTREAM_MOCK_CONTROL: '1' }, /token/i], + [ + { + HOST: '192.0.2.1', + IPTVNATOR_XTREAM_MOCK_CONTROL: '1', + IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'secret-token', + }, + /loopback/i, + ], + ])('rejects invalid environment %#', (environment, message) => { + expect(() => parseXtreamMockServerEnvironment(environment)).toThrow( + message + ); + }); +}); + +async function rawLoopbackGet( + origin: string, + path: string +): Promise<{ headers: string; status: number }> { + const url = new URL(origin); + return new Promise((resolve, reject) => { + const socket = connect(Number(url.port), url.hostname); + let raw = ''; + socket.setEncoding('utf8'); + socket.once('error', reject); + socket.on('data', (chunk) => (raw += chunk)); + socket.once('end', () => { + const [headers = ''] = raw.split('\r\n\r\n'); + const status = Number(headers.split(' ')[1]); + resolve({ headers: headers.toLowerCase(), status }); + }); + socket.once('connect', () => { + socket.write( + `GET ${path} HTTP/1.1\r\nHost: ${url.host}\r\nConnection: close\r\n\r\n` + ); + }); + }); +} diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts new file mode 100644 index 000000000..a5aebc464 --- /dev/null +++ b/apps/xtream-mock-server/src/app/server.ts @@ -0,0 +1,274 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import cors from 'cors'; +import express, { + type NextFunction, + type Request, + type Response, +} from 'express'; +import { resetAll } from './data-store.js'; +import { renderMarketingAssetSvg } from './generators/marketing.generator.js'; +import { installPerformanceControlRoutes } from './performance-control-routes.js'; +import { + type PerformanceControlState, + XtreamPerformanceController, +} from './performance-control.js'; +import { dispatchAction } from './routes/dispatch.js'; + +export { createXtreamMockServerShutdown } from './server-lifecycle.js'; + +export interface XtreamMockServerOptions { + readonly control?: { + readonly enabled: boolean; + readonly token: string; + }; + readonly host: string; + readonly port: number; +} + +export interface XtreamMockApplication extends express.Express { + shutdown(): PerformanceControlState | null; +} + +const M3U_FIXTURE = `#EXTM3U +#EXTINF:0 tvg-id="1" tvg-logo="http://channel.icons.url/img/1.png" group-title="News", Channel 1 +https://example.channels/path-to-file/1.m3u8 +#EXTINF:0 tvg-id="2" tvg-logo="http://channel.icons.url/img/2.png" group-title="News", Positive News TV +https://example.channels/path-to-file/2.m3u8 +#EXTINF:0 tvg-id="3" tvg-logo="http://channel.icons.url/img/3.png" group-title="Sport", Sport TVX +https://example.channels/path-to-file/3.m3u8 +#EXTINF:0 tvg-id="4" tvg-logo="http://channel.icons.url/img/4.png" group-title="Kids", HappyKids TV +https://example.channels/path-to-file/4.m3u8 +`; +const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; +const DEFAULT_PORT = 3211; +const DEFAULT_NORMAL_HOST = '0.0.0.0'; +const DEFAULT_CONTROL_HOST = '127.0.0.1'; +const PERFORMANCE_USERNAME = 'performance'; +const PERFORMANCE_PASSWORD = 'performance'; +const marketingRasterAssetRoot = join( + process.cwd(), + 'apps/xtream-mock-server/public/marketing' +); + +export function createXtreamMockApp( + options: XtreamMockServerOptions +): XtreamMockApplication { + validateServerOptions(options); + const app = express() as XtreamMockApplication; + const controlEnabled = options.control?.enabled === true; + const controller = controlEnabled + ? new XtreamPerformanceController() + : undefined; + app.shutdown = () => controller?.shutdown() ?? null; + + if (controller && options.control) { + installPerformanceControlRoutes(app, controller, options.control.token); + } + app.use(cors()); + app.use(express.json()); + + app.get('/health', (_request, response) => { + response.json({ + status: 'ok', + server: 'xtream-mock-server', + port: options.port, + }); + }); + app.post('/reset', (_request, response) => { + if (controlEnabled) { + response + .status(410) + .json({ error: 'use-performance-control-reset' }); + return; + } + resetAll(); + response.json({ status: 'reset' }); + }); + app.get('/playlist.m3u', (_request, response) => { + if (controlEnabled) { + response.status(410).json({ error: 'performance-media-disabled' }); + return; + } + response + .type('audio/x-mpegurl') + .set('Cache-Control', 'no-store') + .send(M3U_FIXTURE); + }); + installMarketingAssetRoute(app); + + app.get('/player_api.php', (request, response, next) => { + dispatchWithControl(controller, request, response, next, 'direct', () => + dispatchAction(request, response) + ); + }); + installStreamRoutes(app, controlEnabled); + app.get('/xtream', (request, response, next) => { + dispatchWithControl(controller, request, response, next, 'proxy', () => + dispatchProxyAction(request, response) + ); + }); + + return app; +} + +export function parseXtreamMockServerEnvironment( + environment: NodeJS.ProcessEnv +): XtreamMockServerOptions { + const rawPort = environment['PORT'] ?? String(DEFAULT_PORT); + if (!/^\d+$/.test(rawPort)) { + throw new Error('Xtream mock port must be an integer'); + } + const port = Number(rawPort); + const controlEnabled = environment['IPTVNATOR_XTREAM_MOCK_CONTROL'] === '1'; + const host = + environment['HOST'] ?? + (controlEnabled ? DEFAULT_CONTROL_HOST : DEFAULT_NORMAL_HOST); + const token = environment['IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN'] ?? ''; + const options: XtreamMockServerOptions = { + ...(controlEnabled + ? { control: { enabled: true, token } as const } + : {}), + host, + port, + }; + validateServerOptions(options); + return options; +} + +function validateServerOptions(options: XtreamMockServerOptions): void { + if ( + !Number.isInteger(options.port) || + options.port < 0 || + options.port > 65_535 + ) { + throw new Error('Xtream mock port must be in 0..65535'); + } + if (!options.host) throw new Error('Xtream mock host is required'); + if (!options.control?.enabled) return; + if (options.control.token.trim().length === 0) { + throw new Error('Xtream performance control token is required'); + } + if (options.host !== '127.0.0.1' && options.host !== '::1') { + throw new Error( + 'Xtream performance control requires a loopback bind host' + ); + } +} + +function dispatchWithControl( + controller: XtreamPerformanceController | undefined, + request: Request, + response: Response, + next: NextFunction, + transport: 'direct' | 'proxy', + dispatch: () => void +): void { + if (!controller) { + dispatch(); + return; + } + void controller + .intercept(request, response, transport, dispatch) + .catch(next); +} + +function dispatchProxyAction(request: Request, response: Response): void { + const { url, action, ...rest } = request.query as Record; + void url; + const syntheticRequest = { + query: { action, ...rest }, + headers: request.headers, + params: {}, + } as unknown as Request; + let payload: unknown; + let statusCode = 200; + const syntheticResponse = { + json(data: unknown) { + payload = data; + return this; + }, + status(code: number) { + statusCode = code; + return this; + }, + } as unknown as Response; + + dispatchAction(syntheticRequest, syntheticResponse); + if (statusCode !== 200) { + response.status(statusCode).json({ error: payload }); + return; + } + response.json({ payload, action }); +} + +function installMarketingAssetRoute(app: express.Express): void { + app.get('/assets/marketing/:kind/:slug', (request, response) => { + const kind = request.params['kind'] as + 'backdrop' | 'episode' | 'logo' | 'poster' | 'season'; + const slug = request.params['slug'] ?? ''; + const size = + typeof request.query['size'] === 'string' + ? request.query['size'] + : undefined; + if ( + !['backdrop', 'episode', 'logo', 'poster', 'season'].includes(kind) + ) { + response.status(404).send('Unknown marketing asset kind'); + return; + } + const rasterSlug = slug.replace(/\.(svg|png)$/i, ''); + const rasterPath = join( + marketingRasterAssetRoot, + kind, + `${rasterSlug}.png` + ); + if (existsSync(rasterPath)) { + response + .type('image/png') + .set('Cache-Control', 'public, max-age=3600') + .send(readFileSync(rasterPath)); + return; + } + response + .type('image/svg+xml') + .set('Cache-Control', 'public, max-age=3600') + .send(renderMarketingAssetSvg(kind, slug, size)); + }); +} + +function installStreamRoutes( + app: express.Express, + controlEnabled: boolean +): void { + const streamResponse = (request: Request, response: Response) => { + if (isPerformanceMediaRequest(request, controlEnabled)) { + response.status(410).json({ error: 'performance-media-disabled' }); + return; + } + response.redirect(HLS_STUB); + }; + app.get('/live/:username/:password/:streamId.m3u8', streamResponse); + app.get('/live/:username/:password/:streamId.ts', streamResponse); + app.get('/movie/:username/:password/:streamId.:ext', streamResponse); + app.get('/series/:username/:password/:streamId.:ext', streamResponse); + app.all( + '/timeshift/:username/:password/:duration/:start/:streamId.ts', + streamResponse + ); + app.all('/streaming/timeshift.php', streamResponse); +} + +function isPerformanceMediaRequest( + request: Request, + controlEnabled: boolean +): boolean { + if (!controlEnabled) return false; + const username = + request.params['username'] ?? request.query['username'] ?? ''; + const password = + request.params['password'] ?? request.query['password'] ?? ''; + return ( + username === PERFORMANCE_USERNAME && password === PERFORMANCE_PASSWORD + ); +} diff --git a/apps/xtream-mock-server/src/app/testing/http-server.fixture.ts b/apps/xtream-mock-server/src/app/testing/http-server.fixture.ts new file mode 100644 index 000000000..6629f1a53 --- /dev/null +++ b/apps/xtream-mock-server/src/app/testing/http-server.fixture.ts @@ -0,0 +1,126 @@ +import { type AddressInfo } from 'node:net'; +import { + createServer, + get as httpGet, + type Server, + type ServerResponse, +} from 'node:http'; +import type express from 'express'; + +export interface RunningTestServer { + readonly origin: string; + close(): Promise; +} + +export interface AbortableRequest { + readonly completed: Promise; + abort(): void; +} + +export async function startLoopbackServer( + app: express.Express +): Promise { + const server = createServer(app); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address() as AddressInfo; + + return { + origin: `http://127.0.0.1:${address.port}`, + close: () => closeServer(server), + }; +} + +export async function requestJson( + origin: string, + path: string, + init: RequestInit = {} +): Promise<{ body: unknown; response: Response }> { + const response = await fetch(`${origin}${path}`, init); + const body = await response.json(); + return { body, response }; +} + +export async function postJson( + origin: string, + path: string, + token: string, + body?: unknown +): Promise<{ body: unknown; response: Response }> { + return requestJson(origin, path, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-iptvnator-performance-token': token, + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +export function openAbortableGet( + origin: string, + path: string, + headers: Record = {} +): AbortableRequest { + let settled = false; + let settle!: () => void; + const completed = new Promise((resolve) => { + settle = resolve; + }); + const request = httpGet(`${origin}${path}`, { headers }, (response) => { + drain(response, () => { + settled = true; + settle(); + }); + }); + request.once('error', () => { + settled = true; + settle(); + }); + + return { + completed, + abort() { + if (!settled) request.destroy(); + }, + }; +} + +export async function waitFor( + assertion: () => Promise, + timeoutMs = 2_000 +): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + await assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + } + throw lastError; +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }); +} + +function drain( + response: ServerResponse | NodeJS.ReadableStream, + done: () => void +) { + response.on('data', () => undefined); + response.once('end', done); + response.once('error', done); +} diff --git a/apps/xtream-mock-server/src/main.ts b/apps/xtream-mock-server/src/main.ts index 648a15c34..fbfaf3ed9 100644 --- a/apps/xtream-mock-server/src/main.ts +++ b/apps/xtream-mock-server/src/main.ts @@ -1,201 +1,73 @@ -import http from 'http'; -import { existsSync, readFileSync } from 'fs'; -import { join } from 'path'; -import express from 'express'; -import cors from 'cors'; -import { dispatchAction } from './app/routes/dispatch.js'; -import { resetAll } from './app/data-store.js'; -import { renderMarketingAssetSvg } from './app/generators/marketing.generator.js'; +import { createServer } from 'node:http'; +import { + createXtreamMockServerShutdown, + createXtreamMockApp, + parseXtreamMockServerEnvironment, +} from './app/server.js'; -const app = express(); -const PORT = parseInt(process.env['PORT'] ?? '3211', 10); -const M3U_FIXTURE = `#EXTM3U -#EXTINF:0 tvg-id="1" tvg-logo="http://channel.icons.url/img/1.png" group-title="News", Channel 1 -https://example.channels/path-to-file/1.m3u8 -#EXTINF:0 tvg-id="2" tvg-logo="http://channel.icons.url/img/2.png" group-title="News", Positive News TV -https://example.channels/path-to-file/2.m3u8 -#EXTINF:0 tvg-id="3" tvg-logo="http://channel.icons.url/img/3.png" group-title="Sport", Sport TVX -https://example.channels/path-to-file/3.m3u8 -#EXTINF:0 tvg-id="4" tvg-logo="http://channel.icons.url/img/4.png" group-title="Kids", HappyKids TV -https://example.channels/path-to-file/4.m3u8 -`; -const marketingRasterAssetRoot = join( - process.cwd(), - 'apps/xtream-mock-server/public/marketing' -); - -app.use(cors()); -app.use(express.json()); - -// ─── Health check ────────────────────────────────────────────────────────────── -app.get('/health', (_req, res) => { - res.json({ status: 'ok', server: 'xtream-mock-server', port: PORT }); -}); - -// ─── Reset all cached data ───────────────────────────────────────────────────── -app.post('/reset', (_req, res) => { - resetAll(); - res.json({ status: 'reset' }); -}); - -// ─── M3U fixture endpoint for self-hosted PWA URL import tests ─────────────── -app.get('/playlist.m3u', (_req, res) => { - res.type('audio/x-mpegurl') - .set('Cache-Control', 'no-store') - .send(M3U_FIXTURE); -}); - -// ─── Local fictional artwork for release screenshots ────────────────────────── -app.get('/assets/marketing/:kind/:slug', (req, res) => { - const kind = req.params['kind'] as - | 'backdrop' - | 'episode' - | 'logo' - | 'poster' - | 'season'; - const slug = req.params['slug'] ?? ''; - const size = - typeof req.query['size'] === 'string' ? req.query['size'] : undefined; - - if (!['backdrop', 'episode', 'logo', 'poster', 'season'].includes(kind)) { - res.status(404).send('Unknown marketing asset kind'); - return; +function start(): void { + let options; + try { + options = parseXtreamMockServerEnvironment(process.env); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Invalid configuration'; + console.error(`[xtream-mock] ${message}`); + process.exit(1); } - const rasterSlug = slug.replace(/\.(svg|png)$/i, ''); - const rasterPath = join( - marketingRasterAssetRoot, - kind, - `${rasterSlug}.png` - ); + const application = createXtreamMockApp(options); + const server = createServer(application); + const shutdownServer = createXtreamMockServerShutdown(server, application); + const displayHost = options.host === '::1' ? '[::1]' : options.host; + let shuttingDown = false; - if (existsSync(rasterPath)) { - res.type('image/png') - .set('Cache-Control', 'public, max-age=3600') - .send(readFileSync(rasterPath)); - return; - } + server.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + console.error( + `[xtream-mock] Port ${options.port} is already in use.` + ); + } else { + console.error('[xtream-mock] Server error:', error.message); + } + process.exit(1); + }); - res.type('image/svg+xml') - .set('Cache-Control', 'public, max-age=3600') - .send(renderMarketingAssetSvg(kind, slug, size)); -}); + server.listen(options.port, options.host, () => { + const origin = `http://${displayHost}:${options.port}`; + console.log(`[xtream-mock] Listening on ${origin}`); + console.log(`[xtream-mock] Direct API: ${origin}/player_api.php`); + console.log(`[xtream-mock] PWA proxy: ${origin}/xtream`); + console.log(`[xtream-mock] Health: ${origin}/health`); + if (options.control?.enabled) { + console.log('[xtream-mock] Performance control enabled'); + } + }); -// ─── Direct Xtream player_api.php endpoint ───────────────────────────────────── -app.get('/player_api.php', dispatchAction); + const shutdown = () => { + if (!shuttingDown) { + shuttingDown = true; + console.log('\n[xtream-mock] Shutting down...'); + } + void shutdownServer().then(() => process.exit(0)); + }; -// ─── Stream stub endpoints (Xtream stream URLs reference these) ──────────────── -// Returns a valid HLS playlist redirect for any stream type. -// In production these would be actual video streams. -const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + process.on('uncaughtException', (error) => { + console.error('[xtream-mock] Uncaught exception:', error.message); + process.exit(1); + }); + process.on('unhandledRejection', (reason) => { + const message = + reason instanceof Error ? reason.message : 'Unknown rejection'; + console.error('[xtream-mock] Unhandled rejection:', message); + process.exit(1); + }); + process.stdin.resume(); + process.stdin.on('end', () => { + /* The HTTP server handle intentionally keeps this process alive. */ + }); +} -app.get('/live/:username/:password/:streamId.m3u8', (_req, res) => { - res.redirect(HLS_STUB); -}); - -app.get('/live/:username/:password/:streamId.ts', (_req, res) => { - res.redirect(HLS_STUB); -}); - -app.get('/movie/:username/:password/:streamId.:ext', (_req, res) => { - res.redirect(HLS_STUB); -}); - -app.get('/series/:username/:password/:streamId.:ext', (_req, res) => { - res.redirect(HLS_STUB); -}); - -app.all( - '/timeshift/:username/:password/:duration/:start/:streamId.ts', - (_req, res) => { - res.redirect(HLS_STUB); - } -); - -app.all('/streaming/timeshift.php', (_req, res) => { - res.redirect(HLS_STUB); -}); - -// ─── PWA CORS proxy endpoint ──────────────────────────────────────────────────── -// IPTVnator PWA routes Xtream calls through: -// GET /xtream?url=&action=&username=X&password=Y -// and expects: { payload: , action: } -app.get('/xtream', async (req, res) => { - const { url: _url, action, ...rest } = req.query as Record; - - const syntheticReq = { - query: { action, ...rest }, - headers: req.headers, - params: {}, - } as unknown as express.Request; - - // Capture response via monkey-patched json method - let payload: unknown; - let statusCode = 200; - - const syntheticRes = { - json(data: unknown) { - payload = data; - return this; - }, - status(code: number) { - statusCode = code; - return this; - }, - } as unknown as express.Response; - - dispatchAction(syntheticReq, syntheticRes); - - if (statusCode !== 200) { - res.status(statusCode).json({ error: payload }); - return; - } - - res.json({ payload, action }); -}); - -// ─── Server lifecycle ────────────────────────────────────────────────────────── -const server = http.createServer(app); - -server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - console.error(`[xtream-mock] Port ${PORT} is already in use.`); - } else { - console.error('[xtream-mock] Server error:', err.message); - } - process.exit(1); -}); - -server.listen(PORT, () => { - console.log(`[xtream-mock] Listening on http://localhost:${PORT}`); - console.log( - `[xtream-mock] Direct API: http://localhost:${PORT}/player_api.php?username=user1&password=pass1&action=get_account_info` - ); - console.log( - `[xtream-mock] PWA proxy: http://localhost:${PORT}/xtream?url=http://localhost:${PORT}&username=user1&password=pass1&action=get_account_info` - ); - console.log(`[xtream-mock] Health: http://localhost:${PORT}/health`); -}); - -const shutdown = () => { - console.log('\n[xtream-mock] Shutting down...'); - server.close(() => process.exit(0)); -}; - -process.on('SIGINT', shutdown); -process.on('SIGTERM', shutdown); -process.on('uncaughtException', (err) => { - console.error('[xtream-mock] Uncaught exception:', err); - process.exit(1); -}); -process.on('unhandledRejection', (reason) => { - console.error('[xtream-mock] Unhandled rejection:', reason); - process.exit(1); -}); - -// When Nx (or any process manager) closes stdin, prevent auto-exit. -// The HTTP server handle is what keeps the process alive. -process.stdin.resume(); -process.stdin.on('end', () => { - /* ignore stdin close */ -}); +start(); diff --git a/apps/xtream-mock-server/tsconfig.spec.json b/apps/xtream-mock-server/tsconfig.spec.json new file mode 100644 index 000000000..f1a044453 --- /dev/null +++ b/apps/xtream-mock-server/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"], + "strict": true + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index 56504eb66..f8006d8e6 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -172,6 +172,54 @@ explicit sum is `ipcStructuredCloneProxyMs`. See [M3U Playlist Module Architecture](./m3u-playlist-module.md#initial-url-import-performance-benchmark-electron) for the complete cross-process attribution. +The same request envelope exposes count-only Xtream database phases for these +operations: + +- `DB_GET_CATEGORIES`: `sqlite.categories.read` +- `DB_SAVE_CATEGORIES`: `normalize.categories`, then + `sqlite.categories.write-transactions` +- `DB_GET_CONTENT`: `sqlite.content.read` +- `DB_SAVE_CONTENT`: `sqlite.content.category-map-read`, then + `normalize.content`, then `sqlite.content.write-transactions` +- `DB_CLEAR_XTREAM_IMPORT_CACHE`: + `sqlite.xtream-cache-clear.write-transactions` +- `DB_SEARCH_CONTENT`: `sqlite.search.query`, then `normalize.search-rank` +- `DB_DELETE_XTREAM_CONTENT`: + `sqlite.xtream-delete.collect-user-data`, then + `sqlite.xtream-delete.write-transactions` +- `DB_DELETE_PLAYLIST`: `sqlite.playlist-delete.collect-ids`, then + `sqlite.playlist-delete.write-transactions` + +Read and query phases cover the exact awaited Drizzle query. Normalization +phases cover the existing synchronous transforms. The content-write phase is +one aggregate pair around every existing 100-row transaction, cancellation +checkpoint, and progress callback; it is not an exact measurement of SQLite +commit time. Cache clear similarly uses one aggregate pair around all existing +content and category chunk loops and transactions, including their JavaScript +and autocommit overhead. A zero-category cache clear still emits one pair with +`itemCount: 0` and performs no extra SQL. + +The Xtream-delete collection span includes its existing ordered category, +favorite, recently-viewed, and content-ID work. Its `itemCount` deliberately +counts only content and category deletion candidates; favorite, +recently-viewed, and hidden-category user data is timed but is not added to +that count. The matching write count uses the same deletion-candidate +definition. Playlist-delete collection counts every collected favorite, +recently-viewed, playback-position, download, content, and category ID. Its +write count adds the final playlist row. Both write spans include every +existing cooperative checkpoint, 100-row transaction, progress callback, and, +for playlist deletion, the final playlist-row autocommit; they are not exact +SQLite commit-time measurements. + +Successful end markers carry only row/item counts. Error or cancellation still +closes the active phase without metadata and preserves the original error. +Disabled profiling passes no adapter into the operations, so it performs no +phase-event or metadata-callback allocation. Worker concurrency, SQL, chunk +sizes, transaction boundaries, progress ordering, and cancellation checkpoints +are unchanged. `DB_GLOBAL_SEARCH` is deliberately not instrumented: it +interleaves queries and ranking across sources, so these single-query phases +would be misleading. + Formal initial-import comparison also requires both request-scoped captures in every measured run to have coherent event-loop delay, event-loop utilization, and worker-thread CPU values with no unavailable or invalid reason. Summary @@ -266,6 +314,15 @@ If a worker operation is canceled: Cancellation is cooperative and chunk-based. Already committed SQLite batches stay committed. +With the exact `IPTVNATOR_PERF_WORKER_PROFILING=1` opt-in, receipt of a cancel +for a correlated active request also emits a +`performance-cancel-received` worker diagnostic containing only safe +operation/request IDs and its epoch. The functional cancellation flag is set +first, this receipt remains distinct from the later authoritative +`cancelled` event, and `DatabaseWorkerClient` ignores it without settling or +exposing the pending request. Disabled profiling performs no receipt clock or +transport work, and `DatabaseWorkerClient.cancel()` remains fire-and-return. + ## Renderer Contract The preload bridge keeps the existing database methods but adds scoped worker diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index 6a96fd7b0..21450390d 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -6,7 +6,9 @@ Xtream Codes API protocol. It is used for: - **Local development** — run a full portal without a real Xtream subscription -- **E2E testing** — Playwright spins it up alongside the Angular dev server +- **E2E testing** — Playwright uses it for both web and Electron workflows +- **Performance investigation** — a locked-down loopback control plane + prepares and identifies a fixed synthetic 100k catalog before capture --- @@ -20,6 +22,9 @@ credentialsToSeed(u, p) ←── deterministic polynomial hash │ ▼ faker.seed(seed) ←── all faker calls use same seed per credentials + │ + ├── performance:performance? + │ └── index-derived generator (no Faker/runtime clock) │ ▼ generateCategories() ←── live / vod / series categories @@ -43,19 +48,28 @@ Re-requesting with the same credentials returns the exact same data until ``` apps/xtream-mock-server/ -├── project.json ← Nx targets: serve (port 3211), serve-with-watch +├── project.json ← Nx serve, watch, lint, and test targets ├── public/ │ └── marketing/ ← committed fictional release artwork PNGs ├── tsconfig.json └── src/ - ├── main.ts ← Express app bootstrap, all routes wired up + ├── main.ts ← env validation, HTTP lifecycle, safe logging └── app/ + ├── server.ts ← side-effect-free Express app factory/routes + ├── server-lifecycle.ts ← bounded, idempotent HTTP shutdown ├── scenarios.ts ← Credential → ScenarioConfig mapping ├── data-store.ts ← Lazy cache, per-credentials generation + ├── performance-control.ts ← bounded request lifecycle controller + ├── performance-interception-lifecycle.ts ← reset/shutdown generation + ├── performance-control-routes.ts ← token-gated control HTTP API + ├── performance-control-validation.ts ← strict request validation + ├── performance-control.types.ts ← safe manifest/state contracts + ├── performance-manifest.ts ← fixed-order catalog hash/counts ├── generators/ │ ├── categories.generator.ts │ ├── live.generator.ts ← Live streams + EPG listings │ ├── marketing.generator.ts ← Fictional release screenshot fixture + │ ├── performance.generator.ts ← local-only deterministic 100k fixture │ ├── vod.generator.ts ← VOD streams + VodDetails │ └── series.generator.ts ← Series items + SeriesInfo ├── handlers/ @@ -95,6 +109,134 @@ Response: `{ payload: , action: }` This mirrors the backend proxy in `apps/electron-backend` so the same Angular service code works in both environments. +### Performance control endpoint + +The control plane is absent by default. It is mounted only when +`IPTVNATOR_XTREAM_MOCK_CONTROL=1`; startup then requires a non-empty +`IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN` and a literal loopback `HOST` +(`127.0.0.1` or `::1`). Every `/__control/*` request must carry that exact value +in `x-iptvnator-performance-token`, including `OPTIONS` preflight requests. +Configuration is validated before the HTTP listener opens. Normal development +mode preserves the legacy wildcard bind when `HOST` is unset; control mode +instead defaults to `127.0.0.1` and rejects an explicitly configured +non-loopback host. The Nx serve targets do not pin `PORT`, so an explicit shell +value reaches the parser; its no-value default remains `3211`. + +Use a dedicated port rather than the normal `3211` E2E server: + +```bash +HOST=127.0.0.1 \ +PORT=3221 \ +IPTVNATOR_XTREAM_MOCK_CONTROL=1 \ +IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN=local-benchmark-token \ +pnpm nx run xtream-mock-server:serve +``` + +The strict control API is: + +```text +POST /__control/prepare +POST /__control/reset +POST /__control/barriers +POST /__control/barriers/:id/release +POST /__control/delays +GET /__control/state +``` + +`prepare` accepts exactly `{"scenario":"performance-100k"}` and materializes +the fixed `performance:performance` fixture before application capture. It +returns only this manifest: + +```json +{ + "epoch": 1, + "scenario": "performance-100k", + "seed": 91001, + "counts": { + "categories": { "live": 60, "vod": 20, "series": 20, "total": 100 }, + "items": { + "live": 60000, + "vod": 20000, + "series": 20000, + "total": 100000 + } + }, + "bytes": 123, + "catalogSha256": "64-lowercase-hex-characters" +} +``` + +`bytes` is the UTF-8 byte length and `catalogSha256` is the SHA-256 of one JSON +object whose property order is fixed as: + +1. live categories +2. VOD categories +3. series categories +4. live catalog +5. VOD catalog +6. series catalog + +Credentials, server origins, response envelopes, and EPG/detail caches are not +part of the hash input. + +`reset` accepts exactly `{"mode":"observations"}` or `{"mode":"all"}`. +Observation reset clears active/held rules, per-identity occurrences, and the +ledger while retaining the prepared manifest and epoch. All-state reset also +calls the data-store reset, clears the manifest, and increments the epoch. +Either mode first advances an internal observation generation and detaches all +pre-reset response listeners, so a late `finish` or `close` cannot repopulate +the freshly cleared ledger. Held barrier/delay clients retain the safe `409` +reset response where possible; unmatched active responses are aborted. + +Barrier and delay rules have an ID plus the exact match tuple: + +```text +(epoch, scenario, transport, canonicalAction, categoryId, occurrence) +``` + +`transport` is `direct` or `proxy`. Empty actions canonicalize to +`get_account_info`; the dispatcher's legacy `get_simple_date_table` alias is +allowlisted. Occurrences are counted per tuple excluding occurrence, so +parallel requests for different categories cannot consume each other's rule. +Numeric category IDs must use canonical decimal spelling. Signs, whitespace, +leading zeroes, non-decimal notation, unsafe integers, and values outside the +closed scenario ranges are rejected; invalid incoming aliases collapse to the +single `all` observation identity and cannot expand the bounded state map. +Rules are one-shot and match before dispatch or JSON serialization. Barrier +lifecycle is `arrived → blocked → responded|aborted`; delay lifecycle is +`arrived → delayed → responded|aborted`. Real request abort events release held +state; a normally consumed request's `close` event is not treated as an abort. + +The state response contains only epoch, the safe manifest, active rules, held +IDs/count, bounded occurrence counts, and bounded lifecycle entries with +monotonic timestamps. It never stores or returns the token, username, password, +raw URL/query, request/response payload, titles, or catalog arrays. Unknown +incoming actions are represented only as `unknown`. + +Control JSON is limited to 16 KiB. Bodies reject unknown/missing/wrong-type +fields. Each observation epoch accepts at most 32 rules, and the serialized +ledger retains at most 128 entries. Occurrence state has 512 slots: enough for +the complete closed scenario/action/category/transport identity domain without +eviction or counter restart; an unexpected overflow fails closed. IDs, +scenarios, transports, actions, categories, occurrences, and delays +(`0..5000` ms) use closed validation. Duplicate IDs/matches and already-past +occurrences fail closed. + +The legacy unauthenticated `POST /reset` remains available in normal mode. It +returns `410` while the control plane is enabled, preventing cache invalidation +that would leave the controller's prepared manifest stale. Performance runs +must use the token-authenticated `/__control/reset`. + +Barriers and delays exist for deterministic coordination and smoke tests only. +Formal performance captures must prove that both rule sets are empty and must +never add an artificial delay to the timed application path. + +`SIGINT` and `SIGTERM` use one bounded, idempotent shutdown path. The +application hook invalidates active observations, settles barriers and delays, +and clears their timers/listeners before the HTTP listener closes. The shared +HTTP helper then closes idle and active connections, so an unreleased control +rule cannot keep the mock process alive. + ### M3U fixture endpoint ``` @@ -113,8 +255,12 @@ GET /movie///. GET /series///. ``` -All redirect to a publicly available HLS test stream -(`https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8`). +In normal mode all redirect to a publicly available HLS test stream +(`https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8`). With the control plane +enabled, every stream/timeshift route for the performance credentials returns +`410` without a redirect or outbound request. `/playlist.m3u` is also `410` in +that mode. This keeps formal performance fixtures local-only; normal +control-disabled E2E behavior is unchanged. --- @@ -242,19 +388,20 @@ sometimes only respond to that misspelled action. ## Scenarios -| Key (`username:password`) | Seed | Categories | Items/cat | Account status | -| ------------------------- | ---- | ------------------------ | --------- | -------------- | -| `user1:pass1` | 1001 | 8 each | 40 | active | -| `large:large` | 9999 | 20 each | 200 | active | -| `stress:stress` | 7777 | 16 each | 120 | active | -| `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active | -| `minimal:minimal` | 3003 | 2 each | 5 | active | -| `epg:epg` | 6006 | live:2, vod:1, series:1 | 3 | active | -| `emptyvod:emptyvod` | 7007 | 2 each | 5 | active | -| `marketing:marketing` | 8020 | live:4, vod:4, series:4 | curated | active | -| `expired:expired` | 4004 | 4 each | 10 | Expired | -| `inactive:inactive` | 5005 | 4 each | 10 | Disabled | -| `` | hash | 6 each | 30 | active | +| Key (`username:password`) | Seed | Categories | Items/cat | Account status | +| ------------------------- | ----- | -------------------------- | --------- | -------------- | +| `user1:pass1` | 1001 | 8 each | 40 | active | +| `large:large` | 9999 | 20 each | 200 | active | +| `stress:stress` | 7777 | 16 each | 120 | active | +| `performance:performance` | 91001 | live:60, vod:20, series:20 | 1,000 | active | +| `series:series` | 2002 | live:3, vod:4, series:15 | 30 | active | +| `minimal:minimal` | 3003 | 2 each | 5 | active | +| `epg:epg` | 6006 | live:2, vod:1, series:1 | 3 | active | +| `emptyvod:emptyvod` | 7007 | 2 each | 5 | active | +| `marketing:marketing` | 8020 | live:4, vod:4, series:4 | curated | active | +| `expired:expired` | 4004 | 4 each | 10 | Expired | +| `inactive:inactive` | 5005 | 4 each | 10 | Disabled | +| `` | hash | 6 each | 30 | active | ### `epg:epg` fixture details @@ -296,6 +443,91 @@ This scenario is reserved for release screenshots and marketing materials: - the SVG renderer in `marketing.generator.ts` remains the fallback for missing assets, live logos, season covers, and episode thumbnails +### `performance:performance` fixture details + +This scenario is reserved for local performance captures: + +- exactly 60 live categories/60,000 channels, 20 VOD categories/20,000 movies, + and 20 series categories/20,000 series +- exactly 1,000 catalog items in every category +- fixed IDs, timestamps, ratings, names, and one lazy season/episode generated + from indexes rather than Faker, `Date.now()`, or `Math.random()` +- empty/local-only artwork and direct-source fields +- VOD and series detail records are materialized only on request +- preparation and manifest hashing happen before the timed application request + +### Electron Xtream performance benchmark + +Run a formal benchmark from a clean worktree with the standard CDP endpoint +available at `127.0.0.1:9222`: + +```bash +perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-xtream" +IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \ +IPTVNATOR_PERF_VARIANT=baseline \ +NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false \ +pnpm nx run electron-backend-e2e:benchmark-xtream --skip-nx-cache +``` + +The target starts an isolated control-enabled mock server on +`127.0.0.1:3221` and uses only the synthetic `performance:performance` +credentials and fixture. It covers initial import, refresh, deletion, +cancellation during import, and navigation/search/UI interaction during a +background operation. Every scenario has one warm-up, five measured +iterations, and one diagnostic iteration. Only the five measured iterations +of a clean formal run can have `validForComparison: true`. + +Capture setup and operation measurement are separate boundaries. The harness +first installs and arms the main, renderer, and worker capture infrastructure; +it then starts the main measurement and renderer operation window immediately +before triggering the user operation. Profiler attachment, fixture preparation, +and other setup outside that operation window are not application work. Because +`database.worker` is persistent but created lazily, the harness awaits the +read-only `window.electron.dbGetAppPlaylists()` preload call immediately after +installing main capture. Its result is discarded: this pre-arm only guarantees +that the exact worker can be profiled and happens before seed or measured +capture. + +Diagnostic profiles are evidence envelopes, not comparison totals. Starting +Chromium tracing and the CPU profilers can add a short pre-trigger setup segment +that contains no application workload; the raw boundary timestamps preserve +that segment explicitly. Formal summaries compare only the five measured runs, +never warm-up or diagnostic totals. + +CPU scopes in the summary are intentionally not interchangeable. Electron main +CPU uses `process.threadCpuUsage()` and is labeled +`electron-main-thread`. Database-worker CPU is the sum of thread CPU from valid, +non-overlapping request work and is labeled +`sum-valid-request-thread-cpu`; it is neither process-wide CPU nor a whole-worker +sample. + +Scenarios that need an existing portal seed it outside the measured iteration. +Seed completion is authoritative only after a successful +`store.xtream-import-terminal` renderer phase marker, the matching terminal DOM +gate, and two consecutive painted `requestAnimationFrame` frames. The harness +then verifies main-process settlement before rolling over to the measured +capture; merely seeing the catalog or loading overlay disappear is not a seed +terminal. + +For an end-to-end wiring check, run smoke mode with one comparison-ineligible +measured iteration per scenario. An unused loopback CDP port may be selected +when the formal port is occupied: + +```bash +perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-xtream-smoke" +IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \ +IPTVNATOR_PERF_VARIANT=smoke \ +IPTVNATOR_PERF_SMOKE=1 \ +IPTVNATOR_PERF_CDP_PORT=9322 \ +NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false \ +pnpm nx run electron-backend-e2e:benchmark-xtream --skip-nx-cache +``` + +The manifest, summary, per-iteration JSON, traces, CPU profiles, and heap +snapshots stay under the git-ignored `dist/performance/` tree. Never commit +those artifacts, and never substitute real credentials, portal URLs, or +provider data. + --- ## Playwright Integration @@ -337,4 +569,5 @@ await page.route('**/localhost:3000/xtream**', async (route) => { `OPENAI_API_KEY=... pnpm release:artwork:generate`, inspect the generated PNGs, and finish with `pnpm release:artwork:validate` - **Adjust data volume**: Change `itemsPerCategory`, `seasonsPerSeries`, or `episodesPerSeason` per scenario -- **Custom stream URLs**: Edit the HLS stub redirect in `main.ts` +- **Custom stream URLs**: Edit the normal-mode HLS stub redirect in + `app/server.ts`; never enable it for the performance fixture diff --git a/docs/superpowers/plans/2026-07-27-xtream-performance-benchmark.md b/docs/superpowers/plans/2026-07-27-xtream-performance-benchmark.md new file mode 100644 index 000000000..de9cf69ab --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-xtream-performance-benchmark.md @@ -0,0 +1,1042 @@ +# Xtream Performance Benchmark Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a reproducible, fail-closed Electron benchmark for large Xtream initial import, refresh, deletion, cancellation, and concurrent UI interaction, with process-separated profiles and count-only phase attribution, without changing normal production behavior. + +**Architecture:** A performance-only 100,000-item Xtream mock fixture is materialized and hashed before capture. An opt-in, fail-neutral instrumentation layer emits sanitized renderer, preload, Electron-main, and database-worker markers. A Playwright orchestrator runs five scenarios in fresh Electron processes with exactly one warm-up, five measured iterations, and one diagnostic iteration per scenario, writes only to the ignored `dist/performance/` tree, and rejects incomplete or contaminated measurements. The existing M3U capture implementation remains behaviorally unchanged and supplies stable process/worker primitives through small, tested extensions rather than a broad rewrite. + +**Tech Stack:** Angular 21 signals and router, Electron 41 IPC/preload, Chrome DevTools Protocol, Node `inspector`/`perf_hooks`/Worker APIs, Axios, better-sqlite3, Express, Jest, Playwright, TypeScript, Nx. + +--- + +## Non-Negotiable Measurement Contract + +- Formal runs use only a clean tracked worktree and exact CDP endpoint + `127.0.0.1:9222`. An alternate loopback CDP port is smoke-only and makes the + run ineligible for before/after claims. +- The mock binds to `127.0.0.1`, uses only the committed + `performance-100k` fixture, and rejects arbitrary provider URLs, + credentials, payloads, and cardinalities. No response field used by the + benchmark may reference a non-loopback URL. +- Every formal scenario has exactly: + `warmup-01`, `run-01` through `run-05`, and `diagnostic`. + Warm-up and diagnostic values never enter headline distributions. +- Each iteration gets a fresh Electron process and data directory. Stateful + refresh/delete/UI iterations seed the portal before capture in that same + process, verify database idleness, and atomically roll capture into the + measured operation. +- Renderer, Electron main, and `database.worker` metrics remain separate. + Main RSS is labelled process-wide and includes worker/native/SQLite memory; + worker RSS is unavailable because a Node Worker is a thread. Native/SQLite + memory is never inferred by subtracting JS heaps. +- Xtream does not instantiate `playlist-refresh.worker`. Report it as + `N/A: not-instantiated-by-xtream-flow` only after observing zero matching + workers and artifacts. Any instance invalidates the iteration. +- IPC boundary deltas are named structured-clone proxies because they include + queueing/scheduling. SQLite transaction spans include commit. Index creation + is `N/A: schema-indexes-pre-exist; operation-creates-none`. +- Cancellation acknowledgement is the terminal database `cancelled` event. + Also retain click-to-preload return, main HTTP abort, DB cancel dispatch, and + dev-only worker-receipt timestamps as separate diagnostics. +- Instrumentation is active only when `IPTVNATOR_PERF_CAPTURE=1` and/or + `IPTVNATOR_PERF_WORKER_PROFILING=1`, is count/identifier-only, never logs + credentials/URLs/titles/search text/payloads, and is fail-neutral. +- No production bottleneck fix belongs in this branch. This branch establishes + the benchmark and instrumentation required to prove a later fix. + +## Scenario Matrix + +| Scenario ID | Seed state | Measured trigger | Authoritative terminal | +| ----------------------------- | ----------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | +| `xtream-initial-import-large` | Empty DB, prepared mock | Final Add button | All three import states completed, overlay absent, expected DB counts, two painted frames | +| `xtream-refresh-large` | Complete 100k import | Refresh-confirm button | Delete/reimport/empty restore complete, exact counts, no duplicate or stale rows | +| `xtream-delete-large` | Complete 100k import | Delete-confirm button | `DB_DELETE_PLAYLIST` completed, source row absent, playlist/category/content counts zero | +| `xtream-cancel-import` | Empty DB, prepared mock | Final Add; cancel at first real live save progress threshold | Worker receipt plus authoritative DB `cancelled`, visible cancelled state painted, no later VOD/series request | +| `xtream-background-ui` | Complete 100k import | Refresh-confirm button | Fixed navigation/search/action sequence paints while DB operation is active, then normal refresh terminal | + +The fixture contains 60 live categories/items groups, 20 VOD groups, and 20 +series groups with 1,000 items each: exactly 60,000 live + 20,000 VOD + +20,000 series = 100,000 imported catalog rows and 100 categories. + +## Task 0: Reconfirm Branch, Baseline, And Safety Preconditions + +**Files:** + +- Verify only: `AGENTS.md` +- Verify only: `package.json` +- Verify only: `pnpm-lock.yaml` +- Verify only: `apps/electron-backend-e2e/project.json` + +- [ ] **Step 1: Confirm the linked worktree and branch** + +Run: + +```bash +git status --short --branch +git rev-parse --show-toplevel +git rev-parse --git-common-dir +git log -1 --oneline +``` + +Expected: branch `agent/perf-xtream-harness`, clean tracked tree, repository root +`/Users/4gray/.codex/worktrees/05cf/iptvnator`, and base commit +`24f0dee6f033613148aeab4b699e9db667041927`. + +- [ ] **Step 2: Verify dependencies and Nx discovery** + +Run: + +```bash +pnpm install --frozen-lockfile +pnpm nx show projects +pnpm nx show projects --withTarget test +pnpm nx show projects --withTarget e2e +``` + +Expected: all commands exit 0 and the project list includes +`electron-backend`, `electron-backend-e2e`, `portal-xtream-data-access`, +`portal-xtream-feature`, and `xtream-mock-server`. + +- [ ] **Step 3: Record the pre-change regression baseline** + +Run: + +```bash +pnpm nx run electron-backend-e2e:test-performance-harness --skip-nx-cache +pnpm nx test portal-xtream-data-access --skip-nx-cache +pnpm nx test portal-xtream-feature --skip-nx-cache +``` + +Expected baseline: performance harness 156/156, Xtream data-access 162 tests, +Xtream feature 144 tests. Stop and use `systematic-debugging` if the baseline +changes before editing code. + +- [ ] **Step 4: Preserve the CDP blocker as an external precondition** + +Run: + +```bash +lsof -nP -iTCP:9222 -sTCP:LISTEN || true +``` + +Do not terminate an unrelated listener without explicit user authorization. +Harness implementation and unit tests may continue; no formal M3U or Xtream +run may start while `9222` is occupied. + +## Task 1: Add A Deterministic, Local-Only 100k Xtream Fixture + +**Files:** + +- Create: `apps/xtream-mock-server/jest.config.ts` +- Create: `apps/xtream-mock-server/tsconfig.spec.json` +- Create: `apps/xtream-mock-server/src/app/generators/performance.generator.ts` +- Create: `apps/xtream-mock-server/src/app/generators/performance.generator.spec.ts` +- Modify: `apps/xtream-mock-server/project.json` +- Modify: `apps/xtream-mock-server/src/app/scenarios.ts` +- Modify: `apps/xtream-mock-server/src/app/data-store.ts` + +- [ ] **Step 1: Add the mock-server Jest target** + +Mirror the established `stalker-mock-server` Node/Jest configuration: + +```ts +export default { + displayName: 'xtream-mock-server', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { tsconfig: '/tsconfig.spec.json' }, + ], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/apps/xtream-mock-server', +}; +``` + +Add an `@nx/jest:jest` `test` target with `jestConfig` and `tsConfig` paths. + +- [ ] **Step 2: Write failing fixture identity tests** + +The tests must require: + +- scenario key `performance:performance`, name `performance-100k`, seed + `91001`; +- exactly 100 categories and 100,000 catalog items with the 60k/20k/20k split; +- unique stream/series IDs and valid category references; +- two full resets/rebuilds serialize to identical byte counts and SHA-256; +- every artwork/backdrop/stream URL field is empty or begins with the exact + supplied `http://127.0.0.1:` origin; +- the account-info call does not eagerly materialize 20,000 series-detail + objects; a detail is built lazily on `getSeriesInfo`; +- VOD/series detail caches are keyed by portal plus item ID, not global ID. + +Run and preserve the red result: + +```bash +pnpm nx test xtream-mock-server --skip-nx-cache +``` + +Expected: failure because the fixture and test target do not yet exist. + +- [ ] **Step 3: Implement the dedicated deterministic generator** + +Add this scenario shape without changing `large`, `stress`, or other existing +fixtures: + +```ts +'performance:performance': { + name: 'performance-100k', + description: 'Deterministic local-only 100k performance catalog', + seed: 91001, + categoryCount: { live: 60, vod: 20, series: 20 }, + itemsPerCategory: 1000, + seasonsPerSeries: 1, + episodesPerSeason: 1, + accountStatus: 'Active', + expiryDate: '2099-12-31', + performanceFixture: 'catalog-100k', + deferSeriesDetails: true, +}, +``` + +`performance.generator.ts` must use no Faker date clock, `Math.random`, or +`Date.now`. Derive names, ratings, timestamps, extensions, IDs, and ordering +from stable integer indices plus fixed epoch `1_767_225_600` (2026-01-01 UTC). +Use empty artwork/backdrop fields for the list payloads. Do not create stream +URLs in catalog objects. + +- [ ] **Step 4: Make performance series details lazy and cache-safe** + +Branch only the performance fixture in `data-store.ts`; leave every other +scenario's output unchanged. Use `${username}:${password}:${id}` cache keys for +VOD/series details, and skip prepopulation when `deferSeriesDetails` is true. + +- [ ] **Step 5: Run the green mock tests and lint** + +```bash +pnpm nx test xtream-mock-server --skip-nx-cache +pnpm nx lint xtream-mock-server --skip-nx-cache +``` + +Expected: all fixture identity, local-only, lazy-detail, existing mock tests, +and lint checks pass. + +## Task 2: Add A Locked-Down Mock Performance Control Plane + +**Files:** + +- Create: `apps/xtream-mock-server/src/app/performance-control.ts` +- Create: `apps/xtream-mock-server/src/app/performance-control.spec.ts` +- Create: `apps/xtream-mock-server/src/app/server.ts` +- Create: `apps/xtream-mock-server/src/app/server.spec.ts` +- Modify: `apps/xtream-mock-server/src/main.ts` +- Modify: `apps/xtream-mock-server/src/app/data-store.ts` +- Modify: `apps/xtream-mock-server/README.md` +- Modify: `docs/architecture/xtream-mock-server.md` + +- [ ] **Step 1: Write failing control-plane tests** + +Use a real loopback HTTP server on port `0`; do not add `supertest`. +Cover: + +- controls are absent unless `IPTVNATOR_XTREAM_MOCK_CONTROL=1`; +- performance mode rejects non-loopback bind hosts; +- every `/__control/*` request requires the exact per-process token header; +- JSON bodies reject unknown fields, arbitrary scenarios/cardinalities/URLs, + duplicate rules, past occurrences, and delays outside integer `0..5000`; +- `prepare` materializes `performance-100k` and returns only + `{epoch, scenario, seed, counts, bytes, catalogSha256}`; +- `reset observations` clears ledger/rules but preserves the prepared fixture; + `reset all` also clears fixture caches and increments epoch; +- request identity is + `(epoch,scenario,transport,canonicalAction,categoryId|'all',occurrence)`, + so parallel category arrival order does not affect matching; +- barrier release and request-abort both settle and remove held responses; +- ledger entries contain only safe action/scenario/count/status/timestamps and + never password, username, raw URL, query, or response payload; +- performance stream and `/playlist.m3u` endpoints return `410`, while no + non-loopback request is made. + +Run and preserve the red result: + +```bash +pnpm nx test xtream-mock-server --skip-nx-cache +``` + +- [ ] **Step 2: Extract an Express app factory** + +Move route wiring, without behavior changes, into: + +```ts +export interface XtreamMockServerOptions { + readonly control?: { + readonly enabled: boolean; + readonly token: string; + }; + readonly host: string; + readonly port: number; +} + +export function createXtreamMockApp( + options: XtreamMockServerOptions +): express.Express; +``` + +Keep `main.ts` responsible only for validated environment parsing, HTTP +startup, logging, and signal/error shutdown. Normal `serve` behavior remains +port 3211 and existing routes remain compatible. + +- [ ] **Step 3: Implement bounded observations, barriers, and delays** + +Use an exact allowlist for canonical Xtream actions. Cap ledger and rule counts. +A barrier matches before dispatch/serialization, is one-shot, and records +`arrived -> blocked -> responded|aborted`. A delay is abort-aware and exists +only for deterministic smoke/control tests; formal scenario manifests require +zero delay rules. + +- [ ] **Step 4: Add fixture preparation and manifest hashing** + +Hash stable UTF-8 JSON for the three category arrays and three catalog arrays +in a documented fixed order. The hash input contains no credentials or server +origin. Materialization happens before capture, so mock generation is excluded +from application acquisition time. + +- [ ] **Step 5: Run focused tests and docs checks** + +```bash +pnpm nx test xtream-mock-server --skip-nx-cache +pnpm nx lint xtream-mock-server --skip-nx-cache +pnpm exec prettier --check apps/xtream-mock-server/README.md docs/architecture/xtream-mock-server.md +``` + +Expected: green. Docs explain the opt-in control flag, loopback/token +requirements, fixed scenario, prepare/reset/ledger semantics, and that delays +are forbidden in formal timing. + +## Task 3: Add Sanitized Xtream IPC And Main-Process Phase Markers + +**Files:** + +- Create: `libs/shared/interfaces/src/lib/xtream-performance-marker.interface.ts` +- Create: `apps/electron-backend/src/app/api/xtream-preload-performance-capture.ts` +- Create: `apps/electron-backend/src/app/api/xtream-preload-performance-capture.spec.ts` +- Create: `apps/electron-backend/src/app/events/xtream-performance.ts` +- Create: `apps/electron-backend/src/app/events/xtream-performance.spec.ts` +- Modify: `libs/shared/interfaces/src/index.ts` +- Modify: `apps/electron-backend/src/app/api/main.preload.ts` +- Modify: `apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts` +- Modify: `apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts` +- Modify: `apps/electron-backend/src/app/events/xtream.events.ts` +- Modify: `apps/electron-backend/src/app/events/xtream.events.spec.ts` + +- [ ] **Step 1: Write failing schema, gating, redaction, and lifecycle tests** + +Target only these bridge methods: + +```text +xtreamRequest +xtreamCancelSession +dbGetCategories +dbSaveCategories +dbGetContent +dbSaveContent +dbDeleteXtreamContent +dbDeletePlaylist +dbRestoreXtreamUserData +dbSearchContent +dbGlobalSearch +dbCancelOperation +``` + +Markers may include only schema version, IPC call ID, method, boundary/outcome, +epoch, playlist/operation/session identifiers, Xtream action, content/category +type, and item count. Tests must reject or prove absence of URL, username, +password, params, titles, search term, arrays, and result bodies. Prove exact +pass-through result identity, error propagation, no marker work when disabled, +and fail-neutral behavior when the marker sink throws. + +Run the red tests: + +```bash +pnpm nx test electron-backend --skip-nx-cache --testPathPatterns='xtream-preload-performance-capture|main.preload.performance|xtream.events|xtream-performance' +``` + +- [ ] **Step 2: Implement a separate Xtream preload marker protocol** + +Do not overload `preload-performance-correlation.ts`, whose state machine is +M3U refresh-specific. Add a separate channel and schema. Extract safe identity +from arguments without cloning/traversing payload arrays. Emit start and +success/error around the existing proxied call only when +`IPTVNATOR_PERF_CAPTURE=1`. + +- [ ] **Step 3: Instrument main HTTP phases without changing normal Axios behavior** + +Emit count-only main phases: + +```text +xtream.network.total +xtream.json.transform +xtream.response.ready +xtream.cancel-session +``` + +Under capture only, wrap Axios's existing default `transformResponse` chain and +invoke the same functions with the same context. Do not switch response type or +introduce a separate manual `JSON.parse`. `network.total` contains the nested +transform; the report derives acquisition as total minus JSON transform. +Every success, error, and abort path must close its phase exactly once. + +- [ ] **Step 4: Keep debug-copy overhead explicit** + +Electron imports always create a portal-debug request ID, but performance +builds use the production environment and disable portal-debug sanitize/send +before the payload is copied. Record that work as +`N/A: production-debug-send-disabled`; do not infer that the request ID is +absent. If a future diagnostic build enables the send path, instrument +redaction/send separately and never fold that development-only copy into +production acquisition. + +`ElectronService.forwardXtreamRequest()` also posts the full returned payload +through `window.postMessage()` after the preload call completes. Keep that +second renderer clone unchanged until a baseline proves it is the selected +bottleneck; attribute it through the preload-end to store-marker gap and the +renderer CPU profile. + +- [ ] **Step 5: Run focused backend tests and lint** + +```bash +pnpm nx test electron-backend --skip-nx-cache --testPathPatterns='xtream-preload-performance-capture|main.preload.performance|xtream.events|xtream-performance' +pnpm nx lint electron-backend --skip-nx-cache +``` + +Expected: green with no changes to normal IPC payloads or Axios results. + +## Task 4: Add Database-Worker Phase And Cancellation-Receipt Markers + +**Files:** + +- Modify: `libs/shared/interfaces/src/lib/performance-phase.interface.ts` +- Modify: `apps/electron-backend/src/app/workers/worker-performance-capture.model.ts` +- Modify: `apps/electron-backend/src/app/workers/worker-performance-phase.ts` +- Modify: `apps/electron-backend/src/app/workers/database.worker.ts` +- Modify: `apps/electron-backend/src/app/database/operations/category.operations.ts` +- Modify: `apps/electron-backend/src/app/database/operations/content.operations.ts` +- Modify: `apps/electron-backend/src/app/database/operations/xtream.operations.ts` +- Modify: `apps/electron-backend/src/app/database/operations/playlist.operations.ts` +- Modify: `apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts` +- Modify: `apps/electron-backend/src/app/services/database-worker-client.spec.ts` +- Modify: `apps/electron-backend/src/app/workers/worker-performance-phase.spec.ts` +- Modify: `apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts` +- Modify: `apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts` +- Modify: `apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts` +- Modify: `apps/electron-backend/src/app/database/operations/category.operations.spec.ts` +- Modify: `apps/electron-backend/src/app/database/operations/content.operations.spec.ts` +- Create: `apps/electron-backend/src/app/database/operations/xtream.operations.spec.ts` +- Modify: `apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts` + +- [ ] **Step 1: Write failing phase-placement and cancellation tests** + +Require one ordered pair per aggregate phase, count-only metadata, closure on +success/error/cancel, and no calls when worker profiling is off. Required phase +names: + +```text +sqlite.categories.read +normalize.categories +sqlite.categories.write-transactions +sqlite.content.read +sqlite.content.category-map-read +normalize.content +sqlite.content.write-transactions +sqlite.xtream-cache-clear.write-transactions +sqlite.xtream-delete.collect-user-data +sqlite.xtream-delete.write-transactions +sqlite.playlist-delete.collect-ids +sqlite.playlist-delete.write-transactions +sqlite.search.query +normalize.search-rank +``` + +Wrap the complete batch loop in one aggregate `write-transactions` span; +metadata reports total items. This avoids ambiguous repeated phase pairs. +Tests must state that the span includes all transaction commits. + +Cancellation cleanup runs `DB_CLEAR_XTREAM_IMPORT_CACHE` after an aborted +import. Measure its complete delete loops with the dedicated cache-clear phase +instead of hiding that work in the request envelope or mislabelling it as a +content-save phase. Synthetic benchmark fixtures keep favorites/recent data +empty, so restore-user-data remains explicitly `N/A: no-user-data`. + +Require a dev-only `performance-cancel-received` worker message containing only +operation/request IDs and epoch. It must not change +`DatabaseWorkerClient.cancel()` into a wait or alter normal cancellation order. + +- [ ] **Step 2: Extend the worker phase union generically** + +Change the capture helper/model to accept the union of existing app-playlist +phases and new Xtream database phases. Preserve existing M3U phase strings and +tests exactly. + +- [ ] **Step 3: Add operation adapters without changing SQL or concurrency** + +Pass the optional capture adapter from `database.worker.ts` into the existing +operations. Measure around existing normalization, query, and transaction-loop +blocks. Do not reorder statements, change batch size, add indexes, serialize +parallel requests, or alter transaction boundaries. + +- [ ] **Step 4: Preserve overlap invalidation** + +`registerDatabaseWorkerPerformanceCapture()` must continue marking overlapping +request CPU/ELU/event-loop-delay data invalid. Tests must prove no hidden +serialization is introduced. Whole-worker profiles remain valid diagnostics; +request-scoped overlapping metrics remain explicitly invalid. + +- [ ] **Step 5: Run focused and full backend tests** + +```bash +pnpm nx test electron-backend --skip-nx-cache --testPathPatterns='worker-performance|worker-ipc-contract|database-worker-client|category.operations|content.operations|xtream.operations|playlist.operations' +pnpm nx test electron-backend --skip-nx-cache +pnpm nx lint electron-backend --skip-nx-cache +``` + +Expected: green and no SQL/business-result changes. + +## Task 5: Add Renderer Store Markers Without Equating Them To Paint + +**Files:** + +- Modify: `libs/shared/logging/src/lib/renderer-performance-phase.ts` +- Modify: `libs/shared/logging/src/lib/renderer-performance-phase.spec.ts` +- Modify: `libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts` +- Modify: `libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts` +- Modify: `libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts` +- Modify: `libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts` +- Modify: `libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts` +- Modify: `libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts` +- Modify: `libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts` +- Modify: `libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts` + +- [ ] **Step 1: Write failing opt-in/fail-neutral store-marker tests** + +Add count-only phase names: + +```text +store.xtream-publish-categories +store.xtream-publish-live +store.xtream-publish-vod +store.xtream-publish-series +store.xtream-import-terminal +store.xtream-search-results +store.xtream-refresh-meta +store.xtream-delete-row +``` + +Tests must prove the marker wraps only the synchronous `patchState` or NgRx +dispatch, emits one start/end pair, preserves thrown errors/results, performs +no metadata work without a hook, and ignores hook failures. + +- [ ] **Step 2: Wrap the exact existing publication sites** + +Use `measureRendererPerformancePhase` around the category publication at +`with-content.feature.ts:965`, live/VOD/series publications at +`:1047/:1083/:1119`, import-terminal publication, search result publication, +refresh metadata dispatch, and delete-row dispatch. Do not change fetch order, +signals, routes, or visible behavior. + +- [ ] **Step 3: Keep Angular paint external to store timing** + +Document in code comments and benchmark contracts that a synchronous store +span is not render time. The Playwright renderer probe will observe a +scenario-specific DOM sentinel and two `requestAnimationFrame` callbacks after +the marker end. + +- [ ] **Step 4: Run affected project tests** + +```bash +pnpm nx test shared-logging --skip-nx-cache +pnpm nx test portal-xtream-data-access --skip-nx-cache +pnpm nx test playlist-shared-ui --skip-nx-cache +``` + +If the exact project names differ, discover them with: + +```bash +pnpm nx show projects --withTarget test +``` + +Then run the owning targets; do not guess a project name. + +## Task 6: Extend The Existing Process Capture For Xtream Attribution + +**Files:** + +- Create: `apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-ipc-marker-events.spec.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-worker-phase-events.spec.ts` +- Modify: `apps/electron-backend-e2e/src/performance/m3u-refresh-main-capture.ts` +- Modify: `apps/electron-backend-e2e/src/performance/worker-performance-phase-events.ts` +- Modify: `apps/electron-backend-e2e/src/performance/worker-request-performance.ts` +- Modify: `apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts` +- Modify: `apps/electron-backend-e2e/src/performance/performance-phase-events.ts` +- Modify: `apps/electron-backend-e2e/src/performance/performance-phase-events.spec.ts` + +- [ ] **Step 1: Write failing parsing/correlation tests** + +Require: + +- exact schema validation and count-only fields; +- ordered start/end pairs and no unclosed/duplicate boundaries; +- FIFO correlation by safe method/playlist/type/action/operation key; +- quarantine rather than guessing when the same key overlaps; +- handler/preload/worker timestamps remain monotonic; +- acquisition = `network.total - json.transform`, never negative; +- main-to-worker and worker-to-main deltas are named IPC proxies; +- worker cancel receipt and terminal cancel are separate; +- existing M3U parsers produce byte-equivalent normalized output. + +- [ ] **Step 2: Parameterize main capture phase subscriptions** + +Extend `installMainCapture()` to subscribe to both existing M3U and new Xtream +diagnostics channels. Keep the old state key and exported API for compatibility; +do not rename or copy the 1,500-line injected protocol in this task. Add only +the minimum channel allowlists and timeline record shapes. + +- [ ] **Step 3: Capture Xtream preload and main-handler boundaries** + +Listen for sanitized preload markers in main and record handler receive/ready +phases. Match DB worker requests using safe operation ID where supplied and +FIFO/quarantine otherwise. Never attach raw IPC payloads to the timeline. + +- [ ] **Step 4: Record true worker cancellation receipt** + +Recognize `performance-cancel-received` without counting it as a normal +response. Store the epoch/IDs in the current capture generation. Late or +cross-generation markers invalidate cancellation attribution. + +- [ ] **Step 5: Run the complete performance-harness unit suite** + +```bash +pnpm nx run electron-backend-e2e:test-performance-harness --skip-nx-cache +``` + +Expected: all old M3U tests and new Xtream contracts pass. + +## Task 7: Build Xtream Scenario, Summary, And Validity Contracts + +**Files:** + +- Create: `apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-benchmark-contract.spec.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-control-client.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-control-client.spec.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-summary-contract.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-phase-attribution.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-phase-attribution.spec.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-benchmark-report.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-benchmark-report.spec.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-diagnostic-artifacts.spec.ts` + +- [ ] **Step 1: Write failing schedule and safety tests** + +Require the five exact scenario IDs and formal iteration schedule: + +```ts +[ + { runId: 'warmup-01', kind: 'warmup' }, + { runId: 'run-01', kind: 'measured' }, + { runId: 'run-02', kind: 'measured' }, + { runId: 'run-03', kind: 'measured' }, + { runId: 'run-04', kind: 'measured' }, + { runId: 'run-05', kind: 'measured' }, + { runId: 'diagnostic', kind: 'diagnostic' }, +]; +``` + +Smoke may reduce measured runs to one but must set `eligibleForComparison=false`. +Reject non-loopback origin, unexpected fixture name/hash/counts, any credential +field in serialized artifacts, nonzero formal delay/barrier residue, and any +unexpected mock action. + +- [ ] **Step 2: Define the request and phase contract** + +After observations reset, initial/reimport expects exactly seven application +actions: + +```text +get_account_info +get_live_categories +get_vod_categories +get_series_categories +get_live_streams +get_vod_streams +get_series +``` + +Category order is irrelevant; content order is live -> VOD -> series. Cancel +requires account + categories + live only, with no VOD/series request after the +authoritative cancellation point. + +- [ ] **Step 3: Define process-separated result types** + +Each result stores: + +- total and named phase durations; +- renderer peak/post-GC heap, long-task count/max, frame-gap p95/max, + heartbeat p95/max, store-to-paint, navigation/search/action latency; +- main CPU, peak/post-GC heap, process-wide peak/post-GC RSS, ELD + p95/p99/max, ELU/unavailable reason, BrowserWindow + unresponsive/responsive events; +- database-worker CPU, peak/post-GC heap, external memory, ELD p95/p99/max, + ELU, request validity, profile paths, and RSS `N/A` reason; +- playlist-refresh worker `N/A` reason; +- cancel dispatch/receipt/terminal/paint latencies; +- explicit index, exact-commit, native-memory, and debug-copy N/A/proxy labels. + +- [ ] **Step 4: Fail closed on comparison validity** + +Formal summaries require five valid measured values for every required metric; +never silently filter nulls. Require one renderer identity, one current database +worker, zero playlist-refresh workers, no late DB request after capture cutoff, +complete action/DB phase contracts, exact catalog invariants, no console errors, +and valid regular contained diagnostic artifacts. + +- [ ] **Step 5: Run the harness unit suite** + +```bash +pnpm nx run electron-backend-e2e:test-performance-harness --skip-nx-cache +``` + +## Task 8: Build The Renderer Probe And Initial/Cancel Drivers + +**Files:** + +- Create: `apps/electron-backend-e2e/src/performance/xtream-renderer-probe.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-renderer-probe.spec.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-renderer-capture.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts` + +- [ ] **Step 1: Write failing probe lifecycle tests** + +Mirror the proven M3U capture lifecycle and require: + +- Long Tasks clipped to operation boundaries; +- continuous rAF gaps and a 50 ms deadline-based UI heartbeat; +- 20 ms renderer heap sampling plus explicit GC/post-GC heap; +- route, store marker, DOM sentinel, and two-rAF paint timestamps; +- idempotent stop/dispose after partial startup failure; +- CPU/trace/heap snapshot only in diagnostic; +- no profile path in measured/warm-up runs. + +- [ ] **Step 2: Implement initial-import trigger and terminal** + +Prepare the Xtream dialog with only the fixed synthetic name, loopback origin, +and committed synthetic credential pair. Start clocks immediately before the +final Add button activation. Terminal requires overlay absent, all three +import-status values completed, exact 100k content/100 category DB counts, and +two rendered frames after the final store marker. + +- [ ] **Step 3: Implement cancellation at real DB progress** + +Install the DB-event listener before triggering import. On the first live +`save-content` progress event at or above +`max(100, floor(total * 0.10))`, synchronously record the click epoch and +activate the visible cancel button. Use no timeout/delay as the trigger. +Require worker receipt, terminal DB `cancelled`, painted cancelled state, no +later success, no automatic retry, and no VOD/series mock requests. + +- [ ] **Step 4: Add interaction-free smoke driver tests** + +Use deterministic mocked Page/CDP/main-capture adapters to prove trigger order, +terminal checks, cleanup, and that credential strings never enter result, +manifest, console label, or artifact filenames. + +- [ ] **Step 5: Run the complete harness tests** + +```bash +pnpm nx run electron-backend-e2e:test-performance-harness --skip-nx-cache +``` + +## Task 9: Build Refresh, Delete, And Background-UI Drivers + +**Files:** + +- Modify: `apps/electron-backend-e2e/src/performance/xtream-scenario-driver.ts` +- Modify: `apps/electron-backend-e2e/src/performance/xtream-scenario-driver.spec.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.spec.ts` + +- [ ] **Step 1: Write failing seed/rollover tests** + +Require a complete seed import outside measured capture, an idle DB worker, +zero pending requests, and atomic main-capture rollover before trigger. Store +seed metrics separately as `seed-main-capture.json`; never include them in +measured summaries. + +- [ ] **Step 2: Implement refresh** + +Start at the refresh confirmation button. Include +`DB_DELETE_XTREAM_CONTENT`, playback/meta parallel work, navigation, the seven +mock actions, three category writes, three content writes, empty user-data +restore, final store publication, and paint. Assert exact counts and no +duplicate/stale rows. + +- [ ] **Step 3: Implement full source deletion** + +Start at the delete confirmation button. Require started/progress/completed DB +events, source row removal, zero playlist/category/content rows, and no stale +active-store route. Capture all delete transaction phases and final dispatch +to paint. + +- [ ] **Step 4: Implement the fixed background UI sequence** + +Start a natural delay-zero refresh. Only after proving the DB delete operation +active, perform: + +1. Sources filter input and result paint; +2. navigation to Dashboard and two-rAF route paint; +3. navigation back to Sources; +4. source search input and result paint; +5. one stable, non-destructive UI action. + +Record input/click to paint latency for every action and require the background +operation to be active both before and after each action. If it finishes too +early, invalidate the iteration and increase the fixed fixture cardinality in +a separately reviewed change; never add sleeps or mock delay to formal timing. + +- [ ] **Step 5: Run harness and existing responsiveness tests** + +```bash +pnpm nx run electron-backend-e2e:test-performance-harness --skip-nx-cache +pnpm nx run electron-backend-e2e:e2e-ci--src/xtream-responsiveness.e2e.ts --skip-nx-cache +``` + +The existing E2E may keep its deliberate worker batch delay because it is a +functional regression test. The benchmark must always set +`IPTVNATOR_DB_WORKER_BATCH_DELAY_MS=0`. + +## Task 10: Add The Orchestrator, Nx Target, And Artifact Validation + +**Files:** + +- Create: `apps/electron-backend-e2e/src/performance/xtream-benchmark-orchestrator.ts` +- Create: `apps/electron-backend-e2e/src/performance/xtream-benchmark-lifecycle.spec.ts` +- Create: `apps/electron-backend-e2e/src/xtream.performance.ts` +- Create: `apps/electron-backend-e2e/playwright.xtream-performance.config.ts` +- Modify: `apps/electron-backend-e2e/project.json` +- Modify: `apps/electron-backend-e2e/src/performance/initial-import-diagnostic-artifacts.ts` + +- [ ] **Step 1: Write failing lifecycle and artifact tests** + +Require: + +- preflight before directory creation; +- mock prepare before capture and observations reset immediately before trigger; +- fresh app/data dir per iteration and guaranteed cleanup; +- raw iteration JSON written before summary validity throws; +- diagnostic regular/non-symlink/nonempty JSON trace, CPU profile, and heap + snapshot for renderer/main/database worker; +- no playlist-refresh worker artifacts; +- source-map/build hash manifest sufficient to map profiles after a rebuild; +- output contained by a fresh absolute + `dist/performance//` path; +- no `.cpuprofile`, `.heapsnapshot`, trace, or generated summary staged by Git. + +- [ ] **Step 2: Implement the serial orchestrator** + +For each scenario, run all seven definitions serially. Launch Electron with: + +```text +--js-flags=--expose-gc +--remote-debugging-address=127.0.0.1 +--remote-debugging-port=9222 +--user-data-dir= +``` + +Set: + +```text +IPTVNATOR_DB_WORKER_BATCH_DELAY_MS=0 +IPTVNATOR_PERF_CAPTURE=1 +IPTVNATOR_PERF_WORKER_PROFILING=1 +IPTVNATOR_TRACE_RENDERER_CONSOLE=0 +``` + +Start renderer/main capture before trigger, wait for authoritative terminal and +DB settlement, stop capture, write raw capture/result JSON, validate artifacts, +then build the scenario summary. + +- [ ] **Step 3: Add the isolated mock web server** + +`playwright.xtream-performance.config.ts` starts exactly one control-enabled +mock on a dedicated loopback port with a per-process token, `reuseExistingServer: +false`, one Playwright worker, no retries, and a 30-minute test timeout. The +token is passed to the control client but never serialized. + +- [ ] **Step 4: Add the Nx target** + +Add `benchmark-xtream` with: + +```json +{ + "dependsOn": ["electron-backend:build-performance"], + "executor": "nx:run-commands", + "cache": false, + "parallelism": false, + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec playwright test --config=playwright.xtream-performance.config.ts src/xtream.performance.ts" + } +} +``` + +- [ ] **Step 5: Run all harness tests** + +```bash +pnpm nx run electron-backend-e2e:test-performance-harness --skip-nx-cache +``` + +Expected: old M3U and new Xtream lifecycle/contract tests pass. + +## Task 11: Run A Non-Claim Smoke And Close The Harness PR + +**Files:** + +- Modify: `docs/architecture/xtream-mock-server.md` +- Modify: `docs/architecture/sqlite-db-worker.md` +- Modify if command/environment text changed: `AGENTS.md` +- Modify if command/environment text changed: `CLAUDE.md` +- Verify only: `.gitignore` +- Verify only: `.changes/README.md` + +- [ ] **Step 1: Run the full validation ladder** + +```bash +pnpm nx test xtream-mock-server --skip-nx-cache +pnpm nx test electron-backend --skip-nx-cache +pnpm nx test portal-xtream-data-access --skip-nx-cache +pnpm nx test portal-xtream-feature --skip-nx-cache +pnpm nx run electron-backend-e2e:test-performance-harness --skip-nx-cache +pnpm nx run electron-backend-e2e:e2e-ci--src/xtream-responsiveness.e2e.ts --skip-nx-cache +pnpm nx run electron-backend-e2e:e2e-ci--src/sources.e2e.ts --skip-nx-cache +pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts --skip-nx-cache +pnpm nx affected -t lint --base=24f0dee6f033613148aeab4b699e9db667041927 --head=HEAD --skip-nx-cache +pnpm run release:notes:validate +``` + +- [ ] **Step 2: Run a smoke benchmark only when a CDP port is available** + +If `9222` remains occupied, use an unused alternate loopback port only for +smoke: + +```bash +perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-xtream-smoke" +IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \ +IPTVNATOR_PERF_VARIANT=smoke \ +IPTVNATOR_PERF_SMOKE=1 \ +IPTVNATOR_PERF_CDP_PORT=9322 \ +NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false \ +pnpm nx run electron-backend-e2e:benchmark-xtream --skip-nx-cache +``` + +Validate that every smoke summary says `eligibleForComparison=false`. Never +quote smoke values as baseline or improvement evidence. + +- [ ] **Step 3: Update canonical docs** + +Document the future formal command, fixture identity, exact run schedule, +process/RSS/heap distinctions, Xtream worker topology, control-plane safety, +artifact layout, validity gates, and before/after rules. Keep `AGENTS.md` and +`CLAUDE.md` mirrored if their tracing/benchmark environment section changes. + +- [ ] **Step 4: Test-impact and release-note decision** + +This PR changes only opt-in development/test instrumentation and synthetic mock +tooling; normal user-visible behavior is unchanged. Do not add a `.changes` +note. Apply `no-release-note` when opening the PR because runtime paths under +`apps/**` and `libs/**` are touched. + +- [ ] **Step 5: Verify tracked scope and ignored raw artifacts** + +```bash +git status --short +git check-ignore -v dist/performance +git diff --check +git diff --stat 24f0dee6f033613148aeab4b699e9db667041927 +git ls-files 'dist/performance/**' +``` + +Expected: tracked source/tests/docs only; `git ls-files` prints nothing. + +- [ ] **Step 6: Commit, push, open the PR, and run the review loop** + +Use focused conventional commits, push `agent/perf-xtream-harness`, open a +ready PR with validation and smoke caveats, apply `no-release-note`, trigger +`@codex review`, and inspect all CI/review threads. Do not merge until every +actionable item is fixed, re-tested, independently reviewed, and current-head +CI is green. + +## Task 12: Run The Formal Xtream Baseline After Harness Merge + +**Files:** + +- Generated only, ignored: `dist/performance/-xtream/baseline/**` + +- [ ] **Step 1: Start from the exact clean merged commit** + +```bash +git switch master +git pull --ff-only +git status --short +lsof -nP -iTCP:9222 -sTCP:LISTEN +``` + +Expected: clean tree and port 9222 free. If an unrelated listener remains, stop +and request authorization rather than killing it. + +- [ ] **Step 2: Run the formal benchmark** + +```bash +perf_output="$PWD/dist/performance/$(date -u +%Y%m%dT%H%M%SZ)-xtream" +IPTVNATOR_PERF_OUTPUT_DIR="$perf_output" \ +IPTVNATOR_PERF_VARIANT=baseline \ +NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false \ +pnpm nx run electron-backend-e2e:benchmark-xtream --skip-nx-cache +``` + +Do not set smoke or alternate-port flags. + +- [ ] **Step 3: Validate and analyze raw profiles** + +Verify every scenario has one warm-up, five measured, one diagnostic, valid +process identities, exact fixture/action hashes, zero external requests, and +all required artifacts. Run the ignored CPU/heap analyzers sequentially, +preserve source-mapped hotspot and retaining-path JSON beside the raw profiles, +and hash the analyzer/source-map inputs. + +- [ ] **Step 4: Name and prove three bottlenecks** + +For each candidate, cite: + +- scenario and measured phase distribution; +- renderer/main/database-worker CPU stack; +- retaining path and retained/self sizes with process scope; +- corroborating event-loop/long-task/frame/heartbeat evidence; +- explicit confounders and attribution gaps. + +Choose only the strongest candidate. Add a regression test that fails at the +merged baseline, implement the smallest production change, rerun all affected +tests, and repeat the identical formal benchmark into a fresh `after` +directory. + +- [ ] **Step 5: Claim improvement only after matched five-run evidence** + +Report before/after medians and p95 where appropriate, percentage change, +memory/process separation, variance, unchanged/counter-regressed metrics, and +tradeoffs. If the matched results do not improve, revert or describe the change +as unconfirmed; never call it an optimization. diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts index 2ba8bb51e..b87054a3d 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.spec.ts @@ -23,9 +23,26 @@ import { PlaylistMeta, SECURITY_ERROR_PREFIX, } from '@iptvnator/shared/interfaces'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; import { PlaylistRefreshActionService } from './playlist-refresh-action.service'; +const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); + +function setPerformanceHook( + hook: ((event: RendererPerformancePhaseEvent) => void) | null +): void { + const target = globalThis as unknown as Record; + if (hook === null) { + delete target[performanceHookSymbol]; + } else { + target[performanceHookSymbol] = hook; + } +} + function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -233,6 +250,7 @@ describe('PlaylistRefreshActionService', () => { }); afterEach(() => { + setPerformanceHook(null); jest.restoreAllMocks(); localStorage.clear(); }); @@ -382,6 +400,11 @@ describe('PlaylistRefreshActionService', () => { it('stores Xtream restore data before updating playlist meta and navigating', async () => { const item = createPlaylistMeta(); const executionOrder: string[] = []; + const performanceEvents: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => { + performanceEvents.push(event); + executionOrder.push(event.boundary); + }); let confirmPromise: Promise | undefined; const dateNowSpy = jest .spyOn(Date, 'now') @@ -456,7 +479,34 @@ describe('PlaylistRefreshActionService', () => { 'xtreams', item._id, ]); - expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']); + expect(executionOrder).toEqual([ + 'setItem', + 'start', + 'dispatch', + 'end', + 'navigate', + ]); + expect( + performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({ + boundary, + items: metadata?.items, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + items: 1, + outcome: undefined, + phase: 'store.xtream-refresh-meta', + }, + { + boundary: 'end', + items: 1, + outcome: 'success', + phase: 'store.xtream-refresh-meta', + }, + ]); setItemSpy.mockRestore(); dateNowSpy.mockRestore(); diff --git a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts index 43aed4ab6..214cc380d 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-refresh-action.service.ts @@ -23,6 +23,10 @@ import { PLAYLIST_UPDATE, PlaylistMeta, } from '@iptvnator/shared/interfaces'; +import { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, +} from '@iptvnator/shared/logging'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; export interface XtreamRefreshPreparationState { @@ -159,10 +163,15 @@ export class PlaylistRefreshActionService { playbackPositions, }); - this.store.dispatch( - PlaylistActions.updatePlaylistMeta({ - playlist: { ...item, updateDate }, - }) + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_REFRESH_META, + () => + this.store.dispatch( + PlaylistActions.updatePlaylistMeta({ + playlist: { ...item, updateDate }, + }) + ), + () => ({ items: 1 }) ); await this.router.navigate([ diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts index dca8d0fa0..b9de1ca97 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.spec.ts @@ -27,8 +27,25 @@ import { SortService, } from '@iptvnator/services'; import { PLAYLIST_UPDATE, PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; import { RecentPlaylistsComponent } from './recent-playlists.component'; +const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); + +function setPerformanceHook( + hook: ((event: RendererPerformancePhaseEvent) => void) | null +): void { + const target = globalThis as unknown as Record; + if (hook === null) { + delete target[performanceHookSymbol]; + } else { + target[performanceHookSymbol] = hook; + } +} + function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -233,11 +250,14 @@ describe('RecentPlaylistsComponent busy state', () => { }); afterEach(() => { + setPerformanceHook(null); jest.restoreAllMocks(); localStorage.clear(); }); it('tracks delete progress and clears the busy row after completion', async () => { + const events: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => events.push(event)); const item = createPlaylistMeta({ _id: 'playlist-delete-1' }); const deletion = createDeferred(); @@ -290,9 +310,32 @@ describe('RecentPlaylistsComponent busy state', () => { undefined, { duration: 2000 } ); + expect( + events.map(({ boundary, metadata, outcome, phase }) => ({ + boundary, + items: metadata?.items, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + items: 1, + outcome: undefined, + phase: 'store.xtream-delete-row', + }, + { + boundary: 'end', + items: 1, + outcome: 'success', + phase: 'store.xtream-delete-row', + }, + ]); }); it('delegates playlist deletion and updates local UI state after success', async () => { + const events: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => events.push(event)); const item = createPlaylistMeta({ _id: 'pwa-playlist-1', serverUrl: undefined, @@ -316,6 +359,43 @@ describe('RecentPlaylistsComponent busy state', () => { undefined, { duration: 2000 } ); + expect(events).toEqual([]); + }); + + it('records dispatch failure and still clears delete-row busy state', async () => { + const events: RendererPerformancePhaseEvent[] = []; + const item = createPlaylistMeta({ _id: 'dispatch-failure' }); + const dispatchError = new Error('dispatch failed'); + setPerformanceHook((event) => events.push(event)); + (store.dispatch as jest.Mock).mockImplementation(() => { + throw dispatchError; + }); + + await expect(component.removePlaylist(item)).rejects.toBe( + dispatchError + ); + + expect(component.isDeletePending(item._id)).toBe(false); + expect(component.getBusyProgress(item._id)).toBeNull(); + expect(snackBar.open).not.toHaveBeenCalled(); + expect( + events.map(({ boundary, outcome, phase }) => ({ + boundary, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + outcome: undefined, + phase: 'store.xtream-delete-row', + }, + { + boundary: 'end', + outcome: 'error', + phase: 'store.xtream-delete-row', + }, + ]); }); it('tracks Xtream refresh progress and clears the busy row after abort', async () => { @@ -388,6 +468,11 @@ describe('RecentPlaylistsComponent busy state', () => { const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' }); let confirmPromise: Promise | undefined; const executionOrder: string[] = []; + const performanceEvents: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => { + performanceEvents.push(event); + executionOrder.push(event.boundary); + }); const dateNowSpy = jest .spyOn(Date, 'now') .mockReturnValue(1712145600000); @@ -476,7 +561,38 @@ describe('RecentPlaylistsComponent busy state', () => { 'xtreams', item._id, ]); - expect(executionOrder).toEqual(['setItem', 'dispatch', 'navigate']); + expect(executionOrder).toEqual([ + 'setItem', + 'start', + 'dispatch', + 'end', + 'navigate', + ]); + expect(performanceEvents).toHaveLength(2); + expect(performanceEvents[0]?.phaseId).toBe( + performanceEvents[1]?.phaseId + ); + expect( + performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({ + boundary, + metadata, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + metadata: { items: 1 }, + outcome: undefined, + phase: 'store.xtream-refresh-meta', + }, + { + boundary: 'end', + metadata: { items: 1 }, + outcome: 'success', + phase: 'store.xtream-refresh-meta', + }, + ]); setItemSpy.mockRestore(); dateNowSpy.mockRestore(); diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts index bbada321d..45d88f585 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/recent-playlists.component.ts @@ -46,6 +46,10 @@ import { PlaylistMeta, PlaylistRefreshEvent, } from '@iptvnator/shared/interfaces'; +import { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, +} from '@iptvnator/shared/logging'; import { EmptyStateComponent } from './empty-state/empty-state.component'; import type { PlaylistType } from '../add-playlist-menu/playlist-type'; @@ -271,9 +275,24 @@ export class RecentPlaylistsComponent { } ); if (deleted) { - this.store.dispatch( - PlaylistActions.removePlaylist({ playlistId: item._id }) - ); + if (item.serverUrl) { + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW, + () => + this.store.dispatch( + PlaylistActions.removePlaylist({ + playlistId: item._id, + }) + ), + () => ({ items: 1 }) + ); + } else { + this.store.dispatch( + PlaylistActions.removePlaylist({ + playlistId: item._id, + }) + ); + } this.snackBar.open( this.translate.instant( 'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS' @@ -389,10 +408,15 @@ export class RecentPlaylistsComponent { }); // Update the timestamp in NgRx / IndexedDB - this.store.dispatch( - PlaylistActions.updatePlaylistMeta({ - playlist: { ...item, updateDate }, - }) + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_REFRESH_META, + () => + this.store.dispatch( + PlaylistActions.updatePlaylistMeta({ + playlist: { ...item, updateDate }, + }) + ), + () => ({ items: 1 }) ); // Navigate to the playlist to trigger re-import diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts index a8f1bac48..a7d9a4da6 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts @@ -1,6 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; import { DatabaseService } from '@iptvnator/services'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; import { XTREAM_DATA_SOURCE, XtreamPlaylistData, @@ -28,6 +32,18 @@ const PLAYLIST: XtreamPlaylistData = { password: 'secret', type: 'xtream', }; +const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); + +function setPerformanceHook( + hook: ((event: RendererPerformancePhaseEvent) => void) | null +): void { + const target = globalThis as unknown as Record; + if (hook === null) { + delete target[performanceHookSymbol]; + } else { + target[performanceHookSymbol] = hook; + } +} let checkPortalStatusMock: jest.Mock, []>; @@ -120,14 +136,16 @@ describe('withContent import state', () => { databaseService = { clearXtreamImportCache: jest.fn().mockResolvedValue(true), cancelOperation: jest.fn().mockResolvedValue(true), - createOperationId: jest.fn().mockImplementation((prefix?: string) => { - if (prefix === 'xtream-import-session') { - return 'xtream-import-session'; - } + createOperationId: jest + .fn() + .mockImplementation((prefix?: string) => { + if (prefix === 'xtream-import-session') { + return 'xtream-import-session'; + } - operationCounter += 1; - return `${prefix ?? 'db-op'}-${operationCounter}`; - }), + operationCounter += 1; + return `${prefix ?? 'db-op'}-${operationCounter}`; + }), getXtreamImportStatus: jest.fn().mockResolvedValue('completed'), setXtreamImportStatus: jest.fn().mockResolvedValue(true), supportsDbOperationCancellation: jest.fn().mockReturnValue(true), @@ -159,9 +177,111 @@ describe('withContent import state', () => { }); afterEach(() => { + setPerformanceHook(null); localStorage.clear(); }); + it('emits ordered count-only publication markers for a successful import', async () => { + const events: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => events.push(event)); + const categories = { + live: [{ category_id: '1' }], + vod: [{ category_id: '2' }, { category_id: '3' }], + series: [{ category_id: '4' }], + }; + const content = { + live: [{ xtream_id: 1 }, { xtream_id: 2 }], + movie: [{ xtream_id: 3 }], + series: [{ xtream_id: 4 }, { xtream_id: 5 }], + }; + dataSource.getCategories.mockImplementation( + ( + _playlistId: string, + _credentials: unknown, + type: keyof typeof categories + ) => Promise.resolve(categories[type]) + ); + dataSource.getContent.mockImplementation( + ( + _playlistId: string, + _credentials: unknown, + type: keyof typeof content + ) => Promise.resolve(content[type]) + ); + + await store.initializeContent(); + + expect( + events.map(({ boundary, metadata, outcome, phase }) => ({ + boundary, + items: metadata?.items, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + items: 4, + outcome: undefined, + phase: 'store.xtream-publish-categories', + }, + { + boundary: 'end', + items: 4, + outcome: 'success', + phase: 'store.xtream-publish-categories', + }, + { + boundary: 'start', + items: 2, + outcome: undefined, + phase: 'store.xtream-publish-live', + }, + { + boundary: 'end', + items: 2, + outcome: 'success', + phase: 'store.xtream-publish-live', + }, + { + boundary: 'start', + items: 1, + outcome: undefined, + phase: 'store.xtream-publish-vod', + }, + { + boundary: 'end', + items: 1, + outcome: 'success', + phase: 'store.xtream-publish-vod', + }, + { + boundary: 'start', + items: 2, + outcome: undefined, + phase: 'store.xtream-publish-series', + }, + { + boundary: 'end', + items: 2, + outcome: 'success', + phase: 'store.xtream-publish-series', + }, + { + boundary: 'start', + items: 5, + outcome: undefined, + phase: 'store.xtream-import-terminal', + }, + { + boundary: 'end', + items: 5, + outcome: 'success', + phase: 'store.xtream-import-terminal', + }, + ]); + }); + it('tracks aggregated import progress while content is loading', async () => { const pendingCategories = { live: createDeferred(), @@ -350,11 +470,8 @@ describe('withContent import state', () => { ) => pendingCategories[type].promise ); dataSource.getContent.mockImplementation( - ( - _playlistId: string, - _credentials: unknown, - type: ContentType - ) => pending[type].promise + (_playlistId: string, _credentials: unknown, type: ContentType) => + pending[type].promise ); const initialization = store.initializeContent(); @@ -741,6 +858,8 @@ describe('withContent import state', () => { }); it('ignores concurrent initializeContent calls while an import is already running', async () => { + const events: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => events.push(event)); const pendingCategories = { live: createDeferred(), vod: createDeferred(), @@ -772,6 +891,11 @@ describe('withContent import state', () => { expect(dataSource.getCategories).toHaveBeenCalledTimes(3); expect(dataSource.getContent).toHaveBeenCalledTimes(3); + expect( + events.filter( + ({ phase }) => phase === 'store.xtream-import-terminal' + ) + ).toHaveLength(2); }); it('blocks auto restart after cancelling during category loading until retry is explicit', async () => { @@ -936,6 +1060,8 @@ describe('withContent import state', () => { }); it('cancels active imports and clears stale progress after worker aborts', async () => { + const events: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => events.push(event)); const pendingCategories = { live: createDeferred(), vod: createDeferred(), @@ -1042,5 +1168,17 @@ describe('withContent import state', () => { expect(store.activeImportTotalCount()).toBe(0); expect(store.importCount()).toBe(0); expect(store.itemsToImport()).toBe(0); + expect( + events + .filter(({ phase }) => phase === 'store.xtream-import-terminal') + .map(({ boundary, metadata, outcome }) => ({ + boundary, + items: metadata?.items, + outcome, + })) + ).toEqual([ + { boundary: 'start', items: 0, outcome: undefined }, + { boundary: 'end', items: 0, outcome: 'success' }, + ]); }); }); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index f1ab104a7..4ba66c065 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -12,6 +12,10 @@ import { XtreamSerieItem, XtreamVodStream, } from '@iptvnator/shared/interfaces'; +import { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, +} from '@iptvnator/shared/logging'; import { createLogger } from '@iptvnator/portal/shared/util'; import { DatabaseService, @@ -861,18 +865,28 @@ export function withContent() { logger.error('Error initializing content', error); } } finally { - patchState(store, { - isImporting: false, - isCancellingImport: false, - importCount: 0, - importPhase: null, - itemsToImport: 0, - activeImportContentType: null, - activeImportCurrentCount: 0, - activeImportTotalCount: 0, - activeImportSessionId: null, - activeImportOperationIds: [], - }); + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_IMPORT_TERMINAL, + () => + patchState(store, { + isImporting: false, + isCancellingImport: false, + importCount: 0, + importPhase: null, + itemsToImport: 0, + activeImportContentType: null, + activeImportCurrentCount: 0, + activeImportTotalCount: 0, + activeImportSessionId: null, + activeImportOperationIds: [], + }), + () => ({ + items: + store.liveStreams().length + + store.vodStreams().length + + store.serialStreams().length, + }) + ); } }; @@ -962,12 +976,21 @@ export function withContent() { ), ]); - patchState(store, { - liveCategories: live, - vodCategories: vod, - serialCategories: series, - isLoadingCategories: false, - }); + // This span ends after synchronous store publication; + // the renderer probe owns paint observation. + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_CATEGORIES, + () => + patchState(store, { + liveCategories: live, + vodCategories: vod, + serialCategories: series, + isLoadingCategories: false, + }), + () => ({ + items: live.length + vod.length + series.length, + }) + ); } catch (error) { if (!isDbAbortError(error)) { logger.error('Error fetching categories', error); @@ -1044,9 +1067,14 @@ export function withContent() { 'completed' ); options?.completedTypes?.add('live'); - patchState(store, { - liveStreams: live, - }); + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_LIVE, + () => + patchState(store, { + liveStreams: live, + }), + () => ({ items: live.length }) + ); updateContentTypeLoadState('live', 'ready'); throwIfImportCancelled(options?.importSessionId); @@ -1080,9 +1108,14 @@ export function withContent() { 'completed' ); options?.completedTypes?.add('vod'); - patchState(store, { - vodStreams: vod, - }); + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_VOD, + () => + patchState(store, { + vodStreams: vod, + }), + () => ({ items: vod.length }) + ); updateContentTypeLoadState('vod', 'ready'); throwIfImportCancelled(options?.importSessionId); @@ -1116,10 +1149,15 @@ export function withContent() { 'completed' ); options?.completedTypes?.add('series'); - patchState(store, { - serialStreams: series, - isLoadingContent: false, - }); + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_PUBLISH_SERIES, + () => + patchState(store, { + serialStreams: series, + isLoadingContent: false, + }), + () => ({ items: series.length }) + ); updateContentTypeLoadState('series', 'ready'); } catch (error) { if (!isDbAbortError(error)) { diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts new file mode 100644 index 000000000..77ca625d2 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.spec.ts @@ -0,0 +1,116 @@ +import { TestBed } from '@angular/core/testing'; +import { signalStore, withState } from '@ngrx/signals'; +import { + RENDERER_PERFORMANCE_PHASE_HOOK_KEY, + type RendererPerformancePhaseEvent, +} from '@iptvnator/shared/logging'; +import { XTREAM_DATA_SOURCE } from '../../data-sources/xtream-data-source.interface'; +import { withSearch } from './with-search.feature'; + +jest.mock('@iptvnator/portal/shared/util', () => ({ + createLogger: () => ({ error: jest.fn() }), +})); + +const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY); +const TestSearchStore = signalStore( + withState({ playlistId: 'playlist-1' }), + withSearch() +); + +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +} + +function setPerformanceHook( + hook: ((event: RendererPerformancePhaseEvent) => void) | null +): void { + const target = globalThis as unknown as Record; + if (hook === null) { + delete target[performanceHookSymbol]; + } else { + target[performanceHookSymbol] = hook; + } +} + +describe('withSearch renderer performance markers', () => { + let searchContent: jest.Mock; + let store: InstanceType; + + beforeEach(() => { + searchContent = jest.fn(); + TestBed.configureTestingModule({ + providers: [ + TestSearchStore, + { + provide: XTREAM_DATA_SOURCE, + useValue: { searchContent }, + }, + ], + }); + store = TestBed.inject(TestSearchStore); + }); + + afterEach(() => setPerformanceHook(null)); + + it('marks only the authoritative non-stale result publication', async () => { + const events: RendererPerformancePhaseEvent[] = []; + const first = createDeferred(); + const second = createDeferred(); + setPerformanceHook((event) => events.push(event)); + searchContent + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const staleSearch = store.searchContent('old', ['live']); + const authoritativeSearch = store.searchContent('new', ['live']); + second.resolve([{ xtream_id: 2 }, { xtream_id: 3 }]); + await authoritativeSearch; + first.resolve([{ xtream_id: 1 }]); + await staleSearch; + + expect(store.searchResults()).toEqual([ + { xtream_id: 2 }, + { xtream_id: 3 }, + ]); + expect( + events.map(({ boundary, metadata, outcome, phase }) => ({ + boundary, + items: metadata?.items, + outcome, + phase, + })) + ).toEqual([ + { + boundary: 'start', + items: 2, + outcome: undefined, + phase: 'store.xtream-search-results', + }, + { + boundary: 'end', + items: 2, + outcome: 'success', + phase: 'store.xtream-search-results', + }, + ]); + }); + + it('does not mark an error-state reset', async () => { + const events: RendererPerformancePhaseEvent[] = []; + setPerformanceHook((event) => events.push(event)); + searchContent.mockRejectedValue(new Error('search failed')); + + await expect(store.searchContent('query', ['movie'])).resolves.toEqual( + [] + ); + + expect(store.searchResults()).toEqual([]); + expect(events).toEqual([]); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts index 895c360a2..fc769057b 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-search.feature.ts @@ -6,6 +6,10 @@ import { withState, } from '@ngrx/signals'; import { GlobalSearchResult } from '@iptvnator/shared/interfaces'; +import { + measureRendererPerformancePhase, + RENDERER_PERFORMANCE_PHASE, +} from '@iptvnator/shared/logging'; import { XTREAM_DATA_SOURCE, XtreamContentItem, @@ -127,10 +131,15 @@ export function withSearch() { return results; } - patchState(store, { - searchResults: results, - isSearching: false, - }); + measureRendererPerformancePhase( + RENDERER_PERFORMANCE_PHASE.XTREAM_SEARCH_RESULTS, + () => + patchState(store, { + searchResults: results, + isSearching: false, + }), + () => ({ items: results.length }) + ); return results; } catch (error) { diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index e8bf168f0..b8592bf20 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -52,6 +52,7 @@ export * from './lib/xtream-response.interface'; export * from './lib/xtream-portal.utils'; export * from './lib/xtream-restore-state.util'; export * from './lib/xtream-recently-added.utils'; +export * from './lib/xtream-performance-marker.interface'; export * from './lib/xtream-serie-details.interface'; export * from './lib/xtream-serie-item.interface'; export * from './lib/xtream-vod-details.interface'; diff --git a/libs/shared/interfaces/src/lib/performance-phase.interface.ts b/libs/shared/interfaces/src/lib/performance-phase.interface.ts index bbca5daf3..45a6d5a2e 100644 --- a/libs/shared/interfaces/src/lib/performance-phase.interface.ts +++ b/libs/shared/interfaces/src/lib/performance-phase.interface.ts @@ -35,8 +35,35 @@ export type AppPlaylistGetPerformancePhase = (typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE)[keyof typeof APP_PLAYLIST_GET_PERFORMANCE_PHASE]; export type AppPlaylistPerformancePhase = - | AppPlaylistGetPerformancePhase - | AppPlaylistUpsertPerformancePhase; + AppPlaylistGetPerformancePhase | AppPlaylistUpsertPerformancePhase; + +export const XTREAM_DATABASE_PERFORMANCE_PHASE = { + NORMALIZE_CATEGORIES: 'normalize.categories', + NORMALIZE_CONTENT: 'normalize.content', + NORMALIZE_SEARCH_RANK: 'normalize.search-rank', + SQLITE_CATEGORIES_READ: 'sqlite.categories.read', + SQLITE_CATEGORIES_WRITE_TRANSACTIONS: + 'sqlite.categories.write-transactions', + SQLITE_CONTENT_CATEGORY_MAP_READ: 'sqlite.content.category-map-read', + SQLITE_CONTENT_READ: 'sqlite.content.read', + SQLITE_CONTENT_WRITE_TRANSACTIONS: 'sqlite.content.write-transactions', + SQLITE_PLAYLIST_DELETE_COLLECT_IDS: 'sqlite.playlist-delete.collect-ids', + SQLITE_PLAYLIST_DELETE_WRITE_TRANSACTIONS: + 'sqlite.playlist-delete.write-transactions', + SQLITE_SEARCH_QUERY: 'sqlite.search.query', + SQLITE_XTREAM_CACHE_CLEAR_WRITE_TRANSACTIONS: + 'sqlite.xtream-cache-clear.write-transactions', + SQLITE_XTREAM_DELETE_COLLECT_USER_DATA: + 'sqlite.xtream-delete.collect-user-data', + SQLITE_XTREAM_DELETE_WRITE_TRANSACTIONS: + 'sqlite.xtream-delete.write-transactions', +} as const; + +export type XtreamDatabasePerformancePhase = + (typeof XTREAM_DATABASE_PERFORMANCE_PHASE)[keyof typeof XTREAM_DATABASE_PERFORMANCE_PHASE]; + +export type DatabaseWorkerPerformancePhase = + AppPlaylistPerformancePhase | XtreamDatabasePerformancePhase; export interface PerformancePhaseMetadata { readonly byteCount?: number; diff --git a/libs/shared/interfaces/src/lib/xtream-performance-marker.interface.ts b/libs/shared/interfaces/src/lib/xtream-performance-marker.interface.ts new file mode 100644 index 000000000..175787b45 --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-performance-marker.interface.ts @@ -0,0 +1,58 @@ +import { XtreamCodeActions } from './xtream-code-actions'; + +export const XTREAM_MAIN_PERFORMANCE_PHASE_EVENT_CHANNEL = + 'IPTVNATOR_XTREAM_MAIN_PERFORMANCE_PHASE_EVENT'; + +export const XTREAM_MAIN_PERFORMANCE_PHASE = { + CANCEL_SESSION: 'xtream.cancel-session', + JSON_TRANSFORM: 'xtream.json.transform', + NETWORK_TOTAL: 'xtream.network.total', + RESPONSE_READY: 'xtream.response.ready', +} as const; + +export type XtreamMainPerformancePhase = + (typeof XTREAM_MAIN_PERFORMANCE_PHASE)[keyof typeof XTREAM_MAIN_PERFORMANCE_PHASE]; + +export const XTREAM_PRELOAD_PERFORMANCE_MARKER_CHANNEL = + 'IPTVNATOR_XTREAM_PERF_CAPTURE_MARKER'; + +export const XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION = 1 as const; + +export const XTREAM_PRELOAD_PERFORMANCE_METHOD = { + DB_CANCEL_OPERATION: 'dbCancelOperation', + DB_DELETE_PLAYLIST: 'dbDeletePlaylist', + DB_DELETE_XTREAM_CONTENT: 'dbDeleteXtreamContent', + DB_GET_CATEGORIES: 'dbGetCategories', + DB_GET_CONTENT: 'dbGetContent', + DB_GLOBAL_SEARCH: 'dbGlobalSearch', + DB_RESTORE_XTREAM_USER_DATA: 'dbRestoreXtreamUserData', + DB_SAVE_CATEGORIES: 'dbSaveCategories', + DB_SAVE_CONTENT: 'dbSaveContent', + DB_SEARCH_CONTENT: 'dbSearchContent', + XTREAM_CANCEL_SESSION: 'xtreamCancelSession', + XTREAM_REQUEST: 'xtreamRequest', +} as const; + +export type XtreamPreloadPerformanceMethod = + (typeof XTREAM_PRELOAD_PERFORMANCE_METHOD)[keyof typeof XTREAM_PRELOAD_PERFORMANCE_METHOD]; + +export type XtreamPreloadPerformanceBoundary = 'start' | 'end'; +export type XtreamPreloadPerformanceOutcome = 'success' | 'error' | null; +export type XtreamPreloadPerformanceCategoryType = 'live' | 'movies' | 'series'; +export type XtreamPreloadPerformanceContentType = 'live' | 'movie' | 'series'; + +export interface XtreamPreloadPerformanceMarker { + schemaVersion: typeof XTREAM_PRELOAD_PERFORMANCE_SCHEMA_VERSION; + sourceEpochMs: number; + ipcCallId: number; + method: XtreamPreloadPerformanceMethod; + boundary: XtreamPreloadPerformanceBoundary; + outcome: XtreamPreloadPerformanceOutcome; + playlistId: string | null; + operationId: string | null; + sessionId: string | null; + action: XtreamCodeActions | null; + categoryType: XtreamPreloadPerformanceCategoryType | null; + contentType: XtreamPreloadPerformanceContentType | null; + itemCount: number | null; +} diff --git a/libs/shared/logging/src/lib/renderer-performance-phase.spec.ts b/libs/shared/logging/src/lib/renderer-performance-phase.spec.ts index ded5179e4..a9202e4b1 100644 --- a/libs/shared/logging/src/lib/renderer-performance-phase.spec.ts +++ b/libs/shared/logging/src/lib/renderer-performance-phase.spec.ts @@ -1,7 +1,16 @@ import * as sharedLogging from '../index'; type RendererPerformancePhase = - 'store.m3u-import-dispatch' | 'store.m3u-publish-channels'; + | 'store.m3u-import-dispatch' + | 'store.m3u-publish-channels' + | 'store.xtream-delete-row' + | 'store.xtream-import-terminal' + | 'store.xtream-publish-categories' + | 'store.xtream-publish-live' + | 'store.xtream-publish-series' + | 'store.xtream-publish-vod' + | 'store.xtream-refresh-meta' + | 'store.xtream-search-results'; type RendererPerformancePhaseOutcome = 'error' | 'success'; interface RendererPerformancePhaseEvent { @@ -28,11 +37,20 @@ interface RendererPerformancePhaseModule { readonly RENDERER_PERFORMANCE_PHASE?: Readonly<{ M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch'; M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels'; + XTREAM_DELETE_ROW: 'store.xtream-delete-row'; + XTREAM_IMPORT_TERMINAL: 'store.xtream-import-terminal'; + XTREAM_PUBLISH_CATEGORIES: 'store.xtream-publish-categories'; + XTREAM_PUBLISH_LIVE: 'store.xtream-publish-live'; + XTREAM_PUBLISH_SERIES: 'store.xtream-publish-series'; + XTREAM_PUBLISH_VOD: 'store.xtream-publish-vod'; + XTREAM_REFRESH_META: 'store.xtream-refresh-meta'; + XTREAM_SEARCH_RESULTS: 'store.xtream-search-results'; }>; readonly RENDERER_PERFORMANCE_PHASE_HOOK_KEY?: string; } -const rendererPerformance = sharedLogging as RendererPerformancePhaseModule; +const rendererPerformance = + sharedLogging as unknown as RendererPerformancePhaseModule; const EXPECTED_HOOK_KEY = 'iptvnator.renderer.performance-phase-hook.v1'; const hookSymbol = Symbol.for(EXPECTED_HOOK_KEY); @@ -175,6 +193,14 @@ describe('renderer performance phases', () => { expect(rendererPerformance.RENDERER_PERFORMANCE_PHASE).toEqual({ M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch', M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels', + XTREAM_DELETE_ROW: 'store.xtream-delete-row', + XTREAM_IMPORT_TERMINAL: 'store.xtream-import-terminal', + XTREAM_PUBLISH_CATEGORIES: 'store.xtream-publish-categories', + XTREAM_PUBLISH_LIVE: 'store.xtream-publish-live', + XTREAM_PUBLISH_SERIES: 'store.xtream-publish-series', + XTREAM_PUBLISH_VOD: 'store.xtream-publish-vod', + XTREAM_REFRESH_META: 'store.xtream-refresh-meta', + XTREAM_SEARCH_RESULTS: 'store.xtream-search-results', }); const events: RendererPerformancePhaseEvent[] = []; diff --git a/libs/shared/logging/src/lib/renderer-performance-phase.ts b/libs/shared/logging/src/lib/renderer-performance-phase.ts index d9a8b64ae..4671db8d6 100644 --- a/libs/shared/logging/src/lib/renderer-performance-phase.ts +++ b/libs/shared/logging/src/lib/renderer-performance-phase.ts @@ -4,6 +4,14 @@ export const RENDERER_PERFORMANCE_PHASE_HOOK_KEY = export const RENDERER_PERFORMANCE_PHASE = { M3U_IMPORT_DISPATCH: 'store.m3u-import-dispatch', M3U_PUBLISH_CHANNELS: 'store.m3u-publish-channels', + XTREAM_DELETE_ROW: 'store.xtream-delete-row', + XTREAM_IMPORT_TERMINAL: 'store.xtream-import-terminal', + XTREAM_PUBLISH_CATEGORIES: 'store.xtream-publish-categories', + XTREAM_PUBLISH_LIVE: 'store.xtream-publish-live', + XTREAM_PUBLISH_SERIES: 'store.xtream-publish-series', + XTREAM_PUBLISH_VOD: 'store.xtream-publish-vod', + XTREAM_REFRESH_META: 'store.xtream-refresh-meta', + XTREAM_SEARCH_RESULTS: 'store.xtream-search-results', } as const; export type RendererPerformancePhase =