diff --git a/.changes/electron-remote-static-paths.md b/.changes/electron-remote-static-paths.md new file mode 100644 index 000000000..70313de33 --- /dev/null +++ b/.changes/electron-remote-static-paths.md @@ -0,0 +1,7 @@ +--- +type: fix +area: electron +--- + +The desktop remote-control server now blocks crafted static paths from escaping +bundled web files on Windows. diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index abbac03eb..efc62626e 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -56,7 +56,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v4 @@ -322,7 +322,7 @@ jobs: test -s "${RUNTIME_ROOT}/source-archive-binding.json" - name: Upload staged Linux runtime - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-embedded-mpv-runtime path: vendor/embedded-mpv/linux-x64 @@ -330,7 +330,7 @@ jobs: retention-days: 7 - name: Upload Linux runtime source compliance - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-frame-copy-runtime-sources path: dist/compliance/linux-frame-copy-runtime-sources.tar.xz @@ -370,7 +370,7 @@ jobs: steps: &electron-build-steps - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v4 @@ -424,7 +424,7 @@ jobs: - name: Download pinned Linux Embedded MPV runtime if: matrix.os == 'linux' - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: linux-embedded-mpv-runtime path: vendor/embedded-mpv/linux-x64 @@ -1187,7 +1187,7 @@ jobs: - name: Upload artifacts (macOS) if: matrix.os == 'macos' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: macos-${{ matrix.arch }}-artifacts path: | @@ -1199,7 +1199,7 @@ jobs: - name: Upload system-runtime Linux artifacts if: matrix.os == 'linux' && matrix.linux_profile == 'system' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-system-artifacts path: | @@ -1211,7 +1211,7 @@ jobs: - name: Upload portable-runtime Linux artifacts if: matrix.os == 'linux' && matrix.linux_profile == 'portable' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-portable-artifacts path: | @@ -1223,7 +1223,7 @@ jobs: - name: Upload Flatpak-runtime Linux artifacts if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: linux-flatpak-artifacts path: | @@ -1232,7 +1232,7 @@ jobs: - name: Upload artifacts (Windows) if: matrix.os == 'windows' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: windows-artifacts path: | @@ -1294,10 +1294,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: artifacts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc86e2950..14f32433b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Image pinned by digest (tag 1.7.12). False positives are # suppressed in .github/actionlint.yaml; shellcheck runs at @@ -53,7 +53,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 # The gate scripts are dependency-free Node, so this job skips # pnpm install entirely and stays cheap. @@ -92,7 +92,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: # nx affected needs the merge-base with the PR target branch. fetch-depth: 0 @@ -133,7 +133,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v4 @@ -167,7 +167,7 @@ jobs: - name: Upload unit coverage artifact if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: unit-coverage path: | diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 498b03e7c..dfe88308a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 # Initializes the CodeQL tools for scanning. PR runs analyze the merge # commit checked out above (the modern default); JavaScript is diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 01a5143c7..aa235d701 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v4 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c68aaf8a7..dff445563 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -42,7 +42,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Prepare Docker metadata id: docker-meta diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index 62ed7c390..abd9d577a 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -49,7 +49,7 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v4 @@ -87,7 +87,7 @@ jobs: - name: Upload Electron Test Results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: playwright-report-electron-${{ matrix.os }} path: | @@ -103,7 +103,7 @@ jobs: NX_SKIP_NX_CACHE: true steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v4 @@ -131,7 +131,7 @@ jobs: - name: Upload Web Test Results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: playwright-report-web-ubuntu path: | diff --git a/.github/workflows/publish-snap.yaml b/.github/workflows/publish-snap.yaml index b07fe5469..d74213ec2 100644 --- a/.github/workflows/publish-snap.yaml +++ b/.github/workflows/publish-snap.yaml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout released tooling - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ github.event.release.tag_name }} persist-credentials: false @@ -121,7 +121,7 @@ jobs: printf 'receipt-sha256=%s\n' "${RECEIPT_SHA256}" >> "${GITHUB_OUTPUT}" - name: Transfer verified release assets - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: name: verified-snap-release-assets path: /var/lib/iptvnator-snap-release/assets @@ -139,7 +139,7 @@ jobs: steps: - name: Download verified release assets - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: name: verified-snap-release-assets path: ${{ runner.temp }}/verified-snap-release-assets diff --git a/AGENTS.md b/AGENTS.md index 014efff5b..c1b428902 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,7 +84,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_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset + - `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; 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 a29a20585..2e77dc8a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,7 +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_WORKER_PROFILING=1` enables development/test-only event-loop metrics in database and playlist-refresh worker responses; the performance benchmark sets it automatically, and production launches must leave it unset +- `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; 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 1918e34fa..9c1f45538 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -12,14 +12,21 @@ "e2e": { "dependsOn": ["electron-backend:build-e2e"] }, + "test-performance-harness": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec tsx --test src/performance/*.spec.ts" + } + }, "benchmark-m3u-refresh-cancellation": { - "dependsOn": ["electron-backend:build-e2e"], + "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" + "command": "pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts" } }, "packaged-frame-copy-smoke": { 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 new file mode 100644 index 000000000..2e9672718 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.spec.ts @@ -0,0 +1,229 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import test from 'node:test'; + +import { + installDatabaseRequestIdentityCaptureInMain, + type DatabaseRequestIdentityCaptureApi, +} from './database-request-identity-capture'; + +const CHANNEL = 'IPTVNATOR_PERF_CAPTURE_MARKER'; +let nextStateId = 1; + +function createCapture(): { + api: DatabaseRequestIdentityCaptureApi; + ipcMain: EventEmitter; +} { + const ipcMain = new EventEmitter(); + const stateKey = `__identityCapture${nextStateId}`; + nextStateId += 1; + installDatabaseRequestIdentityCaptureInMain({ ipcMain } as never, stateKey); + const api = (globalThis as unknown as Record)[ + stateKey + ] as DatabaseRequestIdentityCaptureApi; + api.start(); + return { api, ipcMain }; +} + +function marker(overrides: Record = {}) { + return { + correlationState: 'correlated', + invalidReason: null, + ipcCallId: 2, + method: 'dbGetAppPlaylist', + operationId: 'refresh-operation-1', + phase: 'start', + playlistId: 'playlist-1', + sourceEpochMs: 100, + ...overrides, + }; +} + +test('correlates the real preload marker to a DB request whose payload omits operationId', () => { + const { api, ipcMain } = createCapture(); + ipcMain.emit(CHANNEL, {}, marker()); + + const identity = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-1', + type: 'request', + }); + + assert.deepEqual(identity, { + operationId: 'refresh-operation-1', + operationIdUnavailableReason: null, + }); +}); + +test('resolves a response-retained request identity when the marker arrives later and resets between captures', () => { + const { api, ipcMain } = createCapture(); + const identity = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-before-marker', + type: 'request', + }); + assert.equal( + identity.operationIdUnavailableReason, + 'preload-performance-marker-missing' + ); + const responseOutcome = { identity }; + + ipcMain.emit(CHANNEL, {}, marker()); + assert.deepEqual(responseOutcome.identity, { + operationId: 'refresh-operation-1', + operationIdUnavailableReason: null, + }); + + api.stop(); + ipcMain.emit(CHANNEL, {}, marker({ operationId: 'marker-while-stopped' })); + api.start(); + const afterReset = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-after-reset', + type: 'request', + }); + assert.deepEqual(afterReset, { + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-missing', + }); +}); + +test('fails closed for missing and ambiguous preload markers', () => { + const missingCapture = createCapture(); + const missing = missingCapture.api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-missing', + type: 'request', + }); + assert.deepEqual(missing, { + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-missing', + }); + + const ambiguousCapture = createCapture(); + ambiguousCapture.ipcMain.emit(CHANNEL, {}, marker()); + ambiguousCapture.ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 3, + operationId: 'refresh-operation-2', + }) + ); + const ambiguous = ambiguousCapture.api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-ambiguous', + type: 'request', + }); + assert.deepEqual(ambiguous, { + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-ambiguous', + }); +}); + +test('quarantines a key after response-before-marker ambiguity', () => { + const { api, ipcMain } = createCapture(); + const requestA = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-a', + type: 'request', + }); + const requestB = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-b', + type: 'request', + }); + + ipcMain.emit( + CHANNEL, + {}, + marker({ ipcCallId: 2, operationId: 'refresh-operation-a' }) + ); + ipcMain.emit( + CHANNEL, + {}, + marker({ ipcCallId: 3, operationId: 'refresh-operation-b' }) + ); + + const requestC = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'request-c', + type: 'request', + }); + ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 4, + operationId: 'other-playlist-operation', + playlistId: 'playlist-2', + }) + ); + const otherPlaylist = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-2' }, + requestId: 'other-playlist-request', + type: 'request', + }); + ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 5, + method: 'dbUpsertAppPlaylist', + operationId: 'other-db-operation', + }) + ); + const otherOperation = api.matchDatabaseRequest({ + operation: 'DB_UPSERT_APP_PLAYLIST', + payload: { _id: 'playlist-1' }, + requestId: 'other-operation-request', + type: 'request', + }); + + const ambiguousIdentity = { + operationId: null, + operationIdUnavailableReason: 'preload-performance-marker-ambiguous', + }; + assert.deepEqual(requestA, ambiguousIdentity); + assert.deepEqual(requestB, ambiguousIdentity); + assert.deepEqual(requestC, ambiguousIdentity); + assert.deepEqual(otherPlaylist, { + operationId: 'other-playlist-operation', + operationIdUnavailableReason: null, + }); + assert.deepEqual(otherOperation, { + operationId: 'other-db-operation', + operationIdUnavailableReason: null, + }); + + api.stop(); + api.start(); + ipcMain.emit( + CHANNEL, + {}, + marker({ + ipcCallId: 6, + operationId: 'fresh-capture-operation', + }) + ); + const freshCaptureIdentity = api.matchDatabaseRequest({ + operation: 'DB_GET_APP_PLAYLIST', + payload: { playlistId: 'playlist-1' }, + requestId: 'fresh-capture-request', + type: 'request', + }); + assert.deepEqual(freshCaptureIdentity, { + operationId: 'fresh-capture-operation', + operationIdUnavailableReason: null, + }); +}); 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 new file mode 100644 index 000000000..a0912d3e3 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/database-request-identity-capture.ts @@ -0,0 +1,270 @@ +import type { ElectronApplication } from '@playwright/test'; + +export const DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY = + '__iptvnatorM3uRefreshDatabaseRequestIdentityCapture'; + +export interface DatabaseRequestIdentity { + operationId: string | null; + operationIdUnavailableReason: string | null; +} + +export interface DatabaseRequestIdentityCaptureApi { + matchDatabaseRequest(message: unknown): DatabaseRequestIdentity; + start(): void; + stop(): void; +} + +interface DatabaseRequestIdentityElectron { + readonly ipcMain: { + on( + channel: string, + listener: (event: unknown, marker: unknown) => void + ): void; + }; +} + +export function installDatabaseRequestIdentityCaptureInMain( + { ipcMain }: DatabaseRequestIdentityElectron, + stateKey: string +): void { + type JsonRecord = Record; + interface PendingMarker { + readonly ipcCallId: number; + readonly operation: string; + readonly operationId: string; + readonly playlistId: string; + } + interface PendingRequest { + readonly identity: DatabaseRequestIdentity; + readonly operation: string; + readonly playlistId: string; + } + interface QuarantinedKey { + readonly operation: string; + readonly playlistId: string; + } + + const target = globalThis as unknown as Record; + if (target[stateKey] !== undefined) { + return; + } + + const markerChannel = 'IPTVNATOR_PERF_CAPTURE_MARKER'; + const markerMethodToOperation: Readonly> = { + dbGetAppPlaylist: 'DB_GET_APP_PLAYLIST', + dbUpsertAppPlaylist: 'DB_UPSERT_APP_PLAYLIST', + }; + let active = false; + let pendingMarkers: PendingMarker[] = []; + let pendingRequests: PendingRequest[] = []; + let quarantinedKeys: QuarantinedKey[] = []; + + const readRecord = (value: unknown): JsonRecord | null => + typeof value === 'object' && value !== null + ? (value as JsonRecord) + : null; + const readPlaylistId = ( + operation: string, + payload: unknown + ): string | null => { + const value = readRecord(payload); + if (!value) { + return null; + } + const playlistId = + operation === 'DB_UPSERT_APP_PLAYLIST' + ? value['_id'] + : value['playlistId']; + return typeof playlistId === 'string' && playlistId.length > 0 + ? playlistId + : null; + }; + const matches = ( + candidate: { operation: string; playlistId: string }, + operation: string, + playlistId: string + ): boolean => + candidate.operation === operation && + candidate.playlistId === playlistId; + const markAmbiguous = (requests: PendingRequest[]): void => { + for (const request of requests) { + request.identity.operationId = null; + request.identity.operationIdUnavailableReason = + 'preload-performance-marker-ambiguous'; + } + }; + const isQuarantined = (operation: string, playlistId: string): boolean => + quarantinedKeys.some((key) => matches(key, operation, playlistId)); + const quarantine = (operation: string, playlistId: string): void => { + if (!isQuarantined(operation, playlistId)) { + quarantinedKeys.push({ operation, playlistId }); + } + const requestCandidates = pendingRequests.filter((request) => + matches(request, operation, playlistId) + ); + markAmbiguous(requestCandidates); + pendingRequests = pendingRequests.filter( + (request) => !matches(request, operation, playlistId) + ); + pendingMarkers = pendingMarkers.filter( + (marker) => !matches(marker, operation, playlistId) + ); + }; + + ipcMain.on(markerChannel, (_event, input) => { + if (!active) { + return; + } + const marker = readRecord(input); + if ( + !marker || + marker['phase'] !== 'start' || + marker['correlationState'] !== 'correlated' || + 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']; + if ( + operation === undefined || + typeof operationId !== 'string' || + operationId.length === 0 || + typeof playlistId !== 'string' || + playlistId.length === 0 || + !Number.isSafeInteger(ipcCallId) || + Number(ipcCallId) < 1 + ) { + return; + } + + if (isQuarantined(operation, playlistId)) { + return; + } + const requestCandidates = pendingRequests.filter((request) => + matches(request, operation, playlistId) + ); + if (requestCandidates.length === 1) { + const request = requestCandidates[0]; + if (!request) { + return; + } + request.identity.operationId = operationId; + request.identity.operationIdUnavailableReason = null; + pendingRequests = pendingRequests.filter( + (candidate) => candidate !== request + ); + return; + } + if (requestCandidates.length > 1) { + quarantine(operation, playlistId); + return; + } + + pendingMarkers.push({ + ipcCallId: Number(ipcCallId), + operation, + operationId, + playlistId, + }); + pendingMarkers.sort((left, right) => left.ipcCallId - right.ipcCallId); + }); + + const api: DatabaseRequestIdentityCaptureApi = { + matchDatabaseRequest: (input) => { + const message = readRecord(input); + const operation = message?.['operation']; + const playlistId = + typeof operation === 'string' + ? readPlaylistId(operation, message?.['payload']) + : null; + if ( + !active || + (operation !== 'DB_GET_APP_PLAYLIST' && + operation !== 'DB_UPSERT_APP_PLAYLIST') || + playlistId === null + ) { + return { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-not-applicable', + }; + } + + if (isQuarantined(operation, playlistId)) { + return { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-ambiguous', + }; + } + const markerCandidates = pendingMarkers.filter((marker) => + matches(marker, operation, playlistId) + ); + if (markerCandidates.length === 1) { + const marker = markerCandidates[0]; + if (!marker) { + return { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-missing', + }; + } + pendingMarkers = pendingMarkers.filter( + (candidate) => candidate !== marker + ); + return { + operationId: marker.operationId, + operationIdUnavailableReason: null, + }; + } + if (markerCandidates.length > 1) { + quarantine(operation, playlistId); + return { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-ambiguous', + }; + } + + const identity: DatabaseRequestIdentity = { + operationId: null, + operationIdUnavailableReason: + 'preload-performance-marker-missing', + }; + pendingRequests.push({ + identity, + operation, + playlistId, + }); + return identity; + }, + start: () => { + pendingMarkers = []; + pendingRequests = []; + quarantinedKeys = []; + active = true; + }, + stop: () => { + active = false; + pendingMarkers = []; + pendingRequests = []; + quarantinedKeys = []; + }, + }; + target[stateKey] = api; +} + +export async function installDatabaseRequestIdentityCapture( + electronApp: ElectronApplication +): Promise { + await electronApp.evaluate( + installDatabaseRequestIdentityCaptureInMain, + DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY + ); +} 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 f0abcd22f..344767515 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 @@ -48,6 +48,29 @@ export interface MainTimelineRecord { readonly type: string; } +export interface WorkerRequestPerformanceMetrics { + readonly eventLoopDelay: EventLoopDelayMetrics | null; + readonly eventLoopDelayUnavailableReason: string | null; + readonly eventLoopUtilization: number | null; + readonly eventLoopUtilizationUnavailableReason: string | null; + readonly histogramFlushedEpochMs: number | null; + readonly invalidReason: string | null; + readonly operation: string | null; + readonly operationId: string | null; + readonly operationIdUnavailableReason: string | null; + readonly performanceCaptureUnavailableReason: string | null; + readonly playlistId: string | null; + readonly requestId: string | null; + readonly requestReceivedEpochMs: number | null; + readonly responseEpochMs: number; + readonly success: boolean; + readonly threadCpuSystemMicros: number | null; + readonly threadCpuUnavailableReason: string | null; + readonly threadCpuUserMicros: number | null; + readonly workEndedEpochMs: number | null; + readonly workStartedEpochMs: number | null; +} + export interface WorkerCaptureMetrics { readonly cancelPostedEpochMs: number | null; readonly cpuSystemMicros: number | null; @@ -62,6 +85,7 @@ export interface WorkerCaptureMetrics { readonly playlistId: string | null; readonly postGcHeapUsedBytes: number | null; readonly profilePath: string | null; + readonly requests: readonly WorkerRequestPerformanceMetrics[]; readonly responseEpochMs: number | null; readonly snapshotPath: string | null; readonly terminatedEpochMs: number | null; @@ -176,13 +200,15 @@ export interface CancellationBenchmarkSummary { readonly cancelToDurableTerminalMs: NumericDistribution; readonly cancelToWorkerTerminatedMs: NumericDistribution; readonly cancelTransportLatencyMs: NumericDistribution; - readonly databaseWorkerEventLoopDelayMaxMs: NumericDistribution; - readonly databaseWorkerEventLoopDelayP95Ms: NumericDistribution; - readonly databaseWorkerEventLoopDelayP99Ms: NumericDistribution; - readonly databaseWorkerEventLoopUtilization: NumericDistribution; readonly databaseWorkerExternalPeakBytes: NumericDistribution; readonly databaseWorkerHeapPeakBytes: NumericDistribution; readonly databaseWorkerPostGcHeapBytes: NumericDistribution; + readonly databaseWorkerRequestEventLoopDelayMaxMs: NumericDistribution; + readonly databaseWorkerRequestEventLoopDelayP95Ms: NumericDistribution; + readonly databaseWorkerRequestEventLoopDelayP99Ms: NumericDistribution; + readonly databaseWorkerRequestEventLoopUtilization: NumericDistribution; + readonly databaseWorkerRequestThreadCpuSystemMicros: NumericDistribution; + readonly databaseWorkerRequestThreadCpuUserMicros: NumericDistribution; readonly dataFetchMs: NumericDistribution; readonly dbCommitProxyMs: NumericDistribution; readonly ipcStoreDispatchProxyMs: NumericDistribution; @@ -196,13 +222,15 @@ export interface CancellationBenchmarkSummary { readonly mainRssPostGcBytes: NumericDistribution; readonly parseNormalizeCloneProxyMs: NumericDistribution; readonly persistencePreparationProxyMs: NumericDistribution; - readonly playlistWorkerEventLoopUtilization: NumericDistribution; - readonly playlistWorkerEventLoopDelayMaxMs: NumericDistribution; - readonly playlistWorkerEventLoopDelayP95Ms: NumericDistribution; - readonly playlistWorkerEventLoopDelayP99Ms: NumericDistribution; readonly playlistWorkerExternalPeakBytes: NumericDistribution; readonly playlistWorkerHeapPeakBytes: NumericDistribution; readonly playlistWorkerPostGcHeapBytes: NumericDistribution; + readonly playlistWorkerRequestEventLoopDelayMaxMs: NumericDistribution; + readonly playlistWorkerRequestEventLoopDelayP95Ms: NumericDistribution; + readonly playlistWorkerRequestEventLoopDelayP99Ms: NumericDistribution; + readonly playlistWorkerRequestEventLoopUtilization: NumericDistribution; + readonly playlistWorkerRequestThreadCpuSystemMicros: NumericDistribution; + readonly playlistWorkerRequestThreadCpuUserMicros: NumericDistribution; readonly rendererFrameGapMs: NumericDistribution; readonly rendererHeartbeatDelayMs: NumericDistribution; readonly rendererHeapPeakBytes: NumericDistribution; 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 2f063cd0a..7a8e4c220 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,6 +9,7 @@ import type { NumericDistribution, PerformanceWorkerKind, RendererCaptureMetrics, + WorkerRequestPerformanceMetrics, } from './m3u-refresh-cancellation-contract'; import { PERFORMANCE_ITERATION_KIND, @@ -63,15 +64,28 @@ export function createCancellationBenchmarkSummary( return worker ? select(worker) : null; }) ); - const workerEventLoopDelayMetric = ( + const workerRequestMetric = ( + kind: PerformanceWorkerKind, + select: (request: WorkerRequestPerformanceMetrics) => number | null + ): NumericDistribution => + summarizeNumbers( + measured.flatMap((iteration) => + iteration.main.workers + .filter((worker) => worker.kind === kind) + .flatMap((worker) => + worker.requests.map((request) => select(request)) + ) + ) + ); + const workerRequestEventLoopDelayMetric = ( kind: PerformanceWorkerKind, percentile: keyof NonNullable< - MainCaptureMetrics['workers'][number]['eventLoopDelay'] + WorkerRequestPerformanceMetrics['eventLoopDelay'] > ): NumericDistribution => - workerMetric( + workerRequestMetric( kind, - (worker) => worker.eventLoopDelay?.[percentile] ?? null + (request) => request.eventLoopDelay?.[percentile] ?? null ); return Object.freeze({ @@ -105,22 +119,6 @@ export function createCancellationBenchmarkSummary( (iteration) => iteration.phases.cancelTransportLatencyMs ) ), - databaseWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - 'maxMs' - ), - databaseWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - 'p95Ms' - ), - databaseWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - 'p99Ms' - ), - databaseWorkerEventLoopUtilization: workerMetric( - PERFORMANCE_WORKER_KIND.DATABASE, - (worker) => worker.eventLoopUtilization - ), databaseWorkerExternalPeakBytes: workerMetric( PERFORMANCE_WORKER_KIND.DATABASE, (worker) => worker.peakExternalBytes @@ -133,6 +131,33 @@ export function createCancellationBenchmarkSummary( PERFORMANCE_WORKER_KIND.DATABASE, (worker) => worker.postGcHeapUsedBytes ), + databaseWorkerRequestEventLoopDelayMaxMs: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'maxMs' + ), + databaseWorkerRequestEventLoopDelayP95Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'p95Ms' + ), + databaseWorkerRequestEventLoopDelayP99Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + 'p99Ms' + ), + databaseWorkerRequestEventLoopUtilization: workerRequestMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (request) => request.eventLoopUtilization + ), + databaseWorkerRequestThreadCpuSystemMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (request) => request.threadCpuSystemMicros + ), + databaseWorkerRequestThreadCpuUserMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.DATABASE, + (request) => request.threadCpuUserMicros + ), dataFetchMs: summarizeNumbers( measured.map((iteration) => iteration.phases.dataFetchMs) ), @@ -185,22 +210,6 @@ export function createCancellationBenchmarkSummary( iteration.phases.persistencePreparationProxyMs ) ), - playlistWorkerEventLoopUtilization: workerMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - (worker) => worker.eventLoopUtilization - ), - playlistWorkerEventLoopDelayMaxMs: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - 'maxMs' - ), - playlistWorkerEventLoopDelayP95Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - 'p95Ms' - ), - playlistWorkerEventLoopDelayP99Ms: workerEventLoopDelayMetric( - PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, - 'p99Ms' - ), playlistWorkerExternalPeakBytes: workerMetric( PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, (worker) => worker.peakExternalBytes @@ -213,6 +222,33 @@ export function createCancellationBenchmarkSummary( PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, (worker) => worker.postGcHeapUsedBytes ), + playlistWorkerRequestEventLoopDelayMaxMs: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'maxMs' + ), + playlistWorkerRequestEventLoopDelayP95Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'p95Ms' + ), + playlistWorkerRequestEventLoopDelayP99Ms: + workerRequestEventLoopDelayMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + 'p99Ms' + ), + playlistWorkerRequestEventLoopUtilization: workerRequestMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (request) => request.eventLoopUtilization + ), + playlistWorkerRequestThreadCpuSystemMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (request) => request.threadCpuSystemMicros + ), + playlistWorkerRequestThreadCpuUserMicros: workerRequestMetric( + PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + (request) => request.threadCpuUserMicros + ), rendererFrameGapMs: summarizeNumbers( flattenRenderer( (iteration) => iteration.renderer.probe.frameGapsMs 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 fcfd83d1f..ce4cd5e0b 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 @@ -166,6 +166,7 @@ async function runIteration( ], 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', 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 fd9435445..f61fa12b4 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,7 +1,17 @@ /* eslint-disable max-lines -- The injected main-process protocol must remain self-contained for Playwright serialization. */ import type { ElectronApplication } from '@playwright/test'; +import { + DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY, + installDatabaseRequestIdentityCapture, + type DatabaseRequestIdentity, + type DatabaseRequestIdentityCaptureApi, +} from './database-request-identity-capture'; import type { MainCaptureMetrics } from './m3u-refresh-cancellation-contract'; +import { + selectMainCaptureGeneration, + type MainCaptureGenerationTransport, +} from './worker-request-performance'; const MAIN_CAPTURE_STATE_KEY = '__iptvnatorM3uRefreshMainCapture'; @@ -23,7 +33,13 @@ export interface MainCaptureStatus { export async function installMainCapture( electronApp: ElectronApplication ): Promise { - await electronApp.evaluate(async ({ app, BrowserWindow }, stateKey) => { + await installDatabaseRequestIdentityCapture(electronApp); + const captureStateKeys = { + databaseRequestIdentityStateKey: + DATABASE_REQUEST_IDENTITY_CAPTURE_STATE_KEY, + stateKey: MAIN_CAPTURE_STATE_KEY, + }; + await electronApp.evaluate(async ({ app, BrowserWindow }, input) => { type JsonRecord = Record; type WorkerTransferable = import('node:worker_threads').Transferable; interface CpuProfileHandle { @@ -42,6 +58,15 @@ export async function installMainCapture( idle: number; utilization: number; } + interface WorkerRequestPerformance { + identity: DatabaseRequestIdentity; + operation: string | null; + performanceCapture: unknown; + playlistId: string | null; + requestId: string | null; + responseEpochMs: number; + success: boolean; + } interface InstrumentedWorker { cpuUsage?(): Promise; getHeapSnapshot?(): Promise; @@ -62,15 +87,11 @@ export async function installMainCapture( } interface WorkerRecord { cancelPostedEpochMs: number | null; + captureGeneration: number | null; cpuFirst: WorkerCpuUsage | null; cpuLast: WorkerCpuUsage | null; elu: number | null; eluStart: WorkerElu | null; - eventLoopDelay: { - maxMs: number; - p95Ms: number; - p99Ms: number; - } | null; externalPeak: number; finalized: boolean; finalizing: Promise | null; @@ -81,6 +102,7 @@ export async function installMainCapture( postGcHeapUsed: number | null; profileHandle: Promise | null; profilePath: string | null; + requestPerformance: WorkerRequestPerformance[]; responseEpochMs: number | null; sampleBusy: boolean; sampleTimer: NodeJS.Timeout | null; @@ -99,9 +121,12 @@ export async function installMainCapture( } const target = globalThis as unknown as Record; - if (target[stateKey] !== undefined) { + if (target[input.stateKey] !== undefined) { return; } + const databaseRequestIdentityCapture = target[ + input.databaseRequestIdentityStateKey + ] as DatabaseRequestIdentityCaptureApi; const runtimeProcess = process as typeof process & { getBuiltinModule(id: string): unknown; @@ -132,6 +157,7 @@ export async function installMainCapture( const dbRequests = new Map< string, { + identity: DatabaseRequestIdentity; operation: string; playlistId: string | null; record: WorkerRecord; @@ -140,6 +166,7 @@ export async function installMainCapture( const state = { active: false, + captureGeneration: 0, cpuStart: null as NodeJS.CpuUsage | null, diagnostic: false, eventLoopDelay: null as ReturnType< @@ -200,6 +227,9 @@ export async function installMainCapture( const playlistId = value['playlistId'] ?? value['_id']; return typeof playlistId === 'string' ? playlistId : null; }; + const isCurrentCaptureRecord = (record: WorkerRecord): boolean => + state.active && + record.captureGeneration === state.captureGeneration; const createWorkerRecord = ( worker: InstrumentedWorker, kind: WorkerRecord['kind'] @@ -210,11 +240,11 @@ export async function installMainCapture( } const record: WorkerRecord = { cancelPostedEpochMs: null, + captureGeneration: null, cpuFirst: null, cpuLast: null, elu: null, eluStart: null, - eventLoopDelay: null, externalPeak: 0, finalized: false, finalizing: null, @@ -225,6 +255,7 @@ export async function installMainCapture( postGcHeapUsed: null, profileHandle: null, profilePath: null, + requestPerformance: [], responseEpochMs: null, sampleBusy: false, sampleTimer: null, @@ -238,30 +269,13 @@ export async function installMainCapture( return; } const message = incoming as JsonRecord; - const performanceCapture = readWorkerPerformance(message); - if (performanceCapture) { - record.eventLoopDelay = record.eventLoopDelay - ? { - maxMs: Math.max( - record.eventLoopDelay.maxMs, - performanceCapture.eventLoopDelay.maxMs - ), - p95Ms: Math.max( - record.eventLoopDelay.p95Ms, - performanceCapture.eventLoopDelay.p95Ms - ), - p99Ms: Math.max( - record.eventLoopDelay.p99Ms, - performanceCapture.eventLoopDelay.p99Ms - ), - } - : performanceCapture.eventLoopDelay; + if (!isCurrentCaptureRecord(record)) { + return; } if (record.kind === 'playlist-refresh.worker') { if (message['type'] === 'event') { const event = message['event'] as - | JsonRecord - | undefined; + JsonRecord | undefined; recordTimeline({ operationId: record.operationId ?? undefined, playlistId: record.playlistId ?? undefined, @@ -270,7 +284,20 @@ export async function installMainCapture( )}:${String(event?.['phase'] ?? 'none')}`, }); } else if (message['type'] === 'response') { - record.responseEpochMs = nowEpochMs(); + const responseEpochMs = nowEpochMs(); + record.responseEpochMs = responseEpochMs; + record.requestPerformance.push({ + identity: { + operationId: record.operationId, + operationIdUnavailableReason: null, + }, + operation: null, + performanceCapture: message['performance'] ?? null, + playlistId: record.playlistId, + requestId: null, + responseEpochMs, + success: message['success'] === true, + }); recordTimeline({ operationId: record.operationId ?? undefined, playlistId: record.playlistId ?? undefined, @@ -285,10 +312,23 @@ export async function installMainCapture( typeof message['requestId'] === 'string' ) { const request = dbRequests.get(message['requestId']); + if (request) { + record.requestPerformance.push({ + identity: request.identity, + operation: request.operation, + performanceCapture: message['performance'] ?? null, + playlistId: request.playlistId, + requestId: message['requestId'], + responseEpochMs: nowEpochMs(), + success: message['success'] === true, + }); + } if (request) { dbRequests.delete(message['requestId']); recordTimeline({ operation: request.operation, + operationId: + request.identity.operationId ?? undefined, playlistId: request.playlistId ?? undefined, requestId: message['requestId'], success: message['success'] === true, @@ -299,46 +339,6 @@ export async function installMainCapture( }); return record; }; - const readWorkerPerformance = ( - message: JsonRecord - ): { - eventLoopDelay: { - maxMs: number; - p95Ms: number; - p99Ms: number; - }; - } | null => { - const performanceCapture = message['performance']; - if ( - typeof performanceCapture !== 'object' || - performanceCapture === null - ) { - return null; - } - const eventLoopDelay = (performanceCapture as JsonRecord)[ - 'eventLoopDelay' - ]; - if (typeof eventLoopDelay !== 'object' || eventLoopDelay === null) { - return null; - } - const value = eventLoopDelay as JsonRecord; - const maxMs = value['maxMs']; - const p95Ms = value['p95Ms']; - const p99Ms = value['p99Ms']; - if ( - typeof maxMs !== 'number' || - !Number.isFinite(maxMs) || - typeof p95Ms !== 'number' || - !Number.isFinite(p95Ms) || - typeof p99Ms !== 'number' || - !Number.isFinite(p99Ms) - ) { - return null; - } - return { - eventLoopDelay: { maxMs, p95Ms, p99Ms }, - }; - }; const sampleWorker = async (record: WorkerRecord): Promise => { if (record.sampleBusy || record.finalized) { return; @@ -373,14 +373,45 @@ export async function installMainCapture( record.sampleBusy = false; } }; + const resetWorkerForCapture = (record: WorkerRecord): void => { + if (record.sampleTimer) { + clearInterval(record.sampleTimer); + } + record.cancelPostedEpochMs = null; + record.captureGeneration = state.captureGeneration; + record.cpuFirst = null; + record.cpuLast = null; + record.elu = null; + record.eluStart = null; + record.externalPeak = 0; + record.finalized = false; + record.finalizing = null; + record.heapPeak = 0; + record.operationId = null; + record.playlistId = null; + record.postGcHeapUsed = null; + record.profileHandle = null; + record.profilePath = null; + record.requestPerformance = []; + record.responseEpochMs = null; + record.sampleBusy = false; + record.sampleTimer = null; + record.snapshotPath = null; + record.terminatedEpochMs = null; + }; const startWorker = (record: WorkerRecord): void => { - if (!state.active || record.sampleTimer !== null) { + if (!state.active) { + return; + } + if (record.captureGeneration !== state.captureGeneration) { + resetWorkerForCapture(record); + } + if (record.sampleTimer !== null) { return; } record.elu = null; record.eluStart = record.worker.performance?.eventLoopUtilization() ?? null; - record.eventLoopDelay = null; void sampleWorker(record); record.sampleTimer = setInterval( () => void sampleWorker(record), @@ -456,7 +487,11 @@ export async function installMainCapture( const kind = classifyRequest(value); if (kind) { const record = createWorkerRecord(this, kind); - if (kind === 'playlist-refresh.worker') { + startWorker(record); + if ( + isCurrentCaptureRecord(record) && + kind === 'playlist-refresh.worker' + ) { const payload = value['payload'] as JsonRecord; record.operationId = String(payload['operationId']); record.playlistId = String(payload['playlistId']); @@ -467,23 +502,47 @@ export async function installMainCapture( type: 'playlist-request', }); } else if ( + isCurrentCaptureRecord(record) && typeof value['requestId'] === 'string' && typeof value['operation'] === 'string' ) { + const payload = value['payload']; + const payloadOperationId = + typeof payload === 'object' && + payload !== null && + typeof (payload as JsonRecord)[ + 'operationId' + ] === 'string' && + String((payload as JsonRecord)['operationId']) + .length > 0 + ? String( + (payload as JsonRecord)['operationId'] + ) + : null; + const identity = + payloadOperationId === null + ? databaseRequestIdentityCapture.matchDatabaseRequest( + value + ) + : { + operationId: payloadOperationId, + operationIdUnavailableReason: null, + }; dbRequests.set(value['requestId'], { + identity, operation: value['operation'], playlistId: readDatabasePlaylistId(value), record, }); recordTimeline({ operation: value['operation'], + operationId: identity.operationId ?? undefined, playlistId: readDatabasePlaylistId(value) ?? undefined, requestId: value['requestId'], type: 'db-request', }); } - startWorker(record); } } else if ( value['type'] === 'cancel' && @@ -505,7 +564,7 @@ export async function installMainCapture( this: InstrumentedWorker ): Promise { const record = records.get(this); - if (!record || !state.active) { + if (!record || !isCurrentCaptureRecord(record)) { return originalTerminate.call(this); } const markTerminated = (code: number): number => { @@ -627,6 +686,7 @@ export async function installMainCapture( ).length, playlistTerminated: [...records.values()].filter( (record) => + isCurrentCaptureRecord(record) && record.kind === 'playlist-refresh.worker' && record.terminatedEpochMs !== null ).length, @@ -645,7 +705,11 @@ export async function installMainCapture( ).length, }), start: async (options: MainCaptureStartOptions): Promise => { + state.captureGeneration += 1; state.active = true; + databaseRequestIdentityCapture.start(); + dbRequests.clear(); + operationWorkers.clear(); state.diagnostic = options.diagnostic; state.outputDirectory = options.outputDirectory; state.timeline = []; @@ -682,7 +746,7 @@ export async function installMainCapture( ); } }, - stop: async (): Promise => { + stop: async (): Promise => { if (state.sampleTimer) { clearInterval(state.sampleTimer); state.sampleTimer = null; @@ -705,6 +769,7 @@ export async function installMainCapture( const delay = state.eventLoopDelay; const databaseRecords = [...records.values()].filter( (record) => + record.captureGeneration === state.captureGeneration && record.kind === 'database.worker' && record.sampleTimer !== null ); @@ -756,78 +821,91 @@ export async function installMainCapture( } state.windowListeners = []; state.active = false; + databaseRequestIdentityCapture.stop(); const workers = [...records.values()] .filter( (record) => record.kind === 'playlist-refresh.worker' || - record.heapPeak > 0 + record.heapPeak > 0 || + record.requestPerformance.length > 0 ) .map((record) => ({ - cancelPostedEpochMs: record.cancelPostedEpochMs, - cpuSystemMicros: - record.cpuFirst && record.cpuLast - ? record.cpuLast.system - record.cpuFirst.system - : null, - cpuUserMicros: - record.cpuFirst && record.cpuLast - ? record.cpuLast.user - record.cpuFirst.user - : null, - eventLoopDelay: record.eventLoopDelay, - eventLoopDelayUnavailableReason: - record.eventLoopDelay === null - ? record.terminatedEpochMs !== null - ? 'worker-terminated-before-profile-flush' - : 'worker-self-profile-result-unavailable' - : null, - eventLoopUtilization: record.elu, - kind: record.kind, - operationId: record.operationId, - peakExternalBytes: record.externalPeak, - peakHeapUsedBytes: record.heapPeak, - playlistId: record.playlistId, - postGcHeapUsedBytes: record.postGcHeapUsed, - profilePath: record.profilePath, - responseEpochMs: record.responseEpochMs, - snapshotPath: record.snapshotPath, - terminatedEpochMs: record.terminatedEpochMs, + captureGeneration: record.captureGeneration, + metrics: { + cancelPostedEpochMs: record.cancelPostedEpochMs, + cpuSystemMicros: + record.cpuFirst && record.cpuLast + ? record.cpuLast.system - + record.cpuFirst.system + : null, + cpuUserMicros: + record.cpuFirst && record.cpuLast + ? record.cpuLast.user - record.cpuFirst.user + : null, + eventLoopUtilization: record.elu, + kind: record.kind, + operationId: record.operationId, + peakExternalBytes: record.externalPeak, + peakHeapUsedBytes: record.heapPeak, + playlistId: record.playlistId, + postGcHeapUsedBytes: record.postGcHeapUsed, + profilePath: record.profilePath, + responseEpochMs: record.responseEpochMs, + snapshotPath: record.snapshotPath, + terminatedEpochMs: record.terminatedEpochMs, + }, + requests: record.requestPerformance.map((request) => ({ + operation: request.operation, + operationId: request.identity.operationId, + operationIdUnavailableReason: + request.identity.operationIdUnavailableReason, + performanceCapture: request.performanceCapture, + playlistId: request.playlistId, + requestId: request.requestId, + responseEpochMs: request.responseEpochMs, + success: request.success, + })), })); return { - cpuProfilePath: state.mainProfilePath, - cpuSystemMicros: cpu.system, - cpuUserMicros: cpu.user, - eventLoopDelay: { - maxMs: Number(delay?.max ?? 0) / 1e6, - p95Ms: Number(delay?.percentile(95) ?? 0) / 1e6, - p99Ms: Number(delay?.percentile(99) ?? 0) / 1e6, + captureGeneration: state.captureGeneration, + metrics: { + cpuProfilePath: state.mainProfilePath, + cpuSystemMicros: cpu.system, + cpuUserMicros: cpu.user, + eventLoopDelay: { + maxMs: Number(delay?.max ?? 0) / 1e6, + p95Ms: Number(delay?.percentile(95) ?? 0) / 1e6, + p99Ms: Number(delay?.percentile(99) ?? 0) / 1e6, + }, + // Electron's Chromium-owned main message pump currently + // leaves Node's libuv ELU counters at zero. Preserve that + // as unavailable instead of reporting a misleading 0%. + eventLoopUtilization, + eventLoopUtilizationUnavailableReason: + eventLoopUtilization === null + ? 'electron-main-embedded-event-loop' + : null, + heapSnapshotPath: state.mainSnapshotPath, + memory: { + peakHeapUsedBytes: state.mainPeakHeap, + peakRssBytes: state.mainPeakRss, + postGcHeapUsedBytes: state.postGcHeap, + postGcRssBytes: state.postGcRss, + }, + rendererPeakRssBytes: state.rendererPeakRss, + responsiveEvents: state.responsiveEvents, + rssScope: + 'electron-main-process-including-worker-threads-and-native-memory', + timeline: state.timeline, + unresponsiveEvents: state.unresponsiveEvents, }, - // Electron's Chromium-owned main message pump currently - // leaves Node's libuv ELU counters at zero. Preserve that - // as unavailable instead of reporting a misleading 0%. - eventLoopUtilization, - eventLoopUtilizationUnavailableReason: - eventLoopUtilization === null - ? 'electron-main-embedded-event-loop' - : null, - heapSnapshotPath: state.mainSnapshotPath, - memory: { - peakHeapUsedBytes: state.mainPeakHeap, - peakRssBytes: state.mainPeakRss, - postGcHeapUsedBytes: state.postGcHeap, - postGcRssBytes: state.postGcRss, - }, - rendererPeakRssBytes: state.rendererPeakRss, - responsiveEvents: state.responsiveEvents, - rssScope: - 'electron-main-process-including-worker-threads-and-native-memory', - timeline: state.timeline, - unresponsiveEvents: state.unresponsiveEvents, workers, }; }, }; - target[stateKey] = api; - }, MAIN_CAPTURE_STATE_KEY); + target[input.stateKey] = api; + }, captureStateKeys); } export async function startMainCapture( @@ -861,11 +939,15 @@ export async function readMainCaptureStatus( export async function stopMainCapture( electronApp: ElectronApplication ): Promise { - return 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 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 + ); + return selectMainCaptureGeneration(transport); } 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 new file mode 100644 index 000000000..c854a5568 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/performance-build-config.spec.ts @@ -0,0 +1,259 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based configuration contract tests. */ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { join } from 'node:path'; + +interface TargetConfiguration { + configurations?: Record>; + dependsOn?: unknown; + executor?: unknown; + options?: Record; +} + +interface ProjectConfiguration { + targets: Record; +} + +interface NxGraphTask { + outputs?: string[]; + target: { + project: string; + target: string; + }; +} + +interface NxGraph { + tasks: { + tasks: Record; + }; +} + +const workspaceRoot = fileURLToPath(new URL('../../../../', import.meta.url)); + +function readProject(relativePath: string): ProjectConfiguration { + return JSON.parse( + readFileSync(join(workspaceRoot, relativePath), 'utf8') + ) as ProjectConfiguration; +} + +function readResolvedWebBuildTask(): NxGraphTask | undefined { + const environment = { + ...process.env, + FORCE_COLOR: '0', + NX_DAEMON: 'false', + }; + delete environment['NO_COLOR']; + + const graph = JSON.parse( + execFileSync( + process.execPath, + [ + join(workspaceRoot, 'node_modules/nx/dist/bin/nx.js'), + 'run', + 'web:build', + '--graph=stdout', + ], + { + cwd: workspaceRoot, + encoding: 'utf8', + env: environment, + } + ) + ) as NxGraph; + + return Object.values(graph.tasks.tasks).find( + (task) => + task.target.project === 'web' && task.target.target === 'build' + ); +} + +const e2eProject = readProject('apps/electron-backend-e2e/project.json'); +const electronProject = readProject('apps/electron-backend/project.json'); +const remoteControlProject = readProject( + 'apps/remote-control-web/project.json' +); +const webProject = readProject('apps/web/project.json'); + +test('the performance harness target runs only Node performance specs', () => { + const target = e2eProject.targets['test-performance-harness']; + + assert.ok( + target, + 'electron-backend-e2e must define test-performance-harness' + ); + assert.equal(target.executor, 'nx:run-commands'); + assert.equal(target.options?.['cwd'], 'apps/electron-backend-e2e'); + assert.equal( + target.options?.['command'], + 'pnpm exec tsx --test src/performance/*.spec.ts' + ); +}); + +test('the web performance build keeps production renderer behavior with profiling source maps', () => { + const build = webProject.targets['build']; + const production = build.configurations?.['production']; + const performance = build.configurations?.['electron-performance']; + + assert.ok(production, 'web:build must define production'); + assert.ok(performance, 'web:build must define electron-performance'); + assert.equal(performance['baseHref'], './'); + assert.equal(performance['serviceWorker'], false); + assert.deepEqual(performance['optimization'], production['optimization']); + assert.equal(performance['outputHashing'], production['outputHashing']); + assert.deepEqual( + performance['fileReplacements'], + production['fileReplacements'] + ); + assert.equal(performance['sourceMap'], true); +}); + +test('the resolved web build cache output is the renderer directory', () => { + const task = readResolvedWebBuildTask(); + + assert.ok(task, 'the Nx graph must include web:build'); + assert.deepEqual(task.outputs, ['dist/apps/web']); +}); + +test('the remote-control performance build keeps optimized hashed output with profiling source maps', () => { + const build = remoteControlProject.targets['build']; + const production = build.configurations?.['production']; + const performance = build.configurations?.['electron-performance']; + + assert.ok(production, 'remote-control-web:build must define production'); + assert.ok( + performance, + 'remote-control-web:build must define electron-performance' + ); + assert.equal(performance['optimization'], true); + assert.equal(performance['outputHashing'], production['outputHashing']); + assert.equal(performance['sourceMap'], true); +}); + +test('the Electron performance build keeps production main-process behavior with profiling source maps', () => { + const build = electronProject.targets['build']; + const production = build.configurations?.['production']; + const performance = build.configurations?.['electron-performance']; + + assert.ok(production, 'electron-backend:build must define production'); + assert.ok( + performance, + 'electron-backend:build must define electron-performance' + ); + assert.equal(performance['optimization'], production['optimization']); + assert.equal(performance['inspect'], production['inspect']); + assert.deepEqual( + performance['fileReplacements'], + production['fileReplacements'] + ); + assert.equal(performance['sourceMap'], true); +}); + +test('the regular Electron build keeps its existing renderer dependency contract', () => { + const build = electronProject.targets['build']; + const dependencies = build.dependsOn as Array< + Record | string + >; + const rendererBuild = dependencies.find( + (dependency): dependency is Record => + typeof dependency === 'object' && + dependency !== null && + dependency['target'] === 'build' + ); + + assert.deepEqual(rendererBuild, { + projects: ['web', 'remote-control-web'], + target: 'build', + }); +}); + +test('the web performance wrapper selects the profiling configuration', () => { + const target = webProject.targets['build-performance']; + + assert.ok(target, 'web must define build-performance'); + assert.equal(target.executor, 'nx:run-commands'); + assert.equal( + target.options?.['command'], + 'pnpm nx run web:build:electron-performance' + ); +}); + +test('the remote-control performance wrapper selects the profiling configuration', () => { + const target = remoteControlProject.targets['build-performance']; + + assert.ok(target, 'remote-control-web must define build-performance'); + assert.equal(target.executor, 'nx:run-commands'); + assert.equal( + target.options?.['command'], + 'pnpm nx run remote-control-web:build:electron-performance' + ); +}); + +test('the Electron performance wrapper owns build dependencies before its profiled build', () => { + const target = electronProject.targets['build-performance']; + + assert.ok(target, 'electron-backend must define build-performance'); + assert.equal(target.executor, 'nx:run-commands'); + assert.deepEqual(target.dependsOn, [ + 'electron-backend:build-worker-performance', + 'electron-backend:build-embedded-mpv', + { + projects: ['web', 'remote-control-web'], + target: 'build-performance', + }, + ]); + assert.equal( + target.options?.['command'], + 'pnpm nx run electron-backend:build:electron-performance --excludeTaskDependencies' + ); +}); + +test('the Electron performance wrapper builds optimized source-mapped workers', () => { + const target = electronProject.targets['build-worker-performance']; + const source = readFileSync( + join(workspaceRoot, 'apps/electron-backend/build-worker.js'), + 'utf8' + ); + + assert.ok(target, 'electron-backend must define build-worker-performance'); + assert.equal(target.executor, 'nx:run-commands'); + assert.equal( + target.options?.['command'], + 'node apps/electron-backend/build-worker.js --performance' + ); + assert.match( + source, + /const isPerformance = process\.argv\.includes\('--performance'\)/ + ); + assert.match(source, /minify: isProduction \|\| isPerformance/); + assert.match(source, /sourcemap: isPerformance \|\| !isProduction/); +}); + +test('the cancellation benchmark uses the production-equivalent performance build', () => { + const target = e2eProject.targets['benchmark-m3u-refresh-cancellation']; + + assert.deepEqual(target.dependsOn, ['electron-backend:build-performance']); +}); + +test('the cancellation benchmark command is pinned to its Playwright test file', () => { + const target = e2eProject.targets['benchmark-m3u-refresh-cancellation']; + + assert.equal( + target.options?.['command'], + 'pnpm exec playwright test --config=playwright.performance.config.ts src/m3u-refresh-cancellation.performance.ts' + ); +}); + +test('the cancellation benchmark enables preload performance capture', () => { + const source = readFileSync( + join( + workspaceRoot, + 'apps/electron-backend-e2e/src/performance/m3u-refresh-cancellation.benchmark.ts' + ), + 'utf8' + ); + + assert.match(source, /IPTVNATOR_PERF_CAPTURE:\s*'1'/); +}); 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 new file mode 100644 index 000000000..e87e3c345 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance-envelope.spec.ts @@ -0,0 +1,172 @@ +/* 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 type { WorkerRequestPerformanceMetrics } from './m3u-refresh-cancellation-contract'; +import { normalizeWorkerRequestPerformanceOutcome } from './worker-request-performance'; + +const validCapture = { + eventLoopDelay: { maxMs: 6, p95Ms: 5, p99Ms: 5.5 }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.5, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 1_050, + invalidReason: null, + requestReceivedEpochMs: 1_000, + threadCpuSystemMicros: 50, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 100, + workEndedEpochMs: 1_040, + workStartedEpochMs: 1_010, +} as const; + +function normalize(performanceCapture: unknown) { + return normalizeWorkerRequestPerformanceOutcome({ + operation: 'DB_GET_APP_PLAYLIST', + operationId: 'operation-42', + operationIdUnavailableReason: null, + performanceCapture, + playlistId: 'playlist-1', + requestId: 'request-1', + responseEpochMs: 1_060, + success: true, + }); +} + +function assertClosedCapture( + capture: WorkerRequestPerformanceMetrics, + reason: string +): void { + assert.equal(capture.performanceCaptureUnavailableReason, reason); + assert.equal(capture.operationId, 'operation-42'); + for (const value of [ + capture.eventLoopDelay, + capture.eventLoopDelayUnavailableReason, + capture.eventLoopUtilization, + capture.eventLoopUtilizationUnavailableReason, + capture.histogramFlushedEpochMs, + capture.invalidReason, + capture.requestReceivedEpochMs, + capture.threadCpuSystemMicros, + capture.threadCpuUnavailableReason, + capture.threadCpuUserMicros, + capture.workEndedEpochMs, + capture.workStartedEpochMs, + ]) { + assert.equal(value, null); + } +} + +test('absent captures are missing while present non-record captures are invalid', () => { + for (const missing of [undefined, null]) { + assertClosedCapture( + normalize(missing), + 'worker-performance-capture-missing' + ); + } + for (const malformed of [42, [], 'capture']) { + assertClosedCapture( + normalize(malformed), + 'worker-performance-capture-invalid' + ); + } +}); + +test('malformed nested fields and cross-field invariants fail the whole capture closed', () => { + const malformed = [ + { ...validCapture, eventLoopDelay: [] }, + { + ...validCapture, + eventLoopDelay: { maxMs: 6, p95Ms: 'bad', p99Ms: 5.5 }, + }, + { + ...validCapture, + eventLoopDelay: { maxMs: 5, p95Ms: 6, p99Ms: 5.5 }, + }, + { ...validCapture, eventLoopDelayUnavailableReason: 42 }, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: null, + histogramFlushedEpochMs: null, + }, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + 'event-loop-delay-capture-unavailable', + }, + { ...validCapture, eventLoopUtilization: 1.01 }, + { + ...validCapture, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: null, + }, + { ...validCapture, threadCpuSystemMicros: null }, + { + ...validCapture, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: null, + threadCpuUserMicros: null, + }, + { ...validCapture, threadCpuUnavailableReason: 'unavailable' }, + { ...validCapture, histogramFlushedEpochMs: null }, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: 'event-loop-delay-invalid', + histogramFlushedEpochMs: null, + }, + { ...validCapture, histogramFlushedEpochMs: 1_039 }, + { ...validCapture, workStartedEpochMs: 999 }, + { + ...validCapture, + invalidReason: 'overlapping-database-worker-requests', + }, + { ...validCapture, threadCpuUserMicros: undefined }, + ]; + for (const performanceCapture of malformed) { + assertClosedCapture( + normalize(performanceCapture), + 'worker-performance-capture-invalid' + ); + } +}); + +test('coherent metric-unavailable and invalid worker results remain valid raw captures', () => { + const unavailable = { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: 'event-loop-delay-capture-unavailable', + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + 'event-loop-utilization-unavailable', + histogramFlushedEpochMs: null, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: 'thread-cpu-usage-unavailable', + threadCpuUserMicros: null, + }; + for (const capture of [ + unavailable, + { + ...validCapture, + eventLoopDelay: null, + eventLoopDelayUnavailableReason: 'event-loop-delay-invalid', + }, + ]) { + const accepted = normalize(capture); + assert.equal(accepted.performanceCaptureUnavailableReason, null); + assert.equal(accepted.requestReceivedEpochMs, 1_000); + } + + const invalidReason = 'overlapping-database-worker-requests'; + const legitimateInvalid = normalize({ + ...unavailable, + eventLoopDelayUnavailableReason: invalidReason, + eventLoopUtilizationUnavailableReason: invalidReason, + invalidReason, + threadCpuUnavailableReason: invalidReason, + }); + assert.equal(legitimateInvalid.performanceCaptureUnavailableReason, null); + assert.equal(legitimateInvalid.invalidReason, invalidReason); +}); 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 new file mode 100644 index 000000000..72a3ae522 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.spec.ts @@ -0,0 +1,302 @@ +/* eslint-disable playwright/expect-expect -- These are Node assertion-based performance contract tests. */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { + type CancellationBenchmarkManifest, + type CancellationIterationResult, + PERFORMANCE_ITERATION_KIND, + PERFORMANCE_WORKER_KIND, + type WorkerCaptureMetrics, + type WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; +import { + normalizeWorkerRequestPerformanceOutcome, + selectMainCaptureGeneration, +} from './worker-request-performance'; +import { createCancellationBenchmarkSummary } from './m3u-refresh-cancellation-report'; + +type RequestPerformanceFixture = WorkerRequestPerformanceMetrics; +function requestPerformance( + requestId: string, + p95Ms: number, + threadCpuUserMicros: number +): RequestPerformanceFixture { + return normalizeWorkerRequestPerformanceOutcome( + requestTransport( + { + eventLoopDelay: { + maxMs: p95Ms + 1, + p95Ms, + p99Ms: p95Ms + 0.5, + }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: p95Ms / 100, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 1_050 + p95Ms, + invalidReason: null, + requestReceivedEpochMs: 1_000, + threadCpuSystemMicros: threadCpuUserMicros / 2, + threadCpuUnavailableReason: null, + threadCpuUserMicros, + workEndedEpochMs: 1_040, + workStartedEpochMs: 1_010, + }, + requestId + ) + ); +} +function requestTransport( + performanceCapture: unknown, + requestId = 'request-1' +) { + return { + operation: 'DB_GET_APP_PLAYLIST', + operationId: 'operation-42', + operationIdUnavailableReason: null, + performanceCapture, + playlistId: 'playlist-1', + requestId, + responseEpochMs: 1_060, + success: true, + } as const; +} +function assertClosedCapture( + capture: WorkerRequestPerformanceMetrics, + reason: string +): void { + assert.equal(capture.performanceCaptureUnavailableReason, reason); + assert.equal(capture.operationId, 'operation-42'); + for (const value of [ + capture.eventLoopDelay, + capture.eventLoopDelayUnavailableReason, + capture.eventLoopUtilization, + capture.eventLoopUtilizationUnavailableReason, + capture.histogramFlushedEpochMs, + capture.invalidReason, + capture.requestReceivedEpochMs, + capture.threadCpuSystemMicros, + capture.threadCpuUnavailableReason, + capture.threadCpuUserMicros, + capture.workEndedEpochMs, + capture.workStartedEpochMs, + ]) { + assert.equal(value, null); + } +} +function databaseWorker( + requests: readonly RequestPerformanceFixture[] +): WorkerCaptureMetrics { + return { + kind: PERFORMANCE_WORKER_KIND.DATABASE, + operationId: null, + peakExternalBytes: 0, + peakHeapUsedBytes: 0, + playlistId: null, + postGcHeapUsedBytes: null, + requests, + responseEpochMs: null, + terminatedEpochMs: null, + } as WorkerCaptureMetrics; +} +function playlistWorker( + request: RequestPerformanceFixture, + operationId: string, + terminatedEpochMs: number +): WorkerCaptureMetrics { + return { + ...databaseWorker([request]), + kind: PERFORMANCE_WORKER_KIND.PLAYLIST_REFRESH, + operationId, + playlistId: request.playlistId, + responseEpochMs: request.responseEpochMs, + terminatedEpochMs, + }; +} + +function measuredIteration( + workers: readonly WorkerCaptureMetrics[] +): CancellationIterationResult { + return { + cancellationEffectObserved: true, + kind: PERFORMANCE_ITERATION_KIND.MEASURED, + main: { + eventLoopDelay: { maxMs: 0, p95Ms: 0, p99Ms: 0 }, + eventLoopUtilization: null, + memory: { + peakHeapUsedBytes: 0, + peakRssBytes: 0, + postGcHeapUsedBytes: null, + postGcRssBytes: null, + }, + rendererPeakRssBytes: 0, + responsiveEvents: 0, + unresponsiveEvents: 0, + workers, + } as CancellationIterationResult['main'], + phases: {} as CancellationIterationResult['phases'], + renderer: { + peakHeapUsedBytes: 0, + postGcHeapUsedBytes: null, + probe: { + frameGapsMs: [], + heartbeatDelaysMs: [], + longTasksMs: [], + }, + } as CancellationIterationResult['renderer'], + runId: 'measured-1', + }; +} + +test('summary distributions use every request capture instead of a worker-level percentile maximum', () => { + const requests = [ + requestPerformance('request-1', 5, 100), + requestPerformance('request-2', 15, 300), + ]; + const summary = createCancellationBenchmarkSummary( + {} as CancellationBenchmarkManifest, + [measuredIteration([databaseWorker(requests)])] + ); + const measured = summary.measured as unknown as Record< + string, + { count: number; max: number | null; min: number | null } + >; + + assert.deepEqual( + summary.measured.databaseWorkerRequestEventLoopDelayP95Ms, + { + count: 2, + max: 15, + mean: 10, + median: 10, + min: 5, + p95: 14.5, + p99: 14.9, + } + ); + assert.deepEqual(measured['databaseWorkerRequestThreadCpuUserMicros'], { + count: 2, + max: 300, + mean: 200, + median: 200, + min: 100, + p95: 290, + p99: 298, + }); + assert.equal( + summary.iterations[0]?.main.workers[0]?.requests[0]?.operationId, + 'operation-42' + ); + assert.equal( + 'databaseWorkerEventLoopDelayP95Ms' in summary.measured, + false, + 'request-percentile distributions must not look like an operation-wide percentile' + ); +}); + +test('main capture retains raw request identity, timestamps, metrics, and reasons', () => { + const source = readFileSync( + new URL('./m3u-refresh-main-capture.ts', import.meta.url), + 'utf8' + ); + + assert.match(source, /requestPerformance: \[\]/); + assert.match(source, /record\.requestPerformance\.length > 0/); + assert.match(source, /record\.requestPerformance\.map/); + assert.match(source, /operationId: request\.identity\.operationId/); + assert.match(source, /identity: request\.identity/); + assert.match(source, /performanceCapture:\s+message\['performance'\]/); + assert.match(source, /captureGeneration: state\.captureGeneration/); + assert.doesNotMatch( + source, + /record\.eventLoopDelay = record\.eventLoopDelay/ + ); +}); + +test('capture-generation selection excludes a pre-start seed worker', () => { + const seed = playlistWorker( + requestPerformance('seed-request', 999, 999), + 'seed-operation', + 1_100 + ); + const measured = playlistWorker( + { + ...requestPerformance('measured-request', 8, 80), + success: false, + }, + 'measured-cancel-operation', + 2_100 + ); + + const mainMetrics = measuredIteration([]).main; + const toTransportWorker = ( + captureGeneration: number | null, + worker: WorkerCaptureMetrics + ) => ({ + captureGeneration, + metrics: worker, + requests: worker.requests.map((request) => ({ + ...requestTransport(request, request.requestId ?? undefined), + success: request.success, + })), + }); + const selected = selectMainCaptureGeneration({ + captureGeneration: 2, + metrics: mainMetrics, + workers: [ + toTransportWorker(null, seed), + toTransportWorker(2, measured), + ], + }); + + assert.equal(selected.workers.length, 1); + assert.equal( + selected.workers[0]?.requests[0]?.requestId, + 'measured-request' + ); + assert.equal(selected.workers[0]?.operationId, 'measured-cancel-operation'); + assert.equal(selected.workers[0]?.terminatedEpochMs, 2_100); +}); + +test('raw outcomes retain missing and malformed captures while summaries exclude their null metrics', () => { + const valid = normalizeWorkerRequestPerformanceOutcome( + requestTransport(requestPerformance('request-1', 5, 100)) + ); + const missing = normalizeWorkerRequestPerformanceOutcome( + requestTransport(null, 'request-2') + ); + const malformed = normalizeWorkerRequestPerformanceOutcome( + requestTransport( + { + requestReceivedEpochMs: 'invalid', + workEndedEpochMs: 1_080, + workStartedEpochMs: 1_075, + }, + 'request-3' + ) + ); + const worker = databaseWorker([valid, missing, malformed]); + const summary = createCancellationBenchmarkSummary( + {} as CancellationBenchmarkManifest, + [measuredIteration([worker])] + ); + + assert.equal(worker.requests.length, 3); + assertClosedCapture( + worker.requests[1] as WorkerRequestPerformanceMetrics, + 'worker-performance-capture-missing' + ); + assertClosedCapture( + worker.requests[2] as WorkerRequestPerformanceMetrics, + 'worker-performance-capture-invalid' + ); + assert.equal( + summary.measured.databaseWorkerRequestEventLoopDelayP95Ms.count, + 1 + ); + assert.equal( + summary.measured.databaseWorkerRequestThreadCpuUserMicros.count, + 1 + ); +}); diff --git a/apps/electron-backend-e2e/src/performance/worker-request-performance.ts b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts new file mode 100644 index 000000000..edf157282 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/worker-request-performance.ts @@ -0,0 +1,302 @@ +import type { + EventLoopDelayMetrics, + MainCaptureMetrics, + WorkerCaptureMetrics, + WorkerRequestPerformanceMetrics, +} from './m3u-refresh-cancellation-contract'; + +export interface WorkerRequestPerformanceOutcomeTransport { + readonly operation: string | null; + readonly operationId: string | null; + readonly operationIdUnavailableReason: string | null; + readonly performanceCapture: unknown; + readonly playlistId: string | null; + readonly requestId: string | null; + readonly responseEpochMs: number; + readonly success: boolean; +} + +export interface MainCaptureGenerationTransport { + readonly captureGeneration: number; + readonly metrics: Omit; + readonly workers: readonly { + readonly captureGeneration: number | null; + readonly metrics: Omit< + WorkerCaptureMetrics, + 'eventLoopDelay' | 'eventLoopDelayUnavailableReason' | 'requests' + >; + readonly requests: readonly WorkerRequestPerformanceOutcomeTransport[]; + }[]; +} + +type ParsedWorkerPerformanceCapture = Pick< + WorkerRequestPerformanceMetrics, + | 'eventLoopDelay' + | 'eventLoopDelayUnavailableReason' + | 'eventLoopUtilization' + | 'eventLoopUtilizationUnavailableReason' + | 'histogramFlushedEpochMs' + | 'invalidReason' + | 'requestReceivedEpochMs' + | 'threadCpuSystemMicros' + | 'threadCpuUnavailableReason' + | 'threadCpuUserMicros' + | 'workEndedEpochMs' + | 'workStartedEpochMs' +>; + +const INVALID_REASONS = new Set(['overlapping-database-worker-requests']); +const EVENT_LOOP_DELAY_REASONS = new Set([ + ...INVALID_REASONS, + 'event-loop-delay-arm-timeout', + 'event-loop-delay-capture-unavailable', + 'event-loop-delay-flush-timeout', + 'event-loop-delay-invalid', +]); +const EVENT_LOOP_UTILIZATION_REASONS = new Set([ + ...INVALID_REASONS, + 'event-loop-utilization-unavailable', +]); +const THREAD_CPU_REASONS = new Set([ + ...INVALID_REASONS, + 'thread-cpu-usage-invalid', + 'thread-cpu-usage-unavailable', +]); + +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 isNullableFiniteNonNegativeNumber( + value: unknown +): value is number | null { + return value === null || isFiniteNonNegativeNumber(value); +} + +function isNullableReason( + value: unknown, + allowed: ReadonlySet +): value is string | null { + return value === null || (typeof value === 'string' && allowed.has(value)); +} + +function parseEventLoopDelay( + input: unknown +): EventLoopDelayMetrics | null | undefined { + if (input === null) { + return null; + } + if (!isRecord(input)) { + return undefined; + } + const maxMs = input['maxMs']; + const p95Ms = input['p95Ms']; + const p99Ms = input['p99Ms']; + if ( + !isFiniteNonNegativeNumber(maxMs) || + !isFiniteNonNegativeNumber(p95Ms) || + !isFiniteNonNegativeNumber(p99Ms) || + p95Ms > p99Ms || + p99Ms > maxMs + ) { + return undefined; + } + return { maxMs, p95Ms, p99Ms }; +} + +function parseWorkerPerformanceCapture( + input: unknown +): ParsedWorkerPerformanceCapture | null { + if (!isRecord(input)) { + return null; + } + + const eventLoopDelay = parseEventLoopDelay(input['eventLoopDelay']); + const eventLoopDelayUnavailableReason = + input['eventLoopDelayUnavailableReason']; + const eventLoopUtilization = input['eventLoopUtilization']; + const eventLoopUtilizationUnavailableReason = + input['eventLoopUtilizationUnavailableReason']; + const histogramFlushedEpochMs = input['histogramFlushedEpochMs']; + const invalidReason = input['invalidReason']; + const requestReceivedEpochMs = input['requestReceivedEpochMs']; + const threadCpuSystemMicros = input['threadCpuSystemMicros']; + const threadCpuUnavailableReason = input['threadCpuUnavailableReason']; + const threadCpuUserMicros = input['threadCpuUserMicros']; + const workEndedEpochMs = input['workEndedEpochMs']; + const workStartedEpochMs = input['workStartedEpochMs']; + + if ( + eventLoopDelay === undefined || + !isNullableReason( + eventLoopDelayUnavailableReason, + EVENT_LOOP_DELAY_REASONS + ) || + !isNullableFiniteNonNegativeNumber(eventLoopUtilization) || + (eventLoopUtilization !== null && eventLoopUtilization > 1) || + !isNullableReason( + eventLoopUtilizationUnavailableReason, + EVENT_LOOP_UTILIZATION_REASONS + ) || + !isNullableFiniteNonNegativeNumber(histogramFlushedEpochMs) || + !isNullableReason(invalidReason, INVALID_REASONS) || + !isFiniteNonNegativeNumber(requestReceivedEpochMs) || + !isNullableFiniteNonNegativeNumber(threadCpuSystemMicros) || + !isNullableReason(threadCpuUnavailableReason, THREAD_CPU_REASONS) || + !isNullableFiniteNonNegativeNumber(threadCpuUserMicros) || + !isFiniteNonNegativeNumber(workEndedEpochMs) || + !isFiniteNonNegativeNumber(workStartedEpochMs) + ) { + return null; + } + + const timestampsAreOrdered = + requestReceivedEpochMs <= workStartedEpochMs && + workStartedEpochMs <= workEndedEpochMs && + (histogramFlushedEpochMs === null || + workEndedEpochMs <= histogramFlushedEpochMs); + const eventLoopDelayIsCoherent = + eventLoopDelay === null + ? eventLoopDelayUnavailableReason !== null && + (eventLoopDelayUnavailableReason === 'event-loop-delay-invalid' + ? histogramFlushedEpochMs !== null + : histogramFlushedEpochMs === null) + : eventLoopDelayUnavailableReason === null && + histogramFlushedEpochMs !== null; + const eventLoopUtilizationIsCoherent = + eventLoopUtilization === null + ? eventLoopUtilizationUnavailableReason !== null + : eventLoopUtilizationUnavailableReason === null; + const threadCpuIsCoherent = + threadCpuSystemMicros === null && threadCpuUserMicros === null + ? threadCpuUnavailableReason !== null + : threadCpuSystemMicros !== null && + threadCpuUserMicros !== null && + threadCpuUnavailableReason === null; + const invalidCaptureIsCoherent = + invalidReason === null || + (eventLoopDelay === null && + eventLoopDelayUnavailableReason === invalidReason && + eventLoopUtilization === null && + eventLoopUtilizationUnavailableReason === invalidReason && + histogramFlushedEpochMs === null && + threadCpuSystemMicros === null && + threadCpuUnavailableReason === invalidReason && + threadCpuUserMicros === null); + if ( + !timestampsAreOrdered || + !eventLoopDelayIsCoherent || + !eventLoopUtilizationIsCoherent || + !threadCpuIsCoherent || + !invalidCaptureIsCoherent + ) { + return null; + } + + return { + eventLoopDelay, + eventLoopDelayUnavailableReason, + eventLoopUtilization, + eventLoopUtilizationUnavailableReason, + histogramFlushedEpochMs, + invalidReason, + requestReceivedEpochMs, + threadCpuSystemMicros, + threadCpuUnavailableReason, + threadCpuUserMicros, + workEndedEpochMs, + workStartedEpochMs, + }; +} + +export function normalizeWorkerRequestPerformanceOutcome( + input: WorkerRequestPerformanceOutcomeTransport +): WorkerRequestPerformanceMetrics { + const identity = { + operation: input.operation, + operationId: input.operationId, + operationIdUnavailableReason: input.operationIdUnavailableReason, + playlistId: input.playlistId, + requestId: input.requestId, + responseEpochMs: input.responseEpochMs, + success: input.success, + }; + const unavailable = ( + reason: + | 'worker-performance-capture-invalid' + | 'worker-performance-capture-missing' + ): WorkerRequestPerformanceMetrics => ({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: null, + invalidReason: null, + ...identity, + performanceCaptureUnavailableReason: reason, + requestReceivedEpochMs: null, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: null, + threadCpuUserMicros: null, + workEndedEpochMs: null, + workStartedEpochMs: null, + }); + + if ( + input.performanceCapture === undefined || + input.performanceCapture === null + ) { + return unavailable('worker-performance-capture-missing'); + } + + const performanceCapture = parseWorkerPerformanceCapture( + input.performanceCapture + ); + if (!performanceCapture) { + return unavailable('worker-performance-capture-invalid'); + } + + return { + ...performanceCapture, + ...identity, + performanceCaptureUnavailableReason: null, + }; +} + +export function selectMainCaptureGeneration( + input: MainCaptureGenerationTransport +): MainCaptureMetrics { + const workers = input.workers + .filter( + (worker) => worker.captureGeneration === input.captureGeneration + ) + .map((worker): WorkerCaptureMetrics => { + const requests = worker.requests.map( + normalizeWorkerRequestPerformanceOutcome + ); + const onlyRequest = + requests.length === 1 ? (requests[0] ?? null) : null; + return { + ...worker.metrics, + eventLoopDelay: onlyRequest?.eventLoopDelay ?? null, + eventLoopDelayUnavailableReason: onlyRequest + ? (onlyRequest.eventLoopDelayUnavailableReason ?? + onlyRequest.performanceCaptureUnavailableReason ?? + 'worker-self-profile-result-unavailable') + : requests.length > 1 + ? 'multiple-request-scoped-captures' + : worker.metrics.terminatedEpochMs !== null + ? 'worker-terminated-before-profile-flush' + : 'worker-self-profile-result-unavailable', + requests, + }; + }); + return { + ...input.metrics, + workers, + }; +} diff --git a/apps/electron-backend/build-worker.js b/apps/electron-backend/build-worker.js index a80817272..649391365 100644 --- a/apps/electron-backend/build-worker.js +++ b/apps/electron-backend/build-worker.js @@ -18,6 +18,12 @@ const nativeModules = [ ]; const isProduction = process.env.NODE_ENV === 'production'; +const isPerformance = process.argv.includes('--performance'); +const buildMode = isPerformance + ? 'performance' + : isProduction + ? 'production' + : 'development'; async function buildWorker() { try { @@ -59,7 +65,7 @@ async function buildWorker() { for (const worker of workers) { console.log( - `Building ${worker.label} with esbuild (${isProduction ? 'production' : 'development'})...` + `Building ${worker.label} with esbuild (${buildMode})...` ); await esbuild.build({ @@ -74,8 +80,8 @@ async function buildWorker() { ...nodeBuiltins, ...nativeModules, ], - sourcemap: !isProduction, - minify: isProduction, + sourcemap: isPerformance || !isProduction, + minify: isProduction || isPerformance, alias: { '@iptvnator/shared/interfaces': path.join( __dirname, diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index 8e6b9febb..6e4107999 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -12,6 +12,13 @@ "command": "node apps/electron-backend/build-worker.js" } }, + "build-worker-performance": { + "executor": "nx:run-commands", + "outputs": ["{workspaceRoot}/dist/apps/electron-backend/workers"], + "options": { + "command": "node apps/electron-backend/build-worker.js --performance" + } + }, "build-embedded-mpv": { "executor": "nx:run-commands", "cache": false, @@ -81,9 +88,35 @@ "with": "apps/electron-backend/src/environments/environment.prod.ts" } ] + }, + "electron-performance": { + "optimization": true, + "extractLicenses": true, + "inspect": false, + "sourceMap": true, + "fileReplacements": [ + { + "replace": "apps/electron-backend/src/environments/environment.ts", + "with": "apps/electron-backend/src/environments/environment.prod.ts" + } + ] } } }, + "build-performance": { + "dependsOn": [ + "electron-backend:build-worker-performance", + "electron-backend:build-embedded-mpv", + { + "projects": ["web", "remote-control-web"], + "target": "build-performance" + } + ], + "executor": "nx:run-commands", + "options": { + "command": "pnpm nx run electron-backend:build:electron-performance --excludeTaskDependencies" + } + }, "build-e2e": { "dependsOn": [ "electron-backend:build-worker", diff --git a/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts new file mode 100644 index 000000000..e4dc78a44 --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.contract.spec.ts @@ -0,0 +1,343 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type Playlist, + type PreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; +import { + createPlaylist, + createPreloadPerformanceHarness, + createRefreshPayload, + expectFixedMarkers, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; + +describe('main preload performance marker contract', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(() => { + harness = createPreloadPerformanceHarness(); + }); + + afterEach(() => { + harness.cleanup(); + }); + + async function loadPerformancePreload(): Promise { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + } + + it('preserves calls while emitting ordered, correlated, fixed, redacted markers', async () => { + const secrets = { + favorite: 'sentinel-favorite-secret', + filePath: '/sentinel/private/playlist.m3u', + host: 'sentinel.private.example', + item: 'sentinel-item-secret', + result: 'sentinel-result-secret', + title: 'sentinel-title-secret', + url: 'https://sentinel.example/private.m3u?token=secret', + }; + const payload = createRefreshPayload({ + filePath: secrets.filePath, + title: secrets.title, + trustedInsecureTlsHosts: [secrets.host], + url: secrets.url, + }); + const refreshedPlaylist = createPlaylist(payload.playlistId, { + favorites: [secrets.favorite], + items: [{ name: secrets.item }], + title: secrets.title, + url: secrets.url, + }); + const storedPlaylist = createPlaylist(payload.playlistId, { + filePath: secrets.filePath, + title: secrets.title, + }); + const upsertPlaylist = createPlaylist(payload.playlistId, { + favorites: [secrets.favorite], + items: [{ name: secrets.item }], + title: secrets.title, + }); + const upsertResult = { + secretResult: secrets.result, + success: true, + }; + await loadPerformancePreload(); + harness.ipcRenderer.invoke + .mockResolvedValueOnce(refreshedPlaylist) + .mockResolvedValueOnce(storedPlaylist) + .mockResolvedValueOnce(upsertResult); + const api = harness.getApi(); + + await expect(api.refreshPlaylist(payload)).resolves.toBe( + refreshedPlaylist + ); + await expect( + api.dbGetAppPlaylist(payload.playlistId, payload.operationId) + ).resolves.toBe(storedPlaylist); + await expect( + api.dbUpsertAppPlaylist(upsertPlaylist, payload.operationId) + ).resolves.toBe(upsertResult); + + expect(harness.ipcRenderer.invoke.mock.calls).toEqual([ + ['PLAYLIST:REFRESH', payload], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', upsertPlaylist], + ]); + const markers = harness.getMarkers(); + expect(markers).toHaveLength(6); + expect( + markers.map(({ ipcCallId, method, phase }) => [ + ipcCallId, + method, + phase, + ]) + ).toEqual([ + [1, PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, 'start'], + [1, PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, 'success'], + [2, PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, 'start'], + [2, PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, 'success'], + [3, PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, 'start'], + [3, PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, 'success'], + ]); + expect(markers.map(({ correlationState }) => correlationState)).toEqual( + [ + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + ] + ); + expect(markers.map(({ operationId }) => operationId)).toEqual( + Array(6).fill(payload.operationId) + ); + expect(markers.map(({ playlistId }) => playlistId)).toEqual( + Array(6).fill(payload.playlistId) + ); + expect(markers.map(({ invalidReason }) => invalidReason)).toEqual( + Array(6).fill(null) + ); + expectFixedMarkers(markers); + + const markerSendOrders = harness.ipcRenderer.send.mock.calls.flatMap( + ([channel], index) => + channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL + ? [harness.ipcRenderer.send.mock.invocationCallOrder[index]] + : [] + ); + const invokeOrders = + harness.ipcRenderer.invoke.mock.invocationCallOrder; + for (const [index, invokeOrder] of invokeOrders.entries()) { + expect(markerSendOrders[index * 2]).toBeLessThan(invokeOrder); + } + + const serializedMarkers = JSON.stringify(markers); + for (const secret of Object.values(secrets)) { + expect(serializedMarkers).not.toContain(secret); + } + }); + + it('preserves rejection identity and emits a redacted error', async () => { + const payload = createRefreshPayload({ + title: 'sentinel-rejection-title', + url: 'https://sentinel.example/rejected.m3u', + }); + const rejection = new Error('sentinel-rejection-error'); + await loadPerformancePreload(); + harness.ipcRenderer.invoke.mockRejectedValueOnce(rejection); + + await expect(harness.getApi().refreshPlaylist(payload)).rejects.toBe( + rejection + ); + + const markers = harness.getMarkers(); + expect(markerPhases(markers)).toEqual([ + [1, 'start'], + [1, 'error'], + ]); + expect(markers[1]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR, + operationId: payload.operationId, + playlistId: payload.playlistId, + }); + expect(JSON.stringify(markers)).not.toContain(rejection.message); + expect(JSON.stringify(markers)).not.toContain(payload.title); + expect(JSON.stringify(markers)).not.toContain(payload.url); + }); + + it('preserves synchronous throw identity and emits a redacted error', async () => { + const synchronousError = new Error('sentinel-synchronous-error'); + const payload = createRefreshPayload(); + await loadPerformancePreload(); + harness.ipcRenderer.invoke.mockImplementationOnce(() => { + throw synchronousError; + }); + + let caughtError: unknown; + try { + harness.getApi().refreshPlaylist(payload); + } catch (error) { + caughtError = error; + } + + expect(caughtError).toBe(synchronousError); + const markers = harness.getMarkers(); + expect(markerPhases(markers)).toEqual([ + [1, 'start'], + [1, 'error'], + ]); + expect(markers[1]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR, + }); + expect(JSON.stringify(markers)).not.toContain(synchronousError.message); + }); + + it('closes a structured refresh cancellation with a fixed reason', async () => { + const payload = createRefreshPayload(); + const cancelledResult = { + operationId: payload.operationId, + type: 'playlist-refresh-cancelled', + } as const; + await loadPerformancePreload(); + harness.ipcRenderer.invoke + .mockResolvedValueOnce(cancelledResult) + .mockResolvedValueOnce(null); + const api = harness.getApi(); + + await expect(api.refreshPlaylist(payload)).resolves.toBe( + cancelledResult + ); + await api.dbGetAppPlaylist(payload.playlistId); + + const markers = harness.getMarkers(); + expect(markers[1]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED, + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: payload.operationId, + phase: 'success', + playlistId: payload.playlistId, + }); + expect(markers[2]).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + }); + }); + + it('uses the exact upsert _id and keeps one malformed reason in both phases', async () => { + const malformedPlaylist = { + ...createPlaylist('placeholder'), + _id: { + secret: 'sentinel-malformed-playlist-id', + }, + } as unknown as Playlist; + const result = { success: true }; + await loadPerformancePreload(); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + + await expect( + harness.getApi().dbUpsertAppPlaylist(malformedPlaylist) + ).resolves.toBe(result); + expect(harness.ipcRenderer.invoke).toHaveBeenCalledWith( + 'DB_UPSERT_APP_PLAYLIST', + malformedPlaylist + ); + + const markers = harness.getMarkers(); + expect( + markers.map( + ({ correlationState, invalidReason, phase, playlistId }) => ({ + correlationState, + invalidReason, + phase, + playlistId, + }) + ) + ).toEqual([ + { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + phase: 'start', + playlistId: null, + }, + { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + phase: 'success', + playlistId: null, + }, + ]); + expect(markers[0].ipcCallId).toBe(markers[1].ipcCallId); + expect(JSON.stringify(markers)).not.toContain( + 'sentinel-malformed-playlist-id' + ); + }); + + it('poisons correlation after the first marker delivery failure', async () => { + const payload = createRefreshPayload(); + const result = createPlaylist(payload.playlistId); + await loadPerformancePreload(); + harness.ipcRenderer.invoke + .mockResolvedValueOnce(result) + .mockResolvedValueOnce(result) + .mockResolvedValueOnce({ success: true }); + harness.ipcRenderer.send.mockImplementationOnce((channel: string) => { + if (channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL) { + throw new Error('marker-send-failed'); + } + }); + + await expect(harness.getApi().refreshPlaylist(payload)).resolves.toBe( + result + ); + await expect( + harness + .getApi() + .dbGetAppPlaylist(payload.playlistId, payload.operationId) + ).resolves.toBe(result); + await expect( + harness.getApi().dbUpsertAppPlaylist(result, payload.operationId) + ).resolves.toEqual({ success: true }); + expect( + harness.ipcRenderer.send.mock.calls.filter( + ([channel]) => channel === PRELOAD_PERFORMANCE_MARKER_CHANNEL + ) + ).toHaveLength(1); + expect(harness.getMarkers()).toHaveLength(1); + expect(harness.getMarkers()[0]).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + phase: 'start', + }); + expect( + harness + .getMarkers() + .some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); + }); +}); + +function markerPhases( + markers: PreloadPerformanceMarker[] +): [number, PreloadPerformanceMarker['phase']][] { + return markers.map(({ ipcCallId, phase }) => [ipcCallId, phase]); +} diff --git a/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts new file mode 100644 index 000000000..518f9376d --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.gates.spec.ts @@ -0,0 +1,247 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type Settings, + Theme, +} from '@iptvnator/shared/interfaces'; +import { redactSensitiveData } from '@iptvnator/shared/logging'; +import { DEBUG_TRACE_EVENT_CHANNEL } from '../services/debug-trace'; +import { + createPreloadPerformanceHarness, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; + +describe('main preload performance marker gates', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(() => { + harness = createPreloadPerformanceHarness(); + }); + + afterEach(() => { + harness.cleanup(); + jest.restoreAllMocks(); + }); + + it.each([undefined, '', '0', 'false', 'no', 'off'])( + 'emits no marker when the performance flag is %s', + async (flagValue) => { + await harness.load( + flagValue === undefined + ? {} + : { + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: flagValue, + } + ); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect(harness.getMarkers()).toEqual([]); + } + ); + + it.each(['1', ' TRUE ', 'yes', 'On'])( + 'accepts the existing true flag value %s', + async (flagValue) => { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: flagValue, + }); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect( + harness.getMarkers().map(({ correlationState, phase }) => ({ + correlationState, + phase, + })) + ).toEqual([ + { + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + phase: 'start', + }, + { + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + phase: 'success', + }, + ]); + } + ); + + it('keeps trace-only calls on the existing debug channel', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect( + harness + .getSentPayloads< + Record + >(DEBUG_TRACE_EVENT_CHANNEL) + .map(({ method, phase }) => ({ method, phase })) + ).toEqual([ + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'start', + }, + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'success', + }, + ]); + expect(harness.getMarkers()).toEqual([]); + }); + + it('keeps debug tracing unchanged when both gates are enabled', async () => { + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + + await harness.getApi().dbGetAppPlaylist('playlist-1'); + + expect( + harness + .getSentPayloads< + Record + >(DEBUG_TRACE_EVENT_CHANNEL) + .map(({ method, phase }) => ({ method, phase })) + ).toEqual([ + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'start', + }, + { + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'success', + }, + ]); + expect( + harness.getSentPayloads(PRELOAD_PERFORMANCE_MARKER_CHANNEL).length + ).toBe(2); + }); + + it('does not inspect or mark non-target calls in performance-only mode', async () => { + const result = { success: true }; + const settings = Object.defineProperty({}, 'sentinel', { + enumerable: true, + get: () => { + throw new Error('non-target arguments must stay opaque'); + }, + }) as Partial; + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + + await expect(harness.getApi().updateSettings(settings)).resolves.toBe( + result + ); + + expect(harness.ipcRenderer.invoke).toHaveBeenCalledWith( + 'SETTINGS_UPDATE', + settings + ); + expect(harness.getMarkers()).toEqual([]); + }); + + it('preserves the full legacy trace payload for non-target success', async () => { + const settings = { + password: 'sentinel-argument-secret', + theme: Theme.DarkTheme, + } as Partial; + const result = { + password: 'sentinel-result-secret', + success: true, + }; + await harness.load({ + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + jest.spyOn(globalThis.performance, 'now') + .mockReturnValueOnce(10) + .mockReturnValueOnce(12.34); + harness.ipcRenderer.invoke.mockResolvedValueOnce(result); + + await expect(harness.getApi().updateSettings(settings)).resolves.toBe( + result + ); + + const traces = harness.getSentPayloads>( + DEBUG_TRACE_EVENT_CHANNEL + ); + expect(traces).toEqual([ + { + args: { + items: [ + { + password: 'sentinel-argument-secret', + theme: Theme.DarkTheme, + }, + ], + length: 1, + type: 'array', + }, + method: 'updateSettings', + phase: 'start', + }, + { + durationMs: 2.3, + method: 'updateSettings', + phase: 'success', + result, + }, + ]); + const redacted = JSON.stringify(redactSensitiveData(traces)); + expect(redacted).not.toContain('sentinel-argument-secret'); + expect(redacted).not.toContain('sentinel-result-secret'); + expect(harness.getMarkers()).toEqual([]); + }); + + it('preserves the full legacy trace payload for non-target errors', async () => { + const rejection = new Error('token=sentinel-error-secret'); + await harness.load({ + [PRELOAD_ENVIRONMENT.TRACE_IPC]: '1', + }); + jest.spyOn(globalThis.performance, 'now') + .mockReturnValueOnce(20) + .mockReturnValueOnce(23.26); + harness.ipcRenderer.invoke.mockRejectedValueOnce(rejection); + + await expect( + harness.getApi().updateSettings({ theme: Theme.LightTheme }) + ).rejects.toBe(rejection); + + const traces = harness.getSentPayloads>( + DEBUG_TRACE_EVENT_CHANNEL + ); + expect(traces).toEqual([ + { + args: { + items: [{ theme: Theme.LightTheme }], + length: 1, + type: 'array', + }, + method: 'updateSettings', + phase: 'start', + }, + { + durationMs: 3.3, + error: { + message: rejection.message, + name: 'Error', + }, + method: 'updateSettings', + phase: 'error', + }, + ]); + expect(JSON.stringify(redactSensitiveData(traces))).not.toContain( + 'sentinel-error-secret' + ); + expect(harness.getMarkers()).toEqual([]); + }); +}); diff --git a/apps/electron-backend/src/app/api/main.preload.performance.identity.spec.ts b/apps/electron-backend/src/app/api/main.preload.performance.identity.spec.ts new file mode 100644 index 000000000..ae6306c18 --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.identity.spec.ts @@ -0,0 +1,79 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createPlaylist, + createPreloadPerformanceHarness, + createRefreshPayload, + PRELOAD_ENVIRONMENT, + type PreloadPerformanceHarness, +} from './main.preload.performance.test-helpers'; + +describe('main preload performance marker identities', () => { + let harness: PreloadPerformanceHarness; + + beforeEach(async () => { + harness = createPreloadPerformanceHarness(); + await harness.load({ + [PRELOAD_ENVIRONMENT.PERF_CAPTURE]: '1', + }); + }); + + afterEach(() => { + harness.cleanup(); + }); + + it('completes only for database calls carrying the refresh operation ID', async () => { + const payload = createRefreshPayload(); + const playlist = createPlaylist(payload.playlistId); + harness.ipcRenderer.invoke.mockResolvedValue(playlist); + const api = harness.getApi(); + + await api.refreshPlaylist(payload); + await api.dbGetAppPlaylist(payload.playlistId); + await api.dbUpsertAppPlaylist(playlist); + await api.dbGetAppPlaylist(payload.playlistId, 'other-operation'); + await api.dbUpsertAppPlaylist(playlist, 'other-operation'); + + expect( + harness + .getMarkers() + .slice(2) + .every( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED + ) + ).toBe(true); + expect( + harness + .getMarkers() + .some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); + + await api.dbGetAppPlaylist(payload.playlistId, payload.operationId); + await api.dbUpsertAppPlaylist(playlist, payload.operationId); + + const markers = harness.getMarkers(); + expect(markers.at(-1)).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: payload.operationId, + playlistId: payload.playlistId, + }); + expect(harness.ipcRenderer.invoke.mock.calls).toEqual([ + ['PLAYLIST:REFRESH', payload], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', playlist], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', playlist], + ['DB_GET_APP_PLAYLIST', payload.playlistId], + ['DB_UPSERT_APP_PLAYLIST', playlist], + ]); + }); +}); 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 new file mode 100644 index 000000000..69c8a7e0f --- /dev/null +++ b/apps/electron-backend/src/app/api/main.preload.performance.test-helpers.ts @@ -0,0 +1,162 @@ +import type { + ElectronBridgeApi, + Playlist, + PlaylistRefreshPayload, + PreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; + +export const PRELOAD_ENVIRONMENT = { + PERF_CAPTURE: 'IPTVNATOR_PERF_CAPTURE', + TRACE_IPC: 'IPTVNATOR_TRACE_IPC', + TRACE_STARTUP: 'IPTVNATOR_TRACE_STARTUP', +} as const; + +type PreloadEnvironmentName = + (typeof PRELOAD_ENVIRONMENT)[keyof typeof PRELOAD_ENVIRONMENT]; + +type PreloadEnvironment = Partial>; + +export type ExposedElectronApi = ElectronBridgeApi & + Record unknown>; + +export interface MockIpcRenderer { + invoke: jest.Mock; + off: jest.Mock; + on: jest.Mock; + send: jest.Mock; +} + +export interface PreloadPerformanceHarness { + cleanup: () => void; + getApi: () => ExposedElectronApi; + getMarkers: () => PreloadPerformanceMarker[]; + getSentPayloads: (channel: string) => T[]; + ipcRenderer: MockIpcRenderer; + load: (environment?: PreloadEnvironment) => Promise; +} + +const MARKER_KEYS = [ + 'correlationState', + 'invalidReason', + 'ipcCallId', + 'method', + 'operationId', + 'phase', + 'playlistId', + 'sourceEpochMs', +] as const; + +export function createPlaylist( + playlistId: string, + overrides: Partial = {} +): Playlist { + return { + _id: playlistId, + autoRefresh: false, + count: 1, + importDate: '2026-07-26T00:00:00.000Z', + lastUsage: '2026-07-26T00:00:00.000Z', + title: 'Performance fixture', + ...overrides, + }; +} + +export function createRefreshPayload( + overrides: Partial = {} +): PlaylistRefreshPayload { + return { + operationId: 'operation-1', + playlistId: 'playlist-1', + title: 'Performance fixture', + ...overrides, + }; +} + +export function expectFixedMarkers(markers: PreloadPerformanceMarker[]): 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 + ); + } + } +} + +export function createPreloadPerformanceHarness(): PreloadPerformanceHarness { + const originalEnvironment = new Map< + PreloadEnvironmentName, + string | undefined + >( + Object.values(PRELOAD_ENVIRONMENT).map((name) => [ + name, + process.env[name], + ]) + ); + let exposedApi: ExposedElectronApi | null = null; + const ipcRenderer: MockIpcRenderer = { + invoke: jest.fn(), + off: jest.fn(), + on: jest.fn(), + send: jest.fn(), + }; + + function getSentPayloads(channel: string): T[] { + return ipcRenderer.send.mock.calls + .filter(([sentChannel]) => sentChannel === channel) + .map(([, payload]) => payload as T); + } + + return { + cleanup: () => { + for (const [name, value] of originalEnvironment) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + jest.dontMock('electron'); + }, + getApi: () => { + if (!exposedApi) { + throw new Error('Expected preload API to be exposed'); + } + return exposedApi; + }, + getMarkers: () => + getSentPayloads( + 'IPTVNATOR_PERF_CAPTURE_MARKER' + ), + getSentPayloads, + ipcRenderer, + load: async (environment = {}) => { + jest.resetModules(); + for (const name of Object.values(PRELOAD_ENVIRONMENT)) { + delete process.env[name]; + } + Object.assign(process.env, environment); + exposedApi = null; + ipcRenderer.invoke.mockReset().mockResolvedValue({ success: true }); + ipcRenderer.off.mockReset(); + ipcRenderer.on.mockReset(); + ipcRenderer.send.mockReset(); + jest.doMock('electron', () => ({ + contextBridge: { + exposeInMainWorld: jest.fn( + (_name: string, api: ExposedElectronApi) => { + exposedApi = api; + } + ), + }, + ipcRenderer, + webUtils: { + getPathForFile: jest.fn(), + }, + })); + + await import('./main.preload'); + }, + }; +} diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index bd078b223..6f39995da 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -11,6 +11,10 @@ import { attachEmbeddedMpvFrameView, detachEmbeddedMpvFrameView, } from './embedded-mpv-frame-pump'; +import { + createPreloadPerformanceCapture, + toPreloadPerformanceTargetMethod, +} from './preload-performance-capture'; import type { EmbeddedMpvBounds, EmbeddedMpvRecordingStartOptions, @@ -48,6 +52,7 @@ import type { } from '@iptvnator/shared/interfaces'; import { DEBUG_TRACE_EVENT_CHANNEL, + isPerformanceCaptureEnabled, isRendererApiTraceEnabled, roundTraceDuration, summarizeForTrace, @@ -72,6 +77,11 @@ const dbSaveContentProgressListeners = new Set< >(); const shouldTraceRendererApi = isRendererApiTraceEnabled(); +const shouldCapturePerformance = isPerformanceCaptureEnabled(); +const preloadPerformanceCapture = createPreloadPerformanceCapture( + shouldCapturePerformance, + (channel, marker) => ipcRenderer.send(channel, marker) +); function emitRendererTrace(payload: { method: string; @@ -89,7 +99,7 @@ function emitRendererTrace(payload: { } function wrapElectronApi(api: T): T { - if (!shouldTraceRendererApi) { + if (!shouldTraceRendererApi && !shouldCapturePerformance) { return api; } @@ -98,7 +108,9 @@ function wrapElectronApi(api: T): T { if ( typeof value !== 'function' || name.startsWith('on') || - name.startsWith('remove') + name.startsWith('remove') || + (!shouldTraceRendererApi && + !toPreloadPerformanceTargetMethod(name)) ) { return [name, value]; } @@ -108,13 +120,20 @@ function wrapElectronApi(api: T): T { return [ name, (...args: unknown[]) => { - const startedAt = - globalThis.performance?.now?.() ?? Date.now(); - emitRendererTrace({ - args: summarizeForTrace(args), - method: name, - phase: 'start', - }); + const startedAt = shouldTraceRendererApi + ? (globalThis.performance?.now?.() ?? Date.now()) + : 0; + if (shouldTraceRendererApi) { + emitRendererTrace({ + args: summarizeForTrace(args), + method: name, + phase: 'start', + }); + } + const performanceCall = preloadPerformanceCapture.start( + name, + args + ); try { const result = original(...args); @@ -126,54 +145,74 @@ function wrapElectronApi(api: T): T { ) { return (result as Promise) .then((resolvedValue) => { - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - method: name, - phase: 'success', - result: summarizeForTrace( - resolvedValue - ), - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + method: name, + phase: 'success', + result: summarizeForTrace( + resolvedValue + ), + }); + } + preloadPerformanceCapture.success( + performanceCall, + resolvedValue + ); return resolvedValue; }) .catch((error: unknown) => { - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - error: summarizeForTrace(error), - method: name, - phase: 'error', - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + error: summarizeForTrace(error), + method: name, + phase: 'error', + }); + } + preloadPerformanceCapture.error( + performanceCall + ); throw error; }); } - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - method: name, - phase: 'success', - result: summarizeForTrace(result), - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + method: name, + phase: 'success', + result: summarizeForTrace(result), + }); + } + preloadPerformanceCapture.success( + performanceCall, + result + ); return result; } catch (error) { - emitRendererTrace({ - durationMs: roundTraceDuration( - (globalThis.performance?.now?.() ?? - Date.now()) - startedAt - ), - error: summarizeForTrace(error), - method: name, - phase: 'error', - }); + if (shouldTraceRendererApi) { + emitRendererTrace({ + durationMs: roundTraceDuration( + (globalThis.performance?.now?.() ?? + Date.now()) - startedAt + ), + error: summarizeForTrace(error), + method: name, + phase: 'error', + }); + } + preloadPerformanceCapture.error(performanceCall); throw error; } }, @@ -530,8 +569,16 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('EPG_MAPPING_GET', { channelKey }), getEpgMappingsBatch: (channelKeys: string[]) => ipcRenderer.invoke('EPG_MAPPING_GET_BATCH', { channelKeys }), - setEpgMapping: (channelKey: string, epgChannelId: string, playlistId?: string) => - ipcRenderer.invoke('EPG_MAPPING_SET', { channelKey, epgChannelId, playlistId }), + setEpgMapping: ( + channelKey: string, + epgChannelId: string, + playlistId?: string + ) => + ipcRenderer.invoke('EPG_MAPPING_SET', { + channelKey, + epgChannelId, + playlistId, + }), deleteEpgMapping: (channelKey: string) => ipcRenderer.invoke('EPG_MAPPING_DELETE', { channelKey }), searchEpgChannels: (searchTerm: string, limit?: number) => @@ -571,14 +618,14 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist), dbGetPlaylist: (playlistId: string) => ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId), - dbUpsertAppPlaylist: (playlist: Playlist) => + dbUpsertAppPlaylist: (playlist: Playlist, _operationId?: string) => ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist), dbUpsertAppPlaylists: (playlists: Playlist[]) => ipcRenderer.invoke('DB_UPSERT_APP_PLAYLISTS', playlists), dbGetAppPlaylists: () => ipcRenderer.invoke('DB_GET_APP_PLAYLISTS'), dbGetAppPlaylistMetas: () => ipcRenderer.invoke('DB_GET_APP_PLAYLIST_METAS'), - dbGetAppPlaylist: (playlistId: string) => + dbGetAppPlaylist: (playlistId: string, _operationId?: string) => ipcRenderer.invoke('DB_GET_APP_PLAYLIST', playlistId), dbGetAppPlaylistFavoriteChannels: (playlistId: string) => ipcRenderer.invoke('DB_GET_APP_PLAYLIST_FAVORITE_CHANNELS', playlistId), diff --git a/apps/electron-backend/src/app/api/preload-performance-capture.spec.ts b/apps/electron-backend/src/app/api/preload-performance-capture.spec.ts new file mode 100644 index 000000000..9d900e13a --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-capture.spec.ts @@ -0,0 +1,50 @@ +import { + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type PreloadPerformanceMarker, +} from '@iptvnator/shared/interfaces'; +import { createPreloadPerformanceCapture } from './preload-performance-capture'; + +describe('preload performance capture timestamps', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('uses fractional monotonic time on top of the epoch origin', () => { + const markers: PreloadPerformanceMarker[] = []; + const monotonicNow = jest + .spyOn(globalThis.performance, 'now') + .mockReturnValueOnce(10.125) + .mockReturnValueOnce(10.375); + const capture = createPreloadPerformanceCapture( + true, + (channel, marker) => { + expect(channel).toBe(PRELOAD_PERFORMANCE_MARKER_CHANNEL); + markers.push(marker); + } + ); + + const call = capture.start( + PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + [ + { + operationId: 'operation-1', + playlistId: 'playlist-1', + }, + ] + ); + capture.success(call, {}); + + expect(monotonicNow).toHaveBeenCalledTimes(2); + expect(markers).toHaveLength(2); + expect( + markers[0].sourceEpochMs - globalThis.performance.timeOrigin + ).toBeCloseTo(10.125, 3); + expect( + markers[1].sourceEpochMs - globalThis.performance.timeOrigin + ).toBeCloseTo(10.375, 3); + expect(markers[1].sourceEpochMs).toBeGreaterThan( + markers[0].sourceEpochMs + ); + }); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-capture.ts b/apps/electron-backend/src/app/api/preload-performance-capture.ts new file mode 100644 index 000000000..62ae3e25d --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-capture.ts @@ -0,0 +1,197 @@ +import { + isPlaylistRefreshCancelledResult, + PRELOAD_PERFORMANCE_MARKER_CHANNEL, + PRELOAD_PERFORMANCE_METHOD, + type PreloadPerformanceMarker, + type PreloadPerformanceMethod, + type PreloadPerformancePhase, +} from '@iptvnator/shared/interfaces'; +import { + advancePreloadPerformanceCorrelation, + createPreloadPerformanceCorrelationState, + normalizePreloadPerformanceIdentifier, + type PreloadPerformanceCorrelationState, +} from './preload-performance-correlation'; + +export interface PreloadPerformanceCallContext { + ipcCallId: number; + method: PreloadPerformanceMethod; + operationId: string | null; + playlistId: string | null; +} + +export interface PreloadPerformanceCapture { + error: (call: PreloadPerformanceCallContext | null) => void; + start: ( + methodName: string, + args: readonly unknown[] + ) => PreloadPerformanceCallContext | null; + success: ( + call: PreloadPerformanceCallContext | null, + result: unknown + ) => void; +} + +type MarkerSender = ( + channel: typeof PRELOAD_PERFORMANCE_MARKER_CHANNEL, + marker: PreloadPerformanceMarker +) => void; + +function readProperty(value: unknown, property: string): unknown { + if ( + (typeof value !== 'object' || value === null) && + typeof value !== 'function' + ) { + return undefined; + } + + try { + return Reflect.get(value, property); + } catch { + return undefined; + } +} + +export function toPreloadPerformanceTargetMethod( + methodName: string +): PreloadPerformanceMethod | null { + switch (methodName) { + case PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST: + case PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST: + case PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST: + return methodName; + default: + return null; + } +} + +function readHighResolutionEpochMs(): number { + const timeOrigin = globalThis.performance?.timeOrigin; + const monotonicNow = globalThis.performance?.now?.(); + const highResolutionEpoch = + typeof timeOrigin === 'number' && typeof monotonicNow === 'number' + ? timeOrigin + monotonicNow + : Number.NaN; + + return Number.isFinite(highResolutionEpoch) + ? highResolutionEpoch + : Date.now(); +} + +function extractIdentifiers( + method: PreloadPerformanceMethod, + args: readonly unknown[] +): Pick { + if (method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { + const payload = args[0]; + return { + operationId: normalizePreloadPerformanceIdentifier( + readProperty(payload, 'operationId') + ), + playlistId: normalizePreloadPerformanceIdentifier( + readProperty(payload, 'playlistId') + ), + }; + } + + if (method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) { + return { + operationId: normalizePreloadPerformanceIdentifier(args[1]), + playlistId: normalizePreloadPerformanceIdentifier(args[0]), + }; + } + + return { + operationId: normalizePreloadPerformanceIdentifier(args[1]), + playlistId: normalizePreloadPerformanceIdentifier( + readProperty(args[0], '_id') + ), + }; +} + +export function createPreloadPerformanceCapture( + enabled: boolean, + sendMarker: MarkerSender +): PreloadPerformanceCapture { + let correlationState: PreloadPerformanceCorrelationState = + createPreloadPerformanceCorrelationState(); + let markerDeliveryFailed = false; + let lastSourceEpochMs = 0; + let nextIpcCallId = 1; + + function emit( + call: PreloadPerformanceCallContext, + phase: PreloadPerformancePhase, + refreshCancelled = false + ): void { + if (markerDeliveryFailed) { + return; + } + + try { + const transition = advancePreloadPerformanceCorrelation( + correlationState, + { + ...call, + phase, + refreshCancelled, + } + ); + const sourceEpochMs = Math.max( + readHighResolutionEpochMs(), + lastSourceEpochMs + ); + sendMarker(PRELOAD_PERFORMANCE_MARKER_CHANNEL, { + ...transition.marker, + sourceEpochMs, + }); + correlationState = transition.state; + lastSourceEpochMs = sourceEpochMs; + } catch { + markerDeliveryFailed = true; + // Performance instrumentation must never affect the bridge call. + } + } + + return { + error: (call) => { + if (call) { + emit(call, 'error'); + } + }, + start: (methodName, args) => { + if (!enabled) { + return null; + } + + const method = toPreloadPerformanceTargetMethod(methodName); + if (!method) { + return null; + } + + const call: PreloadPerformanceCallContext = { + ipcCallId: nextIpcCallId, + method, + ...extractIdentifiers(method, args), + }; + nextIpcCallId += 1; + emit(call, 'start'); + return call; + }, + success: (call, result) => { + if (!call) { + return; + } + + let refreshCancelled = false; + if (call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { + try { + refreshCancelled = isPlaylistRefreshCancelledResult(result); + } catch { + refreshCancelled = false; + } + } + emit(call, 'success', refreshCancelled); + }, + }; +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts new file mode 100644 index 000000000..e8dc4e699 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.complete.ts @@ -0,0 +1,246 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + PRELOAD_PERFORMANCE_SEQUENCE_STAGE, + closeInvalidPreloadPerformanceSequenceWhenSettled, + createPreloadPerformanceMarkerMetadata, + invalidatePreloadPerformanceSequence, + type PreloadPerformanceCall, + type PreloadPerformanceCorrelationEvent, + type PreloadPerformanceMarkerMetadata, + type PreloadPerformanceSequence, +} from './preload-performance-correlation.model'; + +function eventForRememberedCall( + event: PreloadPerformanceCorrelationEvent, + call: PreloadPerformanceCall +): PreloadPerformanceCorrelationEvent { + return { + ...event, + method: call.method, + operationId: call.operationId, + playlistId: call.playlistId, + }; +} + +function invalidCompletion( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + call: PreloadPerformanceCall +): PreloadPerformanceMarkerMetadata { + const rememberedEvent = eventForRememberedCall(event, call); + const invalidSequence = + call.playlistId === null + ? undefined + : invalidatePreloadPerformanceSequence( + calls, + sequences, + call.playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + calls.delete(event.ipcCallId); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + call.playlistId + ); + return createPreloadPerformanceMarkerMetadata( + rememberedEvent, + call.playlistId, + invalidSequence?.operationId ?? call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidSequence?.invalidReason ?? + call.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); +} + +function completeCorrelatedCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + call: PreloadPerformanceCall & { + operationId: string; + playlistId: string; + } +): PreloadPerformanceMarkerMetadata { + const sequence = sequences.get(call.playlistId); + const expectedStage = + call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_STARTED + : call.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_STARTED + : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_UPSERT_STARTED; + if ( + !sequence || + sequence.operationId !== call.operationId || + sequence.stage !== expectedStage + ) { + return invalidCompletion(calls, sequences, event, call); + } + + if (event.phase === 'error') { + invalidatePreloadPerformanceSequence( + calls, + sequences, + call.playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR + ); + calls.delete(event.ipcCallId); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + call.playlistId + ); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR + ); + } + + if ( + call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST && + event.refreshCancelled === true + ) { + calls.delete(event.ipcCallId); + sequences.delete(call.playlistId); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED + ); + } + + calls.delete(event.ipcCallId); + if (call.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST) { + sequences.set(call.playlistId, { + ...sequence, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_SUCCEEDED, + }); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); + } + + if (call.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST) { + sequences.set(call.playlistId, { + ...sequence, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_SUCCEEDED, + }); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); + } + + sequences.delete(call.playlistId); + return createPreloadPerformanceMarkerMetadata( + event, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + null + ); +} + +export function completePreloadPerformanceCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null +): PreloadPerformanceMarkerMetadata { + const call = calls.get(event.ipcCallId); + if (!call) { + const invalidSequence = + playlistId === null + ? undefined + : invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + playlistId + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + invalidSequence?.operationId ?? operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + } + + const identityMatches = + call.method === event.method && + call.operationId === operationId && + call.playlistId === playlistId; + if (!identityMatches) { + return invalidCompletion(calls, sequences, event, call); + } + + const rememberedEvent = eventForRememberedCall(event, call); + if ( + call.correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED + ) { + calls.delete(event.ipcCallId); + return createPreloadPerformanceMarkerMetadata( + rememberedEvent, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + null + ); + } + + if ( + call.correlationState === PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID + ) { + calls.delete(event.ipcCallId); + closeInvalidPreloadPerformanceSequenceWhenSettled( + calls, + sequences, + call.playlistId + ); + return createPreloadPerformanceMarkerMetadata( + rememberedEvent, + call.playlistId, + call.operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + call.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + } + + return completeCorrelatedCall( + calls, + sequences, + rememberedEvent, + call as PreloadPerformanceCall & { + operationId: string; + playlistId: string; + } + ); +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts new file mode 100644 index 000000000..e10251ddd --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.completion.spec.ts @@ -0,0 +1,311 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + PLAYLIST_TWO, + type CorrelationHarness, +} from './preload-performance-correlation.test-helpers'; + +function completeDatabaseSequence( + advance: CorrelationHarness['advance'], + firstCallId: number, + playlistId: string, + operationId: string +) { + return [ + advance({ + ipcCallId: firstCallId, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId, + phase: 'start', + playlistId, + }), + advance({ + ipcCallId: firstCallId, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId, + phase: 'success', + playlistId, + }), + advance({ + ipcCallId: firstCallId + 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId, + phase: 'start', + playlistId, + }), + advance({ + ipcCallId: firstCallId + 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId, + phase: 'success', + playlistId, + }), + ]; +} + +describe('preload performance marker correlation completions', () => { + let advance: CorrelationHarness['advance']; + + beforeEach(() => { + ({ advance } = createCorrelationHarness()); + }); + + it.each(['success', 'error'] as const)( + 'invalidates only the matching playlist after an unknown call ID %s', + (phase) => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + + const unknownCompletion = advance({ + ipcCallId: 99, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase, + playlistId: PLAYLIST_ONE, + }); + const matchingCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const samePlaylistPersistence = completeDatabaseSequence( + advance, + 20, + PLAYLIST_ONE, + OPERATION_ONE + ); + + const unrelatedRefreshCompletion = advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + const unrelatedPersistence = completeDatabaseSequence( + advance, + 30, + PLAYLIST_TWO, + OPERATION_TWO + ); + + expect(unknownCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect(matchingCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + expect( + [matchingCompletion, ...samePlaylistPersistence].some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); + expect(unrelatedRefreshCompletion.correlationState).toBe( + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ); + expect(unrelatedPersistence.at(-1)).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_TWO, + }); + } + ); + + it('invalidates persistence after a duplicate completion without touching another playlist', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + const duplicateCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const samePlaylistPersistence = completeDatabaseSequence( + advance, + 20, + PLAYLIST_ONE, + OPERATION_ONE + ); + + advance({ + ipcCallId: 10, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + const unrelatedPersistence = completeDatabaseSequence( + advance, + 30, + PLAYLIST_TWO, + OPERATION_TWO + ); + + expect(duplicateCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect( + samePlaylistPersistence.some( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE + ) + ).toBe(false); + expect(unrelatedPersistence.at(-1)).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_TWO, + }); + }); + + it('invalidates a correlated sequence when a target call errors', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const error = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'error', + playlistId: PLAYLIST_ONE, + }); + const laterUpsert = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const restart = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(error).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.IPC_ERROR, + operationId: OPERATION_ONE, + }); + expect(laterUpsert).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + operationId: OPERATION_ONE, + }); + expect(restart.correlationState).toBe( + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ); + }); + + it('closes a cancelled refresh without awaiting database persistence', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const cancelled = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + refreshCancelled: true, + }); + const laterGet = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(cancelled).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.REFRESH_CANCELLED, + operationId: OPERATION_ONE, + }); + expect(laterGet).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + operationId: OPERATION_ONE, + }); + }); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.identity.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.identity.spec.ts new file mode 100644 index 000000000..aec9e4c7b --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.identity.spec.ts @@ -0,0 +1,183 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + PLAYLIST_TWO, +} from './preload-performance-correlation.test-helpers'; + +describe('preload performance correlation identities', () => { + it('advances only database calls tagged with the refresh operation', () => { + const { advance } = createCorrelationHarness(); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + const unrelated = [ + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }), + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }), + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }), + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_ONE, + }), + ]; + + expect( + unrelated.every( + ({ correlationState }) => + correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED + ) + ).toBe(true); + + for (const event of [ + { + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'start', + }, + { + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + phase: 'success', + }, + { + ipcCallId: 5, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + phase: 'start', + }, + ] as const) { + advance({ + ...event, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + } + const complete = advance({ + ipcCallId: 5, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(complete).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + }); + + it.each([ + { + label: 'operation', + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_ONE, + }, + { + label: 'playlist', + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_TWO, + }, + { + label: 'method', + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }, + ])( + 'fails closed when the completion changes the started $label', + ({ method, operationId, playlistId }) => { + const { advance } = createCorrelationHarness(); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const completion = advance({ + ipcCallId: 2, + method, + operationId, + phase: 'success', + playlistId, + }); + const laterUpsert = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(completion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect(laterUpsert).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + }); + } + ); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts new file mode 100644 index 000000000..65c63c3cf --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.model.ts @@ -0,0 +1,173 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + type PreloadPerformanceCorrelationStateName, + type PreloadPerformanceInvalidReason, + type PreloadPerformanceMarker, + type PreloadPerformanceMethod, + type PreloadPerformancePhase, +} from '@iptvnator/shared/interfaces'; + +const MAX_IDENTIFIER_LENGTH = 128; +const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; + +export const PRELOAD_PERFORMANCE_SEQUENCE_STAGE = { + DB_GET_STARTED: 'db-get-started', + DB_GET_SUCCEEDED: 'db-get-succeeded', + DB_UPSERT_STARTED: 'db-upsert-started', + INVALID: 'invalid', + REFRESH_STARTED: 'refresh-started', + REFRESH_SUCCEEDED: 'refresh-succeeded', +} as const; + +export type PreloadPerformanceSequenceStage = + (typeof PRELOAD_PERFORMANCE_SEQUENCE_STAGE)[keyof typeof PRELOAD_PERFORMANCE_SEQUENCE_STAGE]; + +export interface PreloadPerformanceSequence { + invalidReason: PreloadPerformanceInvalidReason | null; + operationId: string; + stage: PreloadPerformanceSequenceStage; +} + +export interface PreloadPerformanceCall { + correlationState: PreloadPerformanceCorrelationStateName; + invalidReason: PreloadPerformanceInvalidReason | null; + method: PreloadPerformanceMethod; + operationId: string | null; + playlistId: string | null; +} + +export interface PreloadPerformanceCorrelationEvent { + ipcCallId: number; + method: PreloadPerformanceMethod; + operationId: unknown; + phase: PreloadPerformancePhase; + playlistId: unknown; + refreshCancelled?: boolean; +} + +export interface PreloadPerformanceCorrelationState { + readonly calls: ReadonlyMap; + readonly sequences: ReadonlyMap; +} + +export interface PreloadPerformanceCorrelationResult { + marker: PreloadPerformanceMarkerMetadata; + state: PreloadPerformanceCorrelationState; +} + +export type PreloadPerformanceMarkerMetadata = Omit< + PreloadPerformanceMarker, + 'sourceEpochMs' +>; + +export function normalizePreloadPerformanceIdentifier( + value: unknown +): string | null { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MAX_IDENTIFIER_LENGTH || + value !== value.trim() || + !SAFE_IDENTIFIER_PATTERN.test(value) + ) { + return null; + } + + return value; +} + +export function createPreloadPerformanceMarkerMetadata( + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null, + correlationState: PreloadPerformanceCorrelationStateName, + invalidReason: PreloadPerformanceInvalidReason | null +): PreloadPerformanceMarkerMetadata { + return { + correlationState, + invalidReason, + ipcCallId: event.ipcCallId, + method: event.method, + operationId, + phase: event.phase, + playlistId, + }; +} + +export function createInvalidPreloadPerformanceCall( + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null, + invalidReason: PreloadPerformanceInvalidReason +): PreloadPerformanceCall { + return { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason, + method: event.method, + operationId, + playlistId, + }; +} + +export function invalidatePreloadPerformanceSequence( + calls: Map, + sequences: Map, + playlistId: string, + reason: PreloadPerformanceInvalidReason +): PreloadPerformanceSequence | undefined { + const sequence = sequences.get(playlistId); + if (!sequence) { + return undefined; + } + + const invalidReason = + sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID + ? (sequence.invalidReason ?? reason) + : reason; + const invalidSequence: PreloadPerformanceSequence = { + ...sequence, + invalidReason, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID, + }; + sequences.set(playlistId, invalidSequence); + + for (const [callId, call] of calls) { + if ( + call.playlistId === playlistId && + call.correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ) { + calls.set(callId, { + ...call, + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason, + }); + } + } + + return invalidSequence; +} + +export function closeInvalidPreloadPerformanceSequenceWhenSettled( + calls: ReadonlyMap, + sequences: Map, + playlistId: string | null +): void { + if ( + playlistId === null || + sequences.get(playlistId)?.stage !== + PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID + ) { + return; + } + + const hasInvalidCall = Array.from(calls.values()).some( + (call) => + call.playlistId === playlistId && + call.correlationState === + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID + ); + if (!hasInvalidCall) { + sequences.delete(playlistId); + } +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts new file mode 100644 index 000000000..c73f50769 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.sequence.spec.ts @@ -0,0 +1,338 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + PLAYLIST_TWO, + type CorrelationEvent, + type CorrelationHarness, +} from './preload-performance-correlation.test-helpers'; + +describe('preload performance marker correlation sequences', () => { + let advance: CorrelationHarness['advance']; + let reset: CorrelationHarness['reset']; + + beforeEach(() => { + ({ advance, reset } = createCorrelationHarness()); + }); + + it('correlates the successful refresh persistence sequence and propagates its operation ID', () => { + const refreshStart = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const refreshSuccess = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const getStart = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const getSuccess = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const upsertStart = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const upsertSuccess = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(refreshStart).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + operationId: OPERATION_ONE, + playlistId: PLAYLIST_ONE, + }); + expect(refreshSuccess).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + operationId: OPERATION_ONE, + }); + expect(getStart.operationId).toBe(OPERATION_ONE); + expect(getSuccess.operationId).toBe(OPERATION_ONE); + expect(upsertStart.operationId).toBe(OPERATION_ONE); + expect(upsertSuccess).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.COMPLETE, + invalidReason: null, + operationId: OPERATION_ONE, + }); + }); + + it('fails closed for wrong-order and duplicate targeted database calls', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + const wrongOrder = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(wrongOrder).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + operationId: OPERATION_ONE, + }); + + reset(); + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const duplicate = advance({ + ipcCallId: 5, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const originalCompletion = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(duplicate.invalidReason).toBe( + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + expect(originalCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER, + }); + }); + + it('invalidates concurrent refreshes for the same playlist', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const concurrentStart = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const firstCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const concurrentCompletion = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + const restart = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: 'operation-3', + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(concurrentStart).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH, + operationId: OPERATION_TWO, + }); + expect(concurrentCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH, + operationId: OPERATION_TWO, + }); + expect(restart.correlationState).toBe( + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED + ); + expect(firstCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH, + }); + }); + + it('keeps different playlists independent when one sequence invalidates', () => { + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'success', + playlistId: PLAYLIST_TWO, + }); + advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + const playlistTwoGet = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_TWO, + }); + + expect(playlistTwoGet).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + operationId: OPERATION_TWO, + playlistId: PLAYLIST_TWO, + }); + }); + + it('allows a new refresh after the prior sequence completes', () => { + const events: CorrelationEvent[] = [ + { + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }, + { + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: PLAYLIST_ONE, + }, + ]; + events.forEach((event) => advance(event)); + + const restart = advance({ + ipcCallId: 4, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_TWO, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + + expect(restart).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + operationId: OPERATION_TWO, + }); + }); +}); diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts new file mode 100644 index 000000000..3363ba13c --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.start.ts @@ -0,0 +1,235 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + PRELOAD_PERFORMANCE_SEQUENCE_STAGE, + createInvalidPreloadPerformanceCall, + createPreloadPerformanceMarkerMetadata, + invalidatePreloadPerformanceSequence, + type PreloadPerformanceCall, + type PreloadPerformanceCorrelationEvent, + type PreloadPerformanceMarkerMetadata, + type PreloadPerformanceSequence, +} from './preload-performance-correlation.model'; + +function rememberUncorrelatedCall( + calls: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string, + operationId: string | null +): PreloadPerformanceMarkerMetadata { + calls.set(event.ipcCallId, { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + method: event.method, + operationId, + playlistId, + }); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + null + ); +} + +function startRefresh( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string, + operationId: string +): PreloadPerformanceMarkerMetadata { + const existing = sequences.get(playlistId); + if (existing) { + const reason = + existing.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID + ? (existing.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER) + : PRELOAD_PERFORMANCE_INVALID_REASON.CONCURRENT_REFRESH; + const invalidSequence = invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + reason + ); + const invalidReason = invalidSequence?.invalidReason ?? reason; + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + operationId, + invalidReason + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason + ); + } + + sequences.set(playlistId, { + invalidReason: null, + operationId, + stage: PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_STARTED, + }); + calls.set(event.ipcCallId, { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + method: event.method, + operationId, + playlistId, + }); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); +} + +function startDatabaseCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string, + operationId: string | null +): PreloadPerformanceMarkerMetadata { + if (operationId === null) { + return rememberUncorrelatedCall(calls, event, playlistId, operationId); + } + + const sequence = sequences.get(playlistId); + if (!sequence || sequence.operationId !== operationId) { + return rememberUncorrelatedCall(calls, event, playlistId, operationId); + } + + if (sequence.stage === PRELOAD_PERFORMANCE_SEQUENCE_STAGE.INVALID) { + const invalidReason = + sequence.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER; + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + operationId, + invalidReason + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason + ); + } + + const isGet = + event.method === PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST; + const expectedStage = isGet + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.REFRESH_SUCCEEDED + : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_SUCCEEDED; + const startedStage = isGet + ? PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_GET_STARTED + : PRELOAD_PERFORMANCE_SEQUENCE_STAGE.DB_UPSERT_STARTED; + + if (sequence.stage !== expectedStage) { + const invalidSequence = invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER + ); + const invalidReason = + invalidSequence?.invalidReason ?? + PRELOAD_PERFORMANCE_INVALID_REASON.OUT_OF_ORDER; + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + operationId, + invalidReason + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason + ); + } + + sequences.set(playlistId, { + ...sequence, + stage: startedStage, + }); + calls.set(event.ipcCallId, { + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + invalidReason: null, + method: event.method, + operationId, + playlistId, + }); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + operationId, + PRELOAD_PERFORMANCE_CORRELATION_STATE.CORRELATED, + null + ); +} + +export function startPreloadPerformanceCall( + calls: Map, + sequences: Map, + event: PreloadPerformanceCorrelationEvent, + playlistId: string | null, + operationId: string | null +): PreloadPerformanceMarkerMetadata { + if ( + playlistId === null || + (event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST && + operationId === null) + ) { + if (playlistId !== null) { + invalidatePreloadPerformanceSequence( + calls, + sequences, + playlistId, + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER + ); + } + calls.set( + event.ipcCallId, + createInvalidPreloadPerformanceCall( + event, + playlistId, + null, + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER + ) + ); + return createPreloadPerformanceMarkerMetadata( + event, + playlistId, + null, + PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER + ); + } + + return event.method === PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST + ? startRefresh(calls, sequences, event, playlistId, operationId) + : startDatabaseCall(calls, sequences, event, playlistId, operationId); +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.test-helpers.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.test-helpers.ts new file mode 100644 index 000000000..bf4d29321 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.test-helpers.ts @@ -0,0 +1,29 @@ +import { + advancePreloadPerformanceCorrelation, + createPreloadPerformanceCorrelationState, + type PreloadPerformanceCorrelationEvent, +} from './preload-performance-correlation'; + +export const PLAYLIST_ONE = 'playlist-1'; +export const PLAYLIST_TWO = 'playlist-2'; +export const OPERATION_ONE = 'operation-1'; +export const OPERATION_TWO = 'operation-2'; + +export type CorrelationEvent = PreloadPerformanceCorrelationEvent; + +export function createCorrelationHarness() { + let state = createPreloadPerformanceCorrelationState(); + + return { + advance: (event: PreloadPerformanceCorrelationEvent) => { + const result = advancePreloadPerformanceCorrelation(state, event); + state = result.state; + return result.marker; + }, + reset: () => { + state = createPreloadPerformanceCorrelationState(); + }, + }; +} + +export type CorrelationHarness = ReturnType; diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.ts new file mode 100644 index 000000000..a42c82749 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.ts @@ -0,0 +1,58 @@ +import { completePreloadPerformanceCall } from './preload-performance-correlation.complete'; +import { + normalizePreloadPerformanceIdentifier, + type PreloadPerformanceCorrelationEvent, + type PreloadPerformanceCorrelationResult, + type PreloadPerformanceCorrelationState, +} from './preload-performance-correlation.model'; +import { startPreloadPerformanceCall } from './preload-performance-correlation.start'; + +export type { + PreloadPerformanceCorrelationEvent, + PreloadPerformanceCorrelationResult, + PreloadPerformanceCorrelationState, +} from './preload-performance-correlation.model'; +export { normalizePreloadPerformanceIdentifier } from './preload-performance-correlation.model'; + +export function createPreloadPerformanceCorrelationState(): PreloadPerformanceCorrelationState { + return { + calls: new Map(), + sequences: new Map(), + }; +} + +export function advancePreloadPerformanceCorrelation( + state: PreloadPerformanceCorrelationState, + event: PreloadPerformanceCorrelationEvent +): PreloadPerformanceCorrelationResult { + const calls = new Map(state.calls); + const sequences = new Map(state.sequences); + const playlistId = normalizePreloadPerformanceIdentifier(event.playlistId); + const operationId = normalizePreloadPerformanceIdentifier( + event.operationId + ); + const marker = + event.phase === 'start' + ? startPreloadPerformanceCall( + calls, + sequences, + event, + playlistId, + operationId + ) + : completePreloadPerformanceCall( + calls, + sequences, + event, + playlistId, + operationId + ); + + return { + marker, + state: { + calls, + sequences, + }, + }; +} diff --git a/apps/electron-backend/src/app/api/preload-performance-correlation.validation.spec.ts b/apps/electron-backend/src/app/api/preload-performance-correlation.validation.spec.ts new file mode 100644 index 000000000..9252bf378 --- /dev/null +++ b/apps/electron-backend/src/app/api/preload-performance-correlation.validation.spec.ts @@ -0,0 +1,117 @@ +import { + PRELOAD_PERFORMANCE_CORRELATION_STATE, + PRELOAD_PERFORMANCE_INVALID_REASON, + PRELOAD_PERFORMANCE_METHOD, +} from '@iptvnator/shared/interfaces'; +import { + createCorrelationHarness, + OPERATION_ONE, + OPERATION_TWO, + PLAYLIST_ONE, + type CorrelationHarness, +} from './preload-performance-correlation.test-helpers'; + +describe('preload performance marker correlation validation', () => { + let advance: CorrelationHarness['advance']; + + beforeEach(() => { + ({ advance } = createCorrelationHarness()); + }); + + it('uses null identifiers and a fixed reason for malformed inputs', () => { + const malformedPlaylist = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: 'https://secret.example/private.m3u', + }); + const malformedOperation = advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: { secret: OPERATION_TWO }, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const malformedDbId = advance({ + ipcCallId: 3, + method: PRELOAD_PERFORMANCE_METHOD.DB_UPSERT_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: undefined, + }); + const malformedPlaylistCompletion = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'success', + playlistId: 'https://secret.example/private.m3u', + }); + + expect(malformedPlaylist).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: null, + }); + expect(malformedOperation).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: PLAYLIST_ONE, + }); + expect(malformedDbId).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: null, + }); + expect(malformedPlaylistCompletion).toMatchObject({ + correlationState: PRELOAD_PERFORMANCE_CORRELATION_STATE.INVALID, + invalidReason: + PRELOAD_PERFORMANCE_INVALID_REASON.MALFORMED_IDENTIFIER, + operationId: null, + playlistId: null, + }); + }); + + it('keeps targeted database calls outside a sequence visible and uncorrelated', () => { + const getStart = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + advance({ + ipcCallId: 2, + method: PRELOAD_PERFORMANCE_METHOD.REFRESH_PLAYLIST, + operationId: OPERATION_ONE, + phase: 'start', + playlistId: PLAYLIST_ONE, + }); + const getSuccess = advance({ + ipcCallId: 1, + method: PRELOAD_PERFORMANCE_METHOD.DB_GET_APP_PLAYLIST, + operationId: null, + phase: 'success', + playlistId: PLAYLIST_ONE, + }); + + expect(getStart).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + operationId: null, + }); + expect(getSuccess).toMatchObject({ + correlationState: + PRELOAD_PERFORMANCE_CORRELATION_STATE.UNCORRELATED, + invalidReason: null, + operationId: null, + }); + }); +}); 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 ccced7e98..090eecabb 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 @@ -7,6 +7,20 @@ 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); @@ -34,7 +48,24 @@ describe('downloads events', () => { 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) => @@ -50,8 +81,8 @@ describe('downloads events', () => { }), }, shell: { - openPath: jest.fn(), - showItemInFolder: jest.fn(), + openPath: mockOpenPath, + showItemInFolder: mockShowItemInFolder, }, })); jest.doMock('../../database/connection', () => ({ @@ -77,8 +108,45 @@ describe('downloads events', () => { })); await import('./downloads.events'); + const schema = await import('../../database/schema'); + downloadsFilePathColumn = schema.downloads.filePath; }); + 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 = { @@ -101,7 +169,9 @@ describe('downloads events', () => { const deleteWhere = jest.fn().mockResolvedValue(undefined); const selectWhere = jest .fn() - .mockResolvedValue(rows.map((row, index) => ({ id: index + 1, ...row }))); + .mockResolvedValue( + rows.map((row, index) => ({ id: index + 1, ...row })) + ); const db = { delete: jest.fn(() => ({ where: deleteWhere })), select: jest.fn(() => ({ @@ -117,9 +187,11 @@ describe('downloads events', () => { it('removes queued resumed partial files before deleting the row', async () => { const { deleteWhere } = mockDownloadRow(createDownloadRow('queued')); - await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({ - success: true, - }); + await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual( + { + success: true, + } + ); expect(mockRemoveDownloadFromRuntime).toHaveBeenCalledWith(42); expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith( @@ -127,7 +199,9 @@ describe('downloads events', () => { ); expect( mockRemovePartialDownloadFile.mock.invocationCallOrder[0] - ).toBeLessThan(mockRemoveDownloadFromRuntime.mock.invocationCallOrder[0]); + ).toBeLessThan( + mockRemoveDownloadFromRuntime.mock.invocationCallOrder[0] + ); expect( mockRemovePartialDownloadFile.mock.invocationCallOrder[0] ).toBeLessThan(deleteWhere.mock.invocationCallOrder[0]); @@ -137,9 +211,11 @@ describe('downloads events', () => { it('removes completed partial files before deleting the row', async () => { const { deleteWhere } = mockDownloadRow(createDownloadRow('completed')); - await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual({ - success: true, - }); + await expect(getHandler('DOWNLOADS_REMOVE')(null, 42)).resolves.toEqual( + { + success: true, + } + ); expect(mockRemovePartialDownloadFile).toHaveBeenCalledWith( '/downloads/resume.mp4' @@ -328,4 +404,127 @@ describe('downloads events', () => { 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/remote-control.events.spec.ts b/apps/electron-backend/src/app/events/remote-control.events.spec.ts new file mode 100644 index 000000000..67713fe93 --- /dev/null +++ b/apps/electron-backend/src/app/events/remote-control.events.spec.ts @@ -0,0 +1,588 @@ +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); +}); + +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'; + +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; + }); + + 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', () => { + 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 } }, + ]); + consoleLog = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.useRealTimers(); + consoleLog.mockRestore(); + consoleWarn.mockRestore(); + }); + + it('registers exactly the seven remote-control HTTP endpoints', () => { + bootstrapRemoteControl(); + + expect(mockRegisterRemoteControlHandler).toHaveBeenCalledTimes(7); + expect([...mockHttpHandlers.keys()].sort()).toEqual( + Object.values(REMOTE_CONTROL_PATHS).sort() + ); + }); + + it('starts the HTTP server once with the stored port when enabled', () => { + bootstrapRemoteControl({ enabled: true, port: 9987 }); + + expect(mockStoreGet).toHaveBeenNthCalledWith(1, 'remoteControl', false); + expect(mockStoreGet).toHaveBeenNthCalledWith( + 2, + 'remoteControlPort', + 8765 + ); + expect(mockStartHttpServer).toHaveBeenCalledTimes(1); + expect(mockStartHttpServer).toHaveBeenCalledWith(9987); + }); + + it('does not start the HTTP server when remote control is disabled', () => { + bootstrapRemoteControl({ enabled: false, port: 9987 }); + + expect(mockStartHttpServer).not.toHaveBeenCalled(); + }); + + it('registers and dispatches both main-app channel IPC handlers', () => { + bootstrapRemoteControl(); + + expect(mockIpcHandle).toHaveBeenCalledTimes(2); + expect([...mockIpcHandlers.keys()].sort()).toEqual([ + 'REMOTE_CONTROL_CHANNEL_DOWN', + 'REMOTE_CONTROL_CHANNEL_UP', + ]); + + const ipcEventPlaceholder = {}; + expect( + getIpcHandler('REMOTE_CONTROL_CHANNEL_UP')(ipcEventPlaceholder) + ).toBeUndefined(); + expect( + getIpcHandler('REMOTE_CONTROL_CHANNEL_DOWN')(ipcEventPlaceholder) + ).toBeUndefined(); + + expect(mockFirstRendererSend).toHaveBeenCalledTimes(2); + expect(mockFirstRendererSend).toHaveBeenNthCalledWith( + 1, + 'CHANNEL_CHANGE', + { direction: 'up' } + ); + expect(mockFirstRendererSend).toHaveBeenNthCalledWith( + 2, + 'CHANNEL_CHANGE', + { direction: 'down' } + ); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + }); + + it.each([ + { + path: REMOTE_CONTROL_PATHS.CHANNEL_UP, + channel: 'CHANNEL_CHANGE', + payload: { direction: 'up' }, + }, + { + path: REMOTE_CONTROL_PATHS.CHANNEL_DOWN, + channel: 'CHANNEL_CHANGE', + payload: { direction: 'down' }, + }, + { + path: REMOTE_CONTROL_PATHS.VOLUME_UP, + channel: 'REMOTE_CONTROL_COMMAND', + payload: { type: 'volume-up' }, + }, + { + path: REMOTE_CONTROL_PATHS.VOLUME_DOWN, + channel: 'REMOTE_CONTROL_COMMAND', + payload: { type: 'volume-down' }, + }, + { + path: REMOTE_CONTROL_PATHS.VOLUME_TOGGLE_MUTE, + channel: 'REMOTE_CONTROL_COMMAND', + payload: { type: 'volume-toggle-mute' }, + }, + ] as const)( + 'dispatches POST $path to the first renderer', + async ({ path, channel, payload }) => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler(path, { method: 'POST' }); + + expect(result.response).toEqual(SUCCESS_RESPONSE); + expect(mockFirstRendererSend).toHaveBeenCalledTimes(1); + expect(mockFirstRendererSend).toHaveBeenCalledWith( + channel, + payload + ); + expect(mockSecondRendererSend).not.toHaveBeenCalled(); + } + ); + + it.each([ + REMOTE_CONTROL_PATHS.SELECT_NUMBER, + REMOTE_CONTROL_PATHS.CHANNEL_UP, + REMOTE_CONTROL_PATHS.CHANNEL_DOWN, + REMOTE_CONTROL_PATHS.VOLUME_UP, + REMOTE_CONTROL_PATHS.VOLUME_DOWN, + REMOTE_CONTROL_PATHS.VOLUME_TOGGLE_MUTE, + ])('rejects GET on POST-only endpoint %s', async (path) => { + bootstrapRemoteControl(); + + const result = await invokeHttpHandler(path, { method: 'GET' }); + + expect(result.response).toEqual(METHOD_NOT_ALLOWED_RESPONSE); + 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.events.ts b/apps/electron-backend/src/app/events/remote-control.events.ts index 366284ade..9f20d8bec 100644 --- a/apps/electron-backend/src/app/events/remote-control.events.ts +++ b/apps/electron-backend/src/app/events/remote-control.events.ts @@ -26,7 +26,7 @@ interface RemoteControlStatus { updatedAt?: string; } -class RemoteControlEvents { +export class RemoteControlEvents { private remoteControlStatus: RemoteControlStatus = { portal: 'unknown', isLiveView: false, diff --git a/apps/electron-backend/src/app/events/settings.events.spec.ts b/apps/electron-backend/src/app/events/settings.events.spec.ts index fa0825ea3..d115c0514 100644 --- a/apps/electron-backend/src/app/events/settings.events.spec.ts +++ b/apps/electron-backend/src/app/events/settings.events.spec.ts @@ -1,45 +1,196 @@ -const handlers = new Map unknown>(); +import type { Settings } from '@iptvnator/shared/interfaces'; + +type ExternalPlayerArgumentsSetting = string | readonly unknown[]; +type SettingsUpdatePayload = Omit< + Partial, + | 'embeddedMpvFrameCopy' + | 'language' + | 'mpvPlayerArguments' + | 'vlcPlayerArguments' +> & { + embeddedMpvFrameCopy?: boolean | number; + language?: string; + mpvPlayerArguments?: ExternalPlayerArgumentsSetting; + vlcPlayerArguments?: ExternalPlayerArgumentsSetting; +}; +type SettingsUpdateHandler = ( + event: unknown, + settings: SettingsUpdatePayload +) => unknown; + +const SETTINGS_UPDATE = 'SETTINGS_UPDATE'; +const STORE_KEYS = { + EMBEDDED_MPV_FRAME_COPY: 'EMBEDDED_MPV_FRAME_COPY', + MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS', + MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE', + VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS', + VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE', +} as const; + +const handlers = new Map(); +const mockStoreGet = jest.fn(); +const mockStoreSet = jest.fn(); +const mockUpdateSettings = jest.fn(); +const mockIpcHandle = jest.fn( + (channel: string, handler: SettingsUpdateHandler): void => { + handlers.set(channel, handler); + } +); jest.mock('electron', () => ({ ipcMain: { - handle: jest.fn( - (channel: string, handler: (...args: unknown[]) => unknown) => { - handlers.set(channel, handler); - } - ), + handle: mockIpcHandle, }, })); jest.mock('../services/store.service', () => ({ - MPV_PLAYER_ARGUMENTS: 'mpvPlayerArguments', - MPV_REUSE_INSTANCE: 'mpvReuseInstance', - VLC_PLAYER_ARGUMENTS: 'vlcPlayerArguments', - VLC_REUSE_INSTANCE: 'vlcReuseInstance', - store: { get: jest.fn(), set: jest.fn() }, + EMBEDDED_MPV_FRAME_COPY: STORE_KEYS.EMBEDDED_MPV_FRAME_COPY, + MPV_PLAYER_ARGUMENTS: STORE_KEYS.MPV_PLAYER_ARGUMENTS, + MPV_REUSE_INSTANCE: STORE_KEYS.MPV_REUSE_INSTANCE, + VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS, + VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE, + store: { + get: mockStoreGet, + set: mockStoreSet, + }, })); jest.mock('../server/http-server', () => ({ - httpServer: { updateSettings: jest.fn() }, + httpServer: { + updateSettings: mockUpdateSettings, + }, })); -describe('SETTINGS_UPDATE logging', () => { +describe('SETTINGS_UPDATE', () => { + let consoleLogSpy: jest.SpiedFunction; + let settingsUpdateHandler: SettingsUpdateHandler; + beforeEach(async () => { - jest.spyOn(console, 'log').mockImplementation(() => undefined); + handlers.clear(); + jest.resetModules(); + mockIpcHandle.mockClear(); + mockStoreGet.mockReset(); + mockStoreSet.mockReset(); + mockUpdateSettings.mockReset(); + mockStoreGet.mockImplementation( + (_key: string, fallbackValue: unknown): unknown => fallbackValue + ); + consoleLogSpy = jest + .spyOn(console, 'log') + .mockImplementation(() => undefined); + await import('./settings.events'); + + const registeredHandler = handlers.get(SETTINGS_UPDATE); + if (!registeredHandler) { + throw new Error(`Missing ipcMain handler for ${SETTINGS_UPDATE}`); + } + settingsUpdateHandler = registeredHandler; }); afterEach(() => { - jest.restoreAllMocks(); + jest.useRealTimers(); + consoleLogSpy.mockRestore(); + }); + + it('normalizes external-player arguments and preserves explicit false reuse settings', () => { + settingsUpdateHandler( + {}, + { + mpvPlayerArguments: [' --screen=1 ', '', ' --hwdec=auto-safe '], + mpvReuseInstance: false, + vlcPlayerArguments: + ' --network-caching=1000 \r\n\n --fullscreen ', + vlcReuseInstance: false, + } + ); + + expect(mockStoreSet.mock.calls).toEqual([ + [STORE_KEYS.MPV_PLAYER_ARGUMENTS, '--screen=1\n--hwdec=auto-safe'], + [ + STORE_KEYS.VLC_PLAYER_ARGUMENTS, + '--network-caching=1000\n--fullscreen', + ], + [STORE_KEYS.MPV_REUSE_INSTANCE, false], + [STORE_KEYS.VLC_REUSE_INSTANCE, false], + ]); + expect(mockUpdateSettings).not.toHaveBeenCalled(); + }); + + it('does not persist or reconcile an empty settings update', () => { + settingsUpdateHandler({}, {}); + + expect(mockStoreGet).not.toHaveBeenCalled(); + expect(mockStoreSet).not.toHaveBeenCalled(); + expect(mockUpdateSettings).not.toHaveBeenCalled(); + }); + + it('coerces an enabled embedded-MPV frame-copy value to boolean true', () => { + settingsUpdateHandler({}, { embeddedMpvFrameCopy: 1 }); + + expect(mockStoreSet).toHaveBeenCalledTimes(1); + expect(mockStoreSet).toHaveBeenCalledWith( + STORE_KEYS.EMBEDDED_MPV_FRAME_COPY, + true + ); + expect(mockUpdateSettings).not.toHaveBeenCalled(); + }); + + it('reconciles an enabled remote-control update with the stored port', () => { + mockStoreGet.mockImplementation( + (key: string, fallbackValue: unknown): unknown => + key === 'remoteControlPort' ? 9988 : fallbackValue + ); + + settingsUpdateHandler({}, { remoteControl: true }); + + expect(mockStoreGet.mock.calls).toEqual([['remoteControlPort', 8765]]); + expect(mockStoreSet.mock.calls).toEqual([['remoteControl', true]]); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); + expect(mockUpdateSettings).toHaveBeenCalledWith(true, 9988); + }); + + it('reconciles a remote-control port update with the stored enabled state', () => { + mockStoreGet.mockImplementation( + (key: string, fallbackValue: unknown): unknown => + key === 'remoteControl' ? true : fallbackValue + ); + + settingsUpdateHandler({}, { remoteControlPort: 9123 }); + + expect(mockStoreGet.mock.calls).toEqual([['remoteControl', false]]); + expect(mockStoreSet.mock.calls).toEqual([['remoteControlPort', 9123]]); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); + expect(mockUpdateSettings).toHaveBeenCalledWith(true, 9123); + }); + + it('preserves explicit false and zero remote-control values', () => { + settingsUpdateHandler( + {}, + { remoteControl: false, remoteControlPort: 0 } + ); + + expect(mockStoreGet).not.toHaveBeenCalled(); + expect(mockStoreSet.mock.calls).toEqual([ + ['remoteControl', false], + ['remoteControlPort', 0], + ]); + expect(mockUpdateSettings).toHaveBeenCalledTimes(1); + expect(mockUpdateSettings).toHaveBeenCalledWith(false, 0); }); it('does not print a TMDB apiKey while retaining useful fields', () => { const apiKey = 'tmdb-settings-api-key-secret'; - const handler = handlers.get('SETTINGS_UPDATE'); - expect(handler).toBeDefined(); - handler?.({}, { language: 'de', tmdb: { apiKey, enabled: true } }); + settingsUpdateHandler( + {}, + { + language: 'de', + tmdb: { apiKey, enabled: true }, + } + ); - const output = JSON.stringify((console.log as jest.Mock).mock.calls); + const output = JSON.stringify(consoleLogSpy.mock.calls); expect(output).not.toContain(apiKey); expect(output).toContain('language'); expect(output).toContain('de'); @@ -49,10 +200,8 @@ describe('SETTINGS_UPDATE logging', () => { it('does not print credentials embedded in external player arguments', () => { const authorizationSecret = 'player-authorization-secret'; const cookieSecret = 'player-cookie-secret'; - const handler = handlers.get('SETTINGS_UPDATE'); - expect(handler).toBeDefined(); - handler?.( + settingsUpdateHandler( {}, { language: 'de', @@ -61,7 +210,7 @@ describe('SETTINGS_UPDATE logging', () => { } ); - const output = JSON.stringify((console.log as jest.Mock).mock.calls); + const output = JSON.stringify(consoleLogSpy.mock.calls); expect(output).not.toContain(authorizationSecret); expect(output).not.toContain(cookieSecret); expect(output).toContain('language'); diff --git a/apps/electron-backend/src/app/server/http-server.spec.ts b/apps/electron-backend/src/app/server/http-server.spec.ts new file mode 100644 index 000000000..88f3d8d38 --- /dev/null +++ b/apps/electron-backend/src/app/server/http-server.spec.ts @@ -0,0 +1,448 @@ +import { once } from 'node:events'; +import { + mkdirSync, + mkdtempSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +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 { HttpServer, resolveStaticFilePath } from './http-server'; + +interface CapturedServer { + listening: Promise; + listeningEvents: number; + server: http.Server; +} + +interface TestResponse { + body: Buffer; + contentType: string | undefined; + statusCode: number | undefined; +} + +const INDEX_BODY = Buffer.from('
IPTVnator remote control
'); +const ASSETS = { + '/app.js': { + body: Buffer.from('globalThis.remoteControlLoaded = true;'), + contentType: 'application/javascript', + }, + '/styles.css': { + body: Buffer.from('main { color: rebeccapurple; }'), + contentType: 'text/css', + }, + '/data.json': { + body: Buffer.from('{"ready":true}'), + contentType: 'application/json', + }, + '/asset.unknown': { + body: Buffer.from([0x00, 0xff, 0x49, 0x50, 0x54, 0x56]), + contentType: 'application/octet-stream', + }, +} as const; + +jest.setTimeout(15_000); + +function request(port: number, path: string): Promise { + return new Promise((resolve, reject) => { + const clientRequest = http.request( + { + host: '127.0.0.1', + method: 'GET', + path, + port, + }, + (response) => { + const chunks: Buffer[] = []; + + response.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + response.on('end', () => { + const contentType = response.headers['content-type']; + resolve({ + body: Buffer.concat(chunks), + contentType: + typeof contentType === 'string' + ? contentType + : undefined, + statusCode: response.statusCode, + }); + }); + response.on('error', reject); + } + ); + + clientRequest.on('error', reject); + clientRequest.end(); + }); +} + +function getAddress(server: http.Server): AddressInfo { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected the HTTP server to have an IP address'); + } + return address; +} + +async function closeServer(server: http.Server): Promise { + if (!server.listening) { + return; + } + + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} + +async function getAvailablePort(): Promise { + const probe = http.createServer(); + + await new Promise((resolve, reject) => { + probe.once('error', reject); + probe.listen(0, '127.0.0.1', resolve); + }); + + const port = getAddress(probe).port; + await closeServer(probe); + 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; + let server: HttpServer; + let tempRoot: string; + + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => undefined); + + capturedServers = []; + tempRoot = mkdtempSync(join(tmpdir(), 'iptvnator-http-server-')); + distPath = join(tempRoot, 'static', 'app'); + mkdirSync(distPath, { recursive: true }); + writeFileSync(join(distPath, 'index.html'), INDEX_BODY); + + for (const [assetPath, asset] of Object.entries(ASSETS)) { + writeFileSync(join(distPath, assetPath), asset.body); + } + + writeFileSync( + join(tempRoot, 'outside-secret.txt'), + 'must-not-leave-the-static-directory' + ); + + server = new HttpServer({ + createServer: (requestListener) => { + const nodeServer = http.createServer(requestListener); + const captured: CapturedServer = { + listening: once(nodeServer, 'listening').then( + () => undefined + ), + listeningEvents: 0, + server: nodeServer, + }; + nodeServer.on('listening', () => { + captured.listeningEvents += 1; + }); + capturedServers.push(captured); + return nodeServer; + }, + distPath, + }); + }); + + afterEach(async () => { + let closeError: unknown; + + try { + await Promise.all( + capturedServers.map(({ server: nodeServer }) => + closeServer(nodeServer) + ) + ); + } catch (error) { + closeError = error; + } finally { + rmSync(tempRoot, { force: true, recursive: true }); + jest.restoreAllMocks(); + } + + if (closeError) { + throw closeError; + } + }); + + async function startServer(): Promise { + const serverIndex = capturedServers.length; + server.start(0); + const captured = capturedServers[serverIndex]; + if (!captured) { + throw new Error('Expected HttpServer.start() to create a server'); + } + + await captured.listening; + return captured; + } + + it('serves the exact index document at the root', async () => { + const captured = await startServer(); + + const response = await request(getAddress(captured.server).port, '/'); + + expect(response).toEqual({ + body: INDEX_BODY, + contentType: 'text/html', + statusCode: 200, + }); + }); + + it('asks the operating system for an ephemeral port when started with zero', async () => { + const captured = await startServer(); + + expect(getAddress(captured.server).port).not.toBe(8765); + }); + + it.each(Object.entries(ASSETS))( + 'serves %s with its expected MIME type', + async (assetPath, asset) => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + assetPath + ); + + expect(response).toEqual({ + body: asset.body, + contentType: asset.contentType, + statusCode: 200, + }); + } + ); + + it('falls back to the exact index document for a client route', async () => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + '/channels/favorites' + ); + + expect(response).toEqual({ + body: INDEX_BODY, + contentType: 'text/html', + statusCode: 200, + }); + }); + + it('returns a plain-text 404 when the index document is missing', async () => { + unlinkSync(join(distPath, 'index.html')); + const captured = await startServer(); + + const response = await request(getAddress(captured.server).port, '/'); + + expect(response).toEqual({ + body: Buffer.from('404 Not Found'), + contentType: 'text/plain', + statusCode: 404, + }); + }); + + it('dispatches a registered API request without serving a static file', async () => { + let receivedRequest: http.IncomingMessage | undefined; + server.registerRemoteControlHandler( + '/api/remote-control/status', + (incomingRequest, response) => { + receivedRequest = incomingRequest; + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end('{"source":"api-handler"}'); + } + ); + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + '/api/remote-control/status' + ); + + expect(receivedRequest?.url).toBe('/api/remote-control/status'); + expect(response).toEqual({ + body: Buffer.from('{"source":"api-handler"}'), + contentType: 'application/json', + statusCode: 200, + }); + expect(response.body).not.toEqual(INDEX_BODY); + }); + + it('returns a JSON 404 for an unknown remote-control API endpoint', async () => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + '/api/remote-control/missing' + ); + + expect(response).toEqual({ + body: Buffer.from('{"error":"Endpoint not found"}'), + contentType: 'application/json', + statusCode: 404, + }); + }); + + it.each([ + '/../../outside-secret.txt', + '//../../outside-secret.txt', + '/%2e%2e/%2e%2e/outside-secret.txt', + '/%E0%A4%A', + ])( + 'rejects the invalid static request target %s without serving an outside file', + async (requestTarget) => { + const captured = await startServer(); + + const response = await request( + getAddress(captured.server).port, + requestTarget + ); + + expect(response).toEqual({ + body: Buffer.from('404 Not Found'), + contentType: 'text/plain', + statusCode: 404, + }); + expect(response.body.toString()).not.toContain( + 'must-not-leave-the-static-directory' + ); + } + ); + + it('creates and listens on only one server when started twice', async () => { + server.start(0); + server.start(0); + + expect(capturedServers).toHaveLength(1); + await capturedServers[0].listening; + expect(capturedServers[0].listeningEvents).toBe(1); + }); + + it('stops the running server when disabled', async () => { + const captured = await startServer(); + const closed = once(captured.server, 'close'); + + server.updateSettings(false, 0); + await closed; + + expect(captured.server.listening).toBe(false); + expect(capturedServers).toHaveLength(1); + }); + + it('stops once and starts once on the new port when the port changes', async () => { + const first = await startServer(); + const newPort = await getAvailablePort(); + let closeEvents = 0; + first.server.on('close', () => { + closeEvents += 1; + }); + const firstClosed = once(first.server, 'close'); + + server.updateSettings(true, newPort); + const second = capturedServers[1]; + if (!second) { + throw new Error('Expected a replacement HTTP server'); + } + await Promise.all([firstClosed, second.listening]); + + expect(capturedServers).toHaveLength(2); + expect(closeEvents).toBe(1); + expect(first.server.listening).toBe(false); + expect(second.listeningEvents).toBe(1); + expect(getAddress(second.server).port).toBe(newPort); + await expect(request(newPort, '/')).resolves.toEqual({ + body: INDEX_BODY, + contentType: 'text/html', + statusCode: 200, + }); + }); +}); diff --git a/apps/electron-backend/src/app/server/http-server.ts b/apps/electron-backend/src/app/server/http-server.ts index 23a235133..a6b24ce37 100644 --- a/apps/electron-backend/src/app/server/http-server.ts +++ b/apps/electron-backend/src/app/server/http-server.ts @@ -3,10 +3,61 @@ import * as fs from 'fs'; import * as http from 'http'; import * as path from 'path'; +type HttpServerFactory = (requestListener: http.RequestListener) => http.Server; + +interface HttpServerOptions { + createServer?: HttpServerFactory; + distPath?: string; +} + +/** + * Resolve an HTTP request target within the configured static root. + * The path implementation is injectable so platform-specific semantics remain testable. + */ +export function resolveStaticFilePath( + staticRoot: string, + requestTarget: string, + pathImplementation: path.PlatformPath = path +): string | null { + const separatorIndex = requestTarget.search(/[?#]/); + const encodedPathname = + separatorIndex === -1 + ? requestTarget + : requestTarget.slice(0, separatorIndex); + + let pathname: string; + try { + pathname = decodeURIComponent(encodedPathname); + } catch { + return null; + } + + if (pathname.includes('\0')) { + return null; + } + + const relativeCandidate = pathname.replace(/^[/\\]+/, '') || 'index.html'; + const resolvedRoot = pathImplementation.resolve(staticRoot); + const candidate = pathImplementation.resolve( + resolvedRoot, + relativeCandidate + ); + + if ( + candidate === resolvedRoot || + candidate.startsWith(`${resolvedRoot}${pathImplementation.sep}`) + ) { + return candidate; + } + + return null; +} + /** * HTTP server for serving the remote control web app and providing REST API endpoints */ export class HttpServer { + private readonly createServer: HttpServerFactory; private server: http.Server | null = null; private port = 8765; private isEnabled = false; @@ -16,6 +67,11 @@ export class HttpServer { (req: http.IncomingMessage, res: http.ServerResponse) => void > = new Map(); + constructor(options: HttpServerOptions = {}) { + this.createServer = options.createServer ?? http.createServer; + this.distPath = options.distPath ?? null; + } + /** * Get the path to the remote-control-web static files. * Lazily computed to avoid calling Electron APIs before app is ready. @@ -43,11 +99,7 @@ export class HttpServer { } else { // Production mode - files are bundled with the app // electron-builder copies remote-control-web/**/* directly to app root - this.distPath = path.join( - appPath, - 'remote-control-web', - 'browser' - ); + this.distPath = path.join(appPath, 'remote-control-web', 'browser'); } console.log('[HTTP Server] Serving from:', this.distPath); @@ -58,7 +110,7 @@ export class HttpServer { * Start the HTTP server */ start(port?: number): void { - if (port) { + if (port !== undefined) { this.port = port; } @@ -67,7 +119,7 @@ export class HttpServer { return; } - this.server = http.createServer((req, res) => { + this.server = this.createServer((req, res) => { this.handleRequest(req, res); }); @@ -153,18 +205,21 @@ export class HttpServer { * Serve static files */ private serveStaticFile(url: string, res: http.ServerResponse): void { - // Default to index.html for root path - let filePath = url === '/' ? '/index.html' : url; - - // Security: prevent directory traversal - filePath = path.normalize(filePath).replace(/^(\.\.[/\\])+/, ''); - - const fullPath = path.join(this.getDistPath(), filePath); + const distPath = this.getDistPath(); + const fullPath = resolveStaticFilePath(distPath, url); + if (!fullPath) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('404 Not Found'); + return; + } fs.readFile(fullPath, (err, data) => { if (err) { // If file not found, try serving index.html (for Angular routing) - if (err.code === 'ENOENT' && filePath !== '/index.html') { + if ( + err.code === 'ENOENT' && + fullPath !== path.resolve(distPath, 'index.html') + ) { this.serveStaticFile('/', res); return; } diff --git a/apps/electron-backend/src/app/services/debug-trace.ts b/apps/electron-backend/src/app/services/debug-trace.ts index f134dc7f3..6b926a6c3 100644 --- a/apps/electron-backend/src/app/services/debug-trace.ts +++ b/apps/electron-backend/src/app/services/debug-trace.ts @@ -52,6 +52,10 @@ export function isRendererApiTraceEnabled(): boolean { return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_IPC'); } +export function isPerformanceCaptureEnabled(): boolean { + return readFlag('IPTVNATOR_PERF_CAPTURE'); +} + export function isDbTraceEnabled(): boolean { return isStartupTraceEnabled() || readFlag('IPTVNATOR_TRACE_DB'); } diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index b3b4d968a..2310d3398 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -90,8 +90,11 @@ import { } from '../database/operations/xtream.operations'; import { armWorkerPerformanceCapture, - finishWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + registerDatabaseWorkerPerformanceCapture, + releaseDatabaseWorkerPerformanceCapture, startWorkerPerformanceCapture, + type WorkerPerformanceCapture, } from './worker-performance-capture'; const loggerLabel = '[DB Worker]'; @@ -104,6 +107,11 @@ type ActiveOperationState = { cancelled: boolean; }; +type PreRegisteredActiveOperation = { + operationId: string; + state: ActiveOperationState; +}; + type OperationController = { control: { checkpoint: () => Promise; @@ -129,6 +137,7 @@ type OperationController = { }; const activeOperations = new Map(); +const activePerformanceCaptures = new Set(); if (!parentPort) { throw new Error('Database worker must be started with a parent port'); @@ -178,17 +187,22 @@ async function pauseBetweenBatches(): Promise { await new Promise((resolve) => setTimeout(resolve, batchDelayMs)); } -function createOperationController(config: { - operationId?: string; - operation: string; - playlistId?: string; - requestId: string; - cancellable?: boolean; -}): OperationController { +function createOperationController( + config: { + operationId?: string; + operation: string; + playlistId?: string; + requestId: string; + cancellable?: boolean; + }, + preRegisteredState?: ActiveOperationState +): OperationController { const { operationId, operation, playlistId, requestId } = config; const cancellable = config.cancellable ?? true; const activeState = - operationId && cancellable ? { cancelled: false } : null; + operationId && cancellable + ? (preRegisteredState ?? { cancelled: false }) + : null; if (operationId && activeState) { activeOperations.set(operationId, activeState); @@ -255,7 +269,10 @@ function createOperationController(config: { }); }, cleanup: () => { - if (operationId) { + if ( + operationId && + activeOperations.get(operationId) === activeState + ) { activeOperations.delete(operationId); } }, @@ -264,9 +281,10 @@ function createOperationController(config: { async function executeTrackedOperation( config: Parameters[0], - handler: (controller: OperationController) => Promise + handler: (controller: OperationController) => Promise, + preRegisteredState?: ActiveOperationState ): Promise { - const controller = createOperationController(config); + const controller = createOperationController(config, preRegisteredState); try { return await handler(controller); @@ -282,7 +300,53 @@ async function executeTrackedOperation( } } -async function executeRequest(message: DbWorkerRequestMessage) { +function preRegisterCancellableOperation( + message: DbWorkerRequestMessage +): PreRegisteredActiveOperation | null { + switch (message.operation) { + case 'DB_SAVE_CONTENT': + case 'DB_DELETE_PLAYLIST': + case 'DB_DELETE_XTREAM_CONTENT': + case 'DB_RESTORE_XTREAM_USER_DATA': + break; + default: + return null; + } + + if ( + typeof message.payload !== 'object' || + message.payload === null || + Array.isArray(message.payload) + ) { + return null; + } + const operationId = (message.payload as Record)[ + 'operationId' + ]; + if (typeof operationId !== 'string' || operationId.length === 0) { + return null; + } + + const state: ActiveOperationState = { cancelled: false }; + activeOperations.set(operationId, state); + return { operationId, state }; +} + +function releasePreRegisteredOperation( + operation: PreRegisteredActiveOperation | null +): void { + if ( + operation && + activeOperations.get(operation.operationId) === operation.state + ) { + activeOperations.delete(operation.operationId); + } +} + +async function executeRequest( + message: DbWorkerRequestMessage, + preRegisteredState?: ActiveOperationState +) { const db = await getWorkerDatabase(); switch (message.operation) { @@ -413,7 +477,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -586,7 +651,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -695,7 +761,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -739,7 +806,8 @@ async function executeRequest(message: DbWorkerRequestMessage) { }); return result; - } + }, + preRegisteredState ); } @@ -929,33 +997,49 @@ parentPort.on('message', async (message: DbWorkerIncomingMessage) => { return; } - const performanceCapture = startWorkerPerformanceCapture(); - await armWorkerPerformanceCapture(performanceCapture); - try { - const result = await executeRequest(message); - const performance = - await finishWorkerPerformanceCapture(performanceCapture); + const preRegisteredOperation = preRegisterCancellableOperation(message); + let performanceCapture: WorkerPerformanceCapture | null = null; + const execution = await (async () => { + try { + performanceCapture = startWorkerPerformanceCapture(); + registerDatabaseWorkerPerformanceCapture( + activePerformanceCaptures, + performanceCapture + ); + await armWorkerPerformanceCapture(performanceCapture); + return await executeWithWorkerPerformanceCapture( + performanceCapture, + () => executeRequest(message, preRegisteredOperation?.state) + ); + } finally { + releaseDatabaseWorkerPerformanceCapture( + activePerformanceCaptures, + performanceCapture + ); + releasePreRegisteredOperation(preRegisteredOperation); + } + })(); + + if (execution.success) { postMessage({ type: 'response', requestId: message.requestId, success: true, - result, - performance, + result: execution.result, + performance: execution.performance, }); - } catch (error) { + } else { console.error( loggerLabel, `Error handling ${message.operation}:`, - error + execution.error ); - const performance = - await finishWorkerPerformanceCapture(performanceCapture); postMessage({ type: 'response', requestId: message.requestId, success: false, - error: serializeError(error), - performance, + error: serializeError(execution.error), + performance: 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 790d6ac55..f6062de87 100644 --- a/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts +++ b/apps/electron-backend/src/app/workers/playlist-refresh.worker.ts @@ -24,7 +24,7 @@ import { requestWithValidatedRedirects } from '../util/validated-axios'; import { PLAYLIST_FETCH_TIMEOUT_MS } from '../events/playlist-source'; import { armWorkerPerformanceCapture, - finishWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, startWorkerPerformanceCapture, } from './worker-performance-capture'; @@ -86,6 +86,15 @@ function checkpoint(payload: PlaylistRefreshPayload): void { } } +function releaseActiveRefresh( + operationId: string, + activeRefresh: ActiveRefreshState +): void { + if (activeRefreshes.get(operationId) === activeRefresh) { + activeRefreshes.delete(operationId); + } +} + async function fetchPlaylistFromUrl( payload: PlaylistRefreshPayload, controller: AbortController @@ -159,23 +168,32 @@ async function fetchPlaylistFromFile( } async function executeRefresh( - payload: PlaylistRefreshPayload + payload: PlaylistRefreshPayload, + activeRefresh: ActiveRefreshState ): Promise { - const controller = new AbortController(); - activeRefreshes.set(payload.operationId, { - cancelled: false, - controller, - }); - try { const playlist = payload.url - ? await fetchPlaylistFromUrl(payload, controller) + ? await fetchPlaylistFromUrl(payload, activeRefresh.controller) : await fetchPlaylistFromFile(payload); checkpoint(payload); return playlist; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + emitEvent(payload, { + status: 'cancelled', + phase: 'parsing', + }); + } else { + emitEvent(payload, { + status: 'error', + phase: payload.url ? 'fetching' : 'reading-file', + error: error instanceof Error ? error.message : String(error), + }); + } + throw error; } finally { - activeRefreshes.delete(payload.operationId); + releaseActiveRefresh(payload.operationId, activeRefresh); } } @@ -191,39 +209,39 @@ parentPort.on( return; } - const performanceCapture = startWorkerPerformanceCapture(); - await armWorkerPerformanceCapture(performanceCapture); + const payload = message.payload; + const activeRefresh: ActiveRefreshState = { + cancelled: false, + controller: new AbortController(), + }; + activeRefreshes.set(payload.operationId, activeRefresh); + try { - const result = await executeRefresh(message.payload); - const performance = - await finishWorkerPerformanceCapture(performanceCapture); - postMessage({ - type: 'response', - success: true, - result, - performance, - }); - } catch (error) { - const payload = message.payload; - if (error instanceof Error && error.name === 'AbortError') { - emitEvent(payload, { status: 'cancelled', phase: 'parsing' }); + const performanceCapture = startWorkerPerformanceCapture(); + await armWorkerPerformanceCapture(performanceCapture); + const execution = await executeWithWorkerPerformanceCapture( + performanceCapture, + () => executeRefresh(payload, activeRefresh) + ); + + if (execution.success) { + postMessage({ + type: 'response', + success: true, + result: execution.result, + performance: execution.performance, + }); } else { - emitEvent(payload, { - status: 'error', - phase: payload.url ? 'fetching' : 'reading-file', - error: - error instanceof Error ? error.message : String(error), + const error = execution.error; + postMessage({ + type: 'response', + success: false, + error: serializeError(error), + performance: execution.performance, }); } - - const performance = - await finishWorkerPerformanceCapture(performanceCapture); - postMessage({ - type: 'response', - success: false, - error: serializeError(error), - performance, - }); + } finally { + releaseActiveRefresh(payload.operationId, activeRefresh); } } ); 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 new file mode 100644 index 000000000..4cdd550b7 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-cancellation.spec.ts @@ -0,0 +1,313 @@ +import type { + DbWorkerIncomingMessage, + DbWorkerMessage, +} from './database-worker.types'; +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(), + startWorkerPerformanceCapture: jest.fn(() => ({})), + })); +} + +describe('worker cancellation while performance capture arms', () => { + afterEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + }); + + it('cancels playlist work before file access when cancel arrives during arming', async () => { + const armGate = createArmGate(); + const profilingFinished = createArmGate(); + const failureObserved = createArmGate(); + const port = createParentPortHarness< + PlaylistRefreshWorkerIncomingMessage, + PlaylistRefreshWorkerMessage + >(); + const readFile = jest.fn().mockResolvedValue('#EXTM3U'); + mockPerformanceCapture(armGate, { + observed: failureObserved, + profilingFinished, + }); + jest.doMock('worker_threads', () => ({ + parentPort: port.parentPort, + })); + jest.doMock('node:fs/promises', () => ({ readFile })); + jest.doMock('iptv-playlist-parser', () => ({ + parse: jest.fn(() => ({ items: [] })), + })); + jest.doMock('@iptvnator/shared/m3u-utils', () => ({ + createPlaylistObject: jest.fn(() => ({ id: 'playlist-1' })), + getFilenameFromUrl: jest.fn(() => 'playlist.m3u'), + })); + + await import('./playlist-refresh.worker'); + port.postMessage.mockClear(); + const handleMessage = port.getMessageHandler(); + const requestPromise = handleMessage({ + type: 'request', + payload: { + filePath: '/fixtures/playlist.m3u', + operationId: 'refresh-1', + playlistId: 'playlist-1', + title: 'Fixture', + }, + }); + + await handleMessage({ + type: 'cancel', + operationId: 'refresh-1', + }); + armGate.release(); + await failureObserved.promise; + + const cancelledBeforeProfilingFinished = + port.postMessage.mock.calls.some( + ([message]) => + message.type === 'event' && + message.event.status === 'cancelled' + ); + const responseBeforeProfilingFinished = + port.postMessage.mock.calls.some( + ([message]) => message.type === 'response' + ); + + profilingFinished.release(); + await requestPromise; + + expect(readFile).not.toHaveBeenCalled(); + expect(cancelledBeforeProfilingFinished).toBe(true); + expect(responseBeforeProfilingFinished).toBe(false); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + operationId: 'refresh-1', + status: 'cancelled', + }), + type: 'event', + }) + ); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ name: 'AbortError' }), + success: false, + type: 'response', + }) + ); + }); + + it('cancels cancellable database work when cancel arrives during arming', async () => { + 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); + 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-1', + playlistId: 'playlist-1', + streams: [], + type: 'movie', + }, + requestId: 'request-1', + }); + + await handleMessage({ + type: 'cancel', + operationId: 'database-operation-1', + }); + armGate.release(); + await requestPromise; + + expect(saveContent).toHaveBeenCalledTimes(1); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + operationId: 'database-operation-1', + status: 'cancelled', + }), + requestId: 'request-1', + type: 'event', + }) + ); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ name: 'AbortError' }), + requestId: 'request-1', + success: false, + type: 'response', + }) + ); + }); + + it('does not make an explicitly non-cancellable database operation cancellable during arming', async () => { + const armGate = createArmGate(); + const port = createParentPortHarness< + DbWorkerIncomingMessage, + DbWorkerMessage + >(); + const deleteAllPlaylists = jest.fn( + async ( + _db: unknown, + control: { checkpoint: () => Promise } + ) => { + await control.checkpoint(); + return { success: true }; + } + ); + mockPerformanceCapture(armGate); + jest.doMock('worker_threads', () => ({ + parentPort: port.parentPort, + })); + jest.doMock('./database.worker-connection', () => ({ + closeWorkerDatabase: jest.fn(), + getWorkerDatabase: jest.fn().mockResolvedValue({}), + })); + jest.doMock('../database/operations/playlist.operations', () => ({ + deleteAllPlaylists, + })); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + + await import('./database.worker'); + port.postMessage.mockClear(); + const handleMessage = port.getMessageHandler(); + const requestPromise = handleMessage({ + type: 'request', + operation: 'DB_DELETE_ALL_PLAYLISTS', + payload: { + operationId: 'database-operation-2', + }, + requestId: 'request-2', + }); + + await handleMessage({ + type: 'cancel', + operationId: 'database-operation-2', + }); + armGate.release(); + await requestPromise; + + expect(deleteAllPlaylists).toHaveBeenCalledTimes(1); + expect(port.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: 'request-2', + result: { success: true }, + success: true, + type: 'response', + }) + ); + expect(port.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ status: 'cancelled' }), + requestId: 'request-2', + type: 'event', + }) + ); + }); +}); 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 new file mode 100644 index 000000000..900f4ccde --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.concurrency.spec.ts @@ -0,0 +1,95 @@ +import { + WORKER_PERFORMANCE_INVALID_REASON, + armWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + registerDatabaseWorkerPerformanceCapture, + releaseDatabaseWorkerPerformanceCapture, + startWorkerPerformanceCapture, + type WorkerPerformanceCapture, +} from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; + +describe('worker performance capture concurrency and real timers', () => { + const originalProfilingValue = process.env[PROFILING_ENV]; + + afterEach(() => { + if (originalProfilingValue === undefined) { + delete process.env[PROFILING_ENV]; + } else { + process.env[PROFILING_ENV] = originalProfilingValue; + } + }); + + it('fails every overlapping database request capture closed without serializing execution', async () => { + const activeCaptures = new Set(); + const firstHarness = createFakeRuntime(); + const secondHarness = createFakeRuntime(); + const first = startWorkerPerformanceCapture({ + enabled: true, + runtime: firstHarness.runtime, + }); + const second = startWorkerPerformanceCapture({ + enabled: true, + runtime: secondHarness.runtime, + }); + registerDatabaseWorkerPerformanceCapture(activeCaptures, first); + registerDatabaseWorkerPerformanceCapture(activeCaptures, second); + await Promise.all([ + armWorkerPerformanceCapture(first), + armWorkerPerformanceCapture(second), + ]); + + const [firstExecution, secondExecution] = await Promise.all([ + executeWithWorkerPerformanceCapture(first, async () => 'first'), + executeWithWorkerPerformanceCapture(second, async () => 'second'), + ]); + releaseDatabaseWorkerPerformanceCapture(activeCaptures, first); + releaseDatabaseWorkerPerformanceCapture(activeCaptures, second); + + for (const execution of [firstExecution, secondExecution]) { + expect(execution.performance).toMatchObject({ + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + histogramFlushedEpochMs: null, + invalidReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS, + threadCpuUserMicros: null, + }); + } + expect(firstExecution.result).toBe('first'); + expect(secondExecution.result).toBe('second'); + expect(activeCaptures.size).toBe(0); + }); + + it('reliably observes a real 20ms event-loop block after 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. + } + } + ); + + expect(execution.success).toBe(true); + expect(execution.performance?.eventLoopDelay).not.toBeNull(); + expect(execution.performance?.eventLoopDelay?.maxMs).toBeGreaterThan( + 10 + ); + expect(execution.performance?.histogramFlushedEpochMs).not.toBeNull(); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.spec.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.spec.ts new file mode 100644 index 000000000..19462fe5a --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.spec.ts @@ -0,0 +1,39 @@ +import { + armWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +describe('worker performance histogram lifecycle', () => { + it('accepts a nonthrowing false disable result because the histogram is already disabled', async () => { + const harness = createFakeRuntime({ + histogramDisableResult: false, + }); + 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, + }, + eventLoopDelayUnavailableReason: null, + histogramFlushedEpochMs: 145, + }, + }); + }); +}); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts new file mode 100644 index 000000000..a4951d6cc --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.histogram.ts @@ -0,0 +1,136 @@ +import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + type WorkerEventLoopDelayHistogram, + type WorkerEventLoopDelayMetrics, + type WorkerPerformanceCapture, + type WorkerPerformanceMetricUnavailableReason, +} from './worker-performance-capture.model'; +import { + HISTOGRAM_POLL_INTERVAL_MS, + HISTOGRAM_WAIT_CAP_MS, +} from './worker-performance-capture.runtime'; + +export function disableWorkerPerformanceHistogram( + capture: WorkerPerformanceCapture +): boolean { + if (!capture.eventLoopDelay || capture.histogramDisabled) { + return true; + } + + try { + capture.eventLoopDelay.disable(); + capture.histogramDisabled = true; + return true; + } catch { + // Performance instrumentation must never affect worker execution. + return false; + } +} + +export function markEventLoopDelayUnavailable( + capture: WorkerPerformanceCapture, + reason: WorkerPerformanceMetricUnavailableReason +): void { + capture.eventLoopDelayUnavailableReason ??= reason; + disableWorkerPerformanceHistogram(capture); +} + +export async function waitForHistogramCondition( + capture: WorkerPerformanceCapture, + condition: (histogram: WorkerEventLoopDelayHistogram) => boolean +): Promise { + const histogram = capture.eventLoopDelay; + if ( + !histogram || + capture.histogramDisabled || + capture.invalidReason !== null + ) { + return false; + } + + let startedAt: number; + try { + startedAt = capture.runtime.readMonotonicMs(); + } catch { + return false; + } + + const maximumPolls = Math.ceil( + HISTOGRAM_WAIT_CAP_MS / HISTOGRAM_POLL_INTERVAL_MS + ); + let pollCount = 0; + + while (true) { + if (capture.invalidReason !== null || capture.histogramDisabled) { + return false; + } + try { + if (condition(histogram)) { + return true; + } + } catch { + return false; + } + + let elapsedMs: number; + try { + elapsedMs = capture.runtime.readMonotonicMs() - startedAt; + } catch { + return false; + } + if ( + !Number.isFinite(elapsedMs) || + elapsedMs >= HISTOGRAM_WAIT_CAP_MS || + pollCount >= maximumPolls + ) { + return false; + } + + const delayMs = Math.min( + HISTOGRAM_POLL_INTERVAL_MS, + HISTOGRAM_WAIT_CAP_MS - Math.max(0, elapsedMs) + ); + try { + pollCount += 1; + await new Promise((resolvePromise) => { + capture.runtime.scheduleTimeout(resolvePromise, delayMs); + }); + } catch { + return false; + } + } +} + +export function readWorkerEventLoopDelay( + capture: WorkerPerformanceCapture +): WorkerEventLoopDelayMetrics | null { + if ( + capture.invalidReason !== null || + capture.eventLoopDelayUnavailableReason !== null || + capture.histogramFlushedEpochMs === null || + !capture.eventLoopDelay + ) { + return null; + } + + try { + const metrics: WorkerEventLoopDelayMetrics = { + maxMs: capture.eventLoopDelay.max / 1e6, + p95Ms: capture.eventLoopDelay.percentile(95) / 1e6, + p99Ms: capture.eventLoopDelay.percentile(99) / 1e6, + }; + if ( + Object.values(metrics).every( + (value) => Number.isFinite(value) && value >= 0 + ) + ) { + return metrics; + } + } catch { + // The fixed reason below is intentionally payload-free. + } + + capture.eventLoopDelayUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_INVALID; + return null; +} 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 new file mode 100644 index 000000000..4d1fb3a1f --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.metrics.ts @@ -0,0 +1,235 @@ +import type { EventLoopUtilization } from 'node:perf_hooks'; +import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + type WorkerPerformanceCapture, + type WorkerPerformanceCaptureResult, +} from './worker-performance-capture.model'; +import { readWorkerEventLoopDelay } from './worker-performance-capture.histogram'; +import { safeReadEpochMs } from './worker-performance-capture.runtime'; + +function readThreadCpuBoundary( + capture: WorkerPerformanceCapture +): NodeJS.CpuUsage | null { + if (!capture.runtime.readThreadCpuUsage) { + capture.threadCpuUnavailableReason ??= + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE; + return null; + } + + try { + const usage = capture.runtime.readThreadCpuUsage(); + if ( + !Number.isFinite(usage.system) || + !Number.isFinite(usage.user) || + usage.system < 0 || + usage.user < 0 + ) { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_INVALID; + return null; + } + return usage; + } catch { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE; + return null; + } +} + +function readEventLoopUtilizationBoundary( + capture: WorkerPerformanceCapture +): EventLoopUtilization | null { + try { + const utilization = capture.runtime.readEventLoopUtilization(); + if ( + !Number.isFinite(utilization.active) || + !Number.isFinite(utilization.idle) + ) { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } + return utilization; + } catch { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } +} + +export function beginWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null +): void { + if (!capture) { + return; + } + + capture.workStartedEpochMs = safeReadEpochMs(capture.runtime); + capture.threadCpuStart = readThreadCpuBoundary(capture); + capture.eventLoopUtilizationStart = + readEventLoopUtilizationBoundary(capture); +} + +function calculateThreadCpu( + capture: WorkerPerformanceCapture +): Pick< + WorkerPerformanceCaptureResult, + 'threadCpuSystemMicros' | 'threadCpuUserMicros' +> { + if ( + capture.invalidReason !== null || + capture.threadCpuUnavailableReason !== null || + !capture.threadCpuStart || + !capture.threadCpuEnd + ) { + return { + threadCpuSystemMicros: null, + threadCpuUserMicros: null, + }; + } + + try { + const system = + capture.threadCpuEnd.system - capture.threadCpuStart.system; + const user = capture.threadCpuEnd.user - capture.threadCpuStart.user; + if ( + !Number.isFinite(system) || + !Number.isFinite(user) || + system < 0 || + user < 0 + ) { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_INVALID; + return { + threadCpuSystemMicros: null, + threadCpuUserMicros: null, + }; + } + + return { + threadCpuSystemMicros: system, + threadCpuUserMicros: user, + }; + } catch { + capture.threadCpuUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_INVALID; + return { + threadCpuSystemMicros: null, + threadCpuUserMicros: null, + }; + } +} + +function calculateEventLoopUtilization( + capture: WorkerPerformanceCapture +): number | null { + if ( + capture.invalidReason !== null || + capture.eventLoopUtilizationUnavailableReason !== null || + !capture.eventLoopUtilizationStart || + !capture.eventLoopUtilizationEnd + ) { + return null; + } + + try { + const active = + capture.eventLoopUtilizationEnd.active - + capture.eventLoopUtilizationStart.active; + const idle = + capture.eventLoopUtilizationEnd.idle - + capture.eventLoopUtilizationStart.idle; + const total = active + idle; + if ( + !Number.isFinite(active) || + !Number.isFinite(idle) || + active < 0 || + idle < 0 || + total < 0 + ) { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } + + return total === 0 ? 0 : active / total; + } catch { + capture.eventLoopUtilizationUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE; + return null; + } +} + +function createFallbackResult( + capture: WorkerPerformanceCapture +): WorkerPerformanceCaptureResult { + const invalidReason = capture.invalidReason; + return { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + invalidReason ?? + capture.eventLoopDelayUnavailableReason ?? + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + eventLoopUtilization: null, + eventLoopUtilizationUnavailableReason: + invalidReason ?? + capture.eventLoopUtilizationUnavailableReason ?? + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_UTILIZATION_UNAVAILABLE, + histogramFlushedEpochMs: null, + invalidReason, + requestReceivedEpochMs: capture.requestReceivedEpochMs, + threadCpuSystemMicros: null, + threadCpuUnavailableReason: + invalidReason ?? + capture.threadCpuUnavailableReason ?? + WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE, + threadCpuUserMicros: null, + workEndedEpochMs: + capture.workEndedEpochMs ?? capture.requestReceivedEpochMs, + workStartedEpochMs: + capture.workStartedEpochMs ?? capture.requestReceivedEpochMs, + }; +} + +export function createWorkerPerformanceCaptureResult( + capture: WorkerPerformanceCapture +): WorkerPerformanceCaptureResult { + try { + const overlapReason = capture.invalidReason; + if (overlapReason) { + capture.eventLoopDelayUnavailableReason = overlapReason; + capture.eventLoopUtilizationUnavailableReason = overlapReason; + capture.threadCpuUnavailableReason = overlapReason; + capture.histogramFlushedEpochMs = null; + } + const threadCpu = calculateThreadCpu(capture); + + return { + eventLoopDelay: readWorkerEventLoopDelay(capture), + eventLoopDelayUnavailableReason: + capture.eventLoopDelayUnavailableReason, + eventLoopUtilization: calculateEventLoopUtilization(capture), + eventLoopUtilizationUnavailableReason: + capture.eventLoopUtilizationUnavailableReason, + histogramFlushedEpochMs: capture.histogramFlushedEpochMs, + invalidReason: capture.invalidReason, + requestReceivedEpochMs: capture.requestReceivedEpochMs, + ...threadCpu, + threadCpuUnavailableReason: capture.threadCpuUnavailableReason, + workEndedEpochMs: + capture.workEndedEpochMs ?? capture.requestReceivedEpochMs, + workStartedEpochMs: + capture.workStartedEpochMs ?? capture.requestReceivedEpochMs, + }; + } catch { + return createFallbackResult(capture); + } +} + +export function finishWorkerPerformanceMetricBoundaries( + capture: WorkerPerformanceCapture +): void { + capture.workEndedEpochMs = safeReadEpochMs(capture.runtime); + capture.threadCpuEnd = readThreadCpuBoundary(capture); + capture.eventLoopUtilizationEnd = readEventLoopUtilizationBoundary(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 new file mode 100644 index 000000000..f86576eda --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.model.ts @@ -0,0 +1,94 @@ +import type { EventLoopUtilization } from 'node:perf_hooks'; + +export const WORKER_PERFORMANCE_INVALID_REASON = { + OVERLAPPING_DATABASE_WORKER_REQUESTS: + 'overlapping-database-worker-requests', +} as const; + +export type WorkerPerformanceInvalidReason = + (typeof WORKER_PERFORMANCE_INVALID_REASON)[keyof typeof WORKER_PERFORMANCE_INVALID_REASON]; + +export const WORKER_PERFORMANCE_UNAVAILABLE_REASON = { + EVENT_LOOP_DELAY_ARM_TIMEOUT: 'event-loop-delay-arm-timeout', + EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE: + 'event-loop-delay-capture-unavailable', + EVENT_LOOP_DELAY_FLUSH_TIMEOUT: 'event-loop-delay-flush-timeout', + EVENT_LOOP_DELAY_INVALID: 'event-loop-delay-invalid', + EVENT_LOOP_UTILIZATION_UNAVAILABLE: 'event-loop-utilization-unavailable', + THREAD_CPU_USAGE_INVALID: 'thread-cpu-usage-invalid', + THREAD_CPU_USAGE_UNAVAILABLE: 'thread-cpu-usage-unavailable', +} as const; + +export type WorkerPerformanceUnavailableReason = + (typeof WORKER_PERFORMANCE_UNAVAILABLE_REASON)[keyof typeof WORKER_PERFORMANCE_UNAVAILABLE_REASON]; + +export type WorkerPerformanceMetricUnavailableReason = + WorkerPerformanceInvalidReason | WorkerPerformanceUnavailableReason; + +export interface WorkerEventLoopDelayMetrics { + maxMs: number; + p95Ms: number; + p99Ms: number; +} + +export interface WorkerEventLoopDelayHistogram { + readonly count: number; + readonly max: number; + disable: () => boolean; + enable: () => boolean; + percentile: (percentile: number) => number; +} + +export interface WorkerPerformanceCaptureResult { + eventLoopDelay: WorkerEventLoopDelayMetrics | null; + eventLoopDelayUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + eventLoopUtilization: number | null; + eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + histogramFlushedEpochMs: number | null; + invalidReason: WorkerPerformanceInvalidReason | null; + requestReceivedEpochMs: number; + threadCpuSystemMicros: number | null; + threadCpuUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + threadCpuUserMicros: number | null; + workEndedEpochMs: number; + workStartedEpochMs: number; +} + +export interface WorkerPerformanceCaptureRuntime { + createEventLoopDelayHistogram: () => WorkerEventLoopDelayHistogram; + readEpochMs: () => number; + readEventLoopUtilization: () => EventLoopUtilization; + readMonotonicMs: () => number; + readThreadCpuUsage: (() => NodeJS.CpuUsage) | null; + scheduleTimeout: (callback: () => void, delayMs: number) => void; +} + +export interface WorkerPerformanceCaptureOptions { + enabled?: boolean; + runtime?: WorkerPerformanceCaptureRuntime; +} + +export interface WorkerPerformanceExecutionResult { + error: unknown | null; + performance: WorkerPerformanceCaptureResult | undefined; + result: TResult | undefined; + success: boolean; +} + +export interface WorkerPerformanceCapture { + eventLoopDelay: WorkerEventLoopDelayHistogram | null; + eventLoopDelayUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + eventLoopUtilizationEnd: EventLoopUtilization | null; + eventLoopUtilizationStart: EventLoopUtilization | null; + eventLoopUtilizationUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + histogramDisabled: boolean; + histogramFlushedEpochMs: number | null; + invalidReason: WorkerPerformanceInvalidReason | null; + requestReceivedEpochMs: number; + runtime: WorkerPerformanceCaptureRuntime; + threadCpuEnd: NodeJS.CpuUsage | null; + threadCpuStart: NodeJS.CpuUsage | null; + threadCpuUnavailableReason: WorkerPerformanceMetricUnavailableReason | null; + workEndedEpochMs: number | null; + workStartedEpochMs: number | null; +} diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.runtime.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.runtime.ts new file mode 100644 index 000000000..15c0a4308 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.runtime.ts @@ -0,0 +1,45 @@ +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; +import type { + WorkerPerformanceCaptureRuntime, + WorkerEventLoopDelayHistogram, +} from './worker-performance-capture.model'; + +export const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; +export const HISTOGRAM_POLL_INTERVAL_MS = 1; +export const HISTOGRAM_WAIT_CAP_MS = 50; + +function readRuntimeThreadCpuUsage(): (() => NodeJS.CpuUsage) | null { + const candidate: unknown = Reflect.get(process, 'threadCpuUsage'); + if (typeof candidate !== 'function') { + return null; + } + + return () => + (candidate as (this: NodeJS.Process) => NodeJS.CpuUsage).call(process); +} + +export const DEFAULT_WORKER_PERFORMANCE_RUNTIME: WorkerPerformanceCaptureRuntime = + { + createEventLoopDelayHistogram: (): WorkerEventLoopDelayHistogram => + monitorEventLoopDelay({ + resolution: HISTOGRAM_POLL_INTERVAL_MS, + }), + readEpochMs: () => performance.timeOrigin + performance.now(), + readEventLoopUtilization: () => performance.eventLoopUtilization(), + readMonotonicMs: () => performance.now(), + readThreadCpuUsage: readRuntimeThreadCpuUsage(), + scheduleTimeout: (callback, delayMs) => { + setTimeout(callback, delayMs); + }, + }; + +export function safeReadEpochMs( + runtime: WorkerPerformanceCaptureRuntime +): number { + try { + const epochMs = runtime.readEpochMs(); + return Number.isFinite(epochMs) ? epochMs : Date.now(); + } catch { + return Date.now(); + } +} 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 2801935c3..7bebd4fe9 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 @@ -1,8 +1,10 @@ import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, armWorkerPerformanceCapture, - finishWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, startWorkerPerformanceCapture, } from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; const PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; @@ -23,26 +25,355 @@ describe('worker performance capture', () => { expect(startWorkerPerformanceCapture()).toBeNull(); }); - it('reports finite worker event-loop metrics when opted in', async () => { - process.env[PROFILING_ENV] = '1'; - - const capture = startWorkerPerformanceCapture(); - await armWorkerPerformanceCapture(capture); - const blockStartedAt = performance.now(); - while (performance.now() - blockStartedAt < 20) { - // Deliberately block the loop so the histogram contract is tested. - } - const result = await finishWorkerPerformanceCapture(capture); - - expect(result).toEqual({ - eventLoopDelay: { - maxMs: expect.any(Number), - p95Ms: expect.any(Number), - p99Ms: expect.any(Number), - }, - eventLoopUtilization: expect.any(Number), + it('captures exact work-boundary CPU, ELU, timestamps, and a flushed fresh histogram', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => { + harness.lifecycle.push('execute'); + await Promise.resolve(); + harness.lifecycle.push('execute:settled'); + return 'result'; + } + ); + + expect(execution).toEqual({ + error: null, + performance: { + eventLoopDelay: { + maxMs: 24, + p95Ms: 18, + p99Ms: 22, + }, + eventLoopDelayUnavailableReason: null, + eventLoopUtilization: 0.75, + eventLoopUtilizationUnavailableReason: null, + histogramFlushedEpochMs: 145, + invalidReason: null, + requestReceivedEpochMs: 100, + threadCpuSystemMicros: 30, + threadCpuUnavailableReason: null, + threadCpuUserMicros: 80, + workEndedEpochMs: 140, + workStartedEpochMs: 110, + }, + result: 'result', + success: true, + }); + expect(harness.lifecycle).toEqual([ + 'epoch:100', + 'histogram:create', + 'timeout:1', + 'epoch:110', + 'cpu:1', + 'elu:1', + 'execute', + 'execute:settled', + 'epoch:140', + 'cpu:2', + 'elu:2', + 'timeout:1', + 'epoch:145', + ]); + expect(harness.histogramLifecycle.enableCalls).toBe(1); + expect(harness.histogramLifecycle.disableCalls).toBe(1); + expect('reset' in harness.histogram).toBe(false); + }); + + it('finishes profiling before returning a rejected execution to worker side effects', async () => { + const harness = createFakeRuntime(); + const capture = startWorkerPerformanceCapture({ + enabled: true, + runtime: harness.runtime, + }); + const failure = new Error('worker failed'); + await armWorkerPerformanceCapture(capture); + + const execution = await executeWithWorkerPerformanceCapture( + capture, + async () => { + harness.lifecycle.push('execute'); + await Promise.resolve(); + harness.lifecycle.push('execute:rejected'); + throw failure; + } + ); + harness.lifecycle.push('worker:emit-error'); + + expect(execution).toMatchObject({ + error: failure, + success: false, + }); + expect(harness.lifecycle.indexOf('epoch:140')).toBe( + harness.lifecycle.indexOf('execute:rejected') + 1 + ); + expect(harness.lifecycle.indexOf('worker:emit-error')).toBeGreaterThan( + 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, + }, }); - expect(result?.eventLoopUtilization).toBeGreaterThanOrEqual(0); - expect(result?.eventLoopDelay.maxMs).toBeGreaterThan(10); }); }); diff --git a/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts b/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts new file mode 100644 index 000000000..54517e637 --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.test-harness.ts @@ -0,0 +1,158 @@ +import type { + WorkerEventLoopDelayHistogram, + WorkerPerformanceCaptureRuntime, +} from './worker-performance-capture'; + +interface FakeRuntimeOptions { + armHistogram?: boolean; + flushHistogram?: boolean; + histogramDisableResult?: boolean; + stallMonotonicClock?: boolean; + threadCpuAvailable?: boolean; + throwBoundaryCallbacks?: boolean; + throwHistogramDisable?: boolean; + throwHistogramCountAtRead?: number; + throwHistogramMetric?: boolean; + throwScheduleTimeout?: boolean; +} + +export interface FakeRuntimeHarness { + histogram: WorkerEventLoopDelayHistogram; + histogramLifecycle: { + disableCalls: number; + enableCalls: number; + }; + lifecycle: string[]; + runtime: WorkerPerformanceCaptureRuntime; + scheduledTimeouts: number[]; +} + +export function createFakeRuntime( + options: FakeRuntimeOptions = {} +): FakeRuntimeHarness { + const lifecycle: string[] = []; + const scheduledTimeouts: number[] = []; + let epochIndex = 0; + let monotonicMs = 0; + let timeoutCount = 0; + let cpuIndex = 0; + let eluIndex = 0; + let histogramCount = 0; + let histogramCountReads = 0; + const histogramLifecycle = { + disableCalls: 0, + enableCalls: 0, + }; + const epochValues = [100, 110, 140, 145]; + const cpuValues = [ + { system: 50, user: 100 }, + { system: 80, user: 180 }, + ]; + const eluValues = [ + { active: 10, idle: 20, utilization: 1 / 3 }, + { active: 40, idle: 30, utilization: 4 / 7 }, + ]; + const histogram = { + get count() { + histogramCountReads += 1; + if (histogramCountReads === options.throwHistogramCountAtRead) { + throw new Error('histogram count unavailable'); + } + return histogramCount; + }, + disable: () => { + histogramLifecycle.disableCalls += 1; + if (options.throwHistogramDisable === true) { + throw new Error('histogram disable unavailable'); + } + return options.histogramDisableResult ?? true; + }, + enable: () => { + histogramLifecycle.enableCalls += 1; + return true; + }, + get max() { + if (options.throwHistogramMetric === true) { + throw new Error('histogram max unavailable'); + } + return 24_000_000; + }, + percentile: (percentile: number) => { + if (options.throwHistogramMetric === true) { + throw new Error('histogram percentile unavailable'); + } + if (percentile === 95) { + return 18_000_000; + } + if (percentile === 99) { + return 22_000_000; + } + return 0; + }, + } satisfies WorkerEventLoopDelayHistogram; + const runtime: WorkerPerformanceCaptureRuntime = { + createEventLoopDelayHistogram: () => { + lifecycle.push('histogram:create'); + return histogram; + }, + readEpochMs: () => { + const value = epochValues[epochIndex] ?? 145 + epochIndex; + epochIndex += 1; + lifecycle.push(`epoch:${value}`); + return value; + }, + readEventLoopUtilization: () => { + if (options.throwBoundaryCallbacks === true) { + throw new Error('ELU callback unavailable'); + } + const value = eluValues[eluIndex] ?? eluValues.at(-1)!; + eluIndex += 1; + lifecycle.push(`elu:${eluIndex}`); + return value; + }, + readMonotonicMs: () => monotonicMs, + readThreadCpuUsage: + options.threadCpuAvailable === false + ? null + : () => { + if (options.throwBoundaryCallbacks === true) { + throw new Error('thread CPU callback unavailable'); + } + const value = cpuValues[cpuIndex] ?? cpuValues.at(-1)!; + cpuIndex += 1; + lifecycle.push(`cpu:${cpuIndex}`); + return value; + }, + scheduleTimeout: (callback, delayMs) => { + lifecycle.push(`timeout:${delayMs}`); + scheduledTimeouts.push(delayMs); + if (options.throwScheduleTimeout === true) { + throw new Error('timer callback unavailable'); + } + if (options.stallMonotonicClock !== true) { + monotonicMs += delayMs; + } else if (timeoutCount >= 55) { + throw new Error('test scheduler fail-safe'); + } + timeoutCount += 1; + if (timeoutCount === 1 && options.armHistogram !== false) { + histogramCount += 1; + } else if ( + timeoutCount > 1 && + options.armHistogram !== false && + options.flushHistogram !== false + ) { + histogramCount += 1; + } + callback(); + }, + }; + + return { + histogram, + histogramLifecycle, + lifecycle, + runtime, + scheduledTimeouts, + }; +} 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 db86f234d..a29d97f0a 100644 --- a/apps/electron-backend/src/app/workers/worker-performance-capture.ts +++ b/apps/electron-backend/src/app/workers/worker-performance-capture.ts @@ -1,47 +1,119 @@ import { - monitorEventLoopDelay, - performance, - type IntervalHistogram, -} from 'node:perf_hooks'; + WORKER_PERFORMANCE_INVALID_REASON, + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + type WorkerPerformanceCapture, + type WorkerPerformanceCaptureOptions, + type WorkerPerformanceCaptureResult, + type WorkerPerformanceExecutionResult, +} from './worker-performance-capture.model'; +import { + disableWorkerPerformanceHistogram, + markEventLoopDelayUnavailable, + waitForHistogramCondition, +} from './worker-performance-capture.histogram'; +import { + beginWorkerPerformanceCapture, + createWorkerPerformanceCaptureResult, + finishWorkerPerformanceMetricBoundaries, +} from './worker-performance-capture.metrics'; +import { + DEFAULT_WORKER_PERFORMANCE_RUNTIME, + safeReadEpochMs, + WORKER_PROFILING_ENV, +} from './worker-performance-capture.runtime'; -const WORKER_PROFILING_ENV = 'IPTVNATOR_PERF_WORKER_PROFILING'; +export { + WORKER_PERFORMANCE_INVALID_REASON, + WORKER_PERFORMANCE_UNAVAILABLE_REASON, +} from './worker-performance-capture.model'; +export type { + WorkerEventLoopDelayHistogram, + WorkerEventLoopDelayMetrics, + WorkerPerformanceCapture, + WorkerPerformanceCaptureOptions, + WorkerPerformanceCaptureResult, + WorkerPerformanceCaptureRuntime, + WorkerPerformanceExecutionResult, + WorkerPerformanceInvalidReason, + WorkerPerformanceUnavailableReason, +} from './worker-performance-capture.model'; -export interface WorkerPerformanceCaptureResult { - eventLoopDelay: { - maxMs: number; - p95Ms: number; - p99Ms: number; - }; - eventLoopUtilization: number; -} - -interface WorkerPerformanceCapture { - eventLoopDelay: IntervalHistogram; - eventLoopUtilizationStart: ReturnType< - typeof performance.eventLoopUtilization - >; -} - -export function startWorkerPerformanceCapture(): WorkerPerformanceCapture | null { - if (process.env[WORKER_PROFILING_ENV] !== '1') { +export function startWorkerPerformanceCapture( + options: WorkerPerformanceCaptureOptions = {} +): WorkerPerformanceCapture | null { + const enabled = + options.enabled ?? process.env[WORKER_PROFILING_ENV] === '1'; + if (!enabled) { return null; } - const eventLoopDelay = monitorEventLoopDelay({ resolution: 1 }); - eventLoopDelay.enable(); - return { - eventLoopDelay, - eventLoopUtilizationStart: performance.eventLoopUtilization(), + const runtime = options.runtime ?? DEFAULT_WORKER_PERFORMANCE_RUNTIME; + const capture: WorkerPerformanceCapture = { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: null, + eventLoopUtilizationEnd: null, + eventLoopUtilizationStart: null, + eventLoopUtilizationUnavailableReason: null, + histogramDisabled: false, + histogramFlushedEpochMs: null, + invalidReason: null, + requestReceivedEpochMs: safeReadEpochMs(runtime), + runtime, + threadCpuEnd: null, + threadCpuStart: null, + threadCpuUnavailableReason: + runtime.readThreadCpuUsage === null + ? WORKER_PERFORMANCE_UNAVAILABLE_REASON.THREAD_CPU_USAGE_UNAVAILABLE + : null, + workEndedEpochMs: null, + workStartedEpochMs: null, }; + + try { + capture.eventLoopDelay = runtime.createEventLoopDelayHistogram(); + capture.eventLoopDelay.enable(); + } catch { + capture.eventLoopDelay = null; + capture.eventLoopDelayUnavailableReason = + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE; + } + + return capture; } export async function armWorkerPerformanceCapture( capture: WorkerPerformanceCapture | null ): Promise { - if (capture) { - await new Promise((resolvePromise) => - setTimeout(resolvePromise, 2) + if (!capture) { + return; + } + + try { + if ( + capture.invalidReason !== null || + capture.eventLoopDelayUnavailableReason !== null + ) { + return; + } + const armed = await waitForHistogramCondition( + capture, + (histogram) => histogram.count > 0 ); + if (!armed && capture.invalidReason === null) { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_ARM_TIMEOUT + ); + } + } catch { + try { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + } catch { + // Instrumentation must never prevent business execution. + } } } @@ -52,17 +124,148 @@ export async function finishWorkerPerformanceCapture( return undefined; } - await new Promise((resolvePromise) => setTimeout(resolvePromise, 2)); - capture.eventLoopDelay.disable(); - const eventLoopUtilization = performance.eventLoopUtilization( - capture.eventLoopUtilizationStart + finishWorkerPerformanceMetricBoundaries(capture); + + if ( + capture.invalidReason === null && + capture.eventLoopDelayUnavailableReason === null && + capture.eventLoopDelay + ) { + try { + const workEndedHistogramCount = capture.eventLoopDelay.count; + const flushed = await waitForHistogramCondition( + capture, + (histogram) => histogram.count > workEndedHistogramCount + ); + if (flushed) { + const disabled = disableWorkerPerformanceHistogram(capture); + if (disabled) { + capture.histogramFlushedEpochMs = safeReadEpochMs( + capture.runtime + ); + } else { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + } + } else if ( + capture.invalidReason === null && + capture.eventLoopDelayUnavailableReason === null + ) { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_FLUSH_TIMEOUT + ); + } + } catch { + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + } + } else { + disableWorkerPerformanceHistogram(capture); + } + + return createWorkerPerformanceCaptureResult(capture); +} + +export async function executeWithWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null, + execute: () => Promise, + finish: ( + capture: WorkerPerformanceCapture | null + ) => Promise< + WorkerPerformanceCaptureResult | undefined + > = finishWorkerPerformanceCapture +): Promise> { + try { + beginWorkerPerformanceCapture(capture); + } catch { + // Instrumentation setup must never prevent business execution. + } + let result: TResult; + try { + result = await execute(); + } catch (error) { + const performance = await safelyFinishWorkerPerformanceCapture( + capture, + finish + ); + return { + error, + performance, + result: undefined, + success: false, + }; + } + + const performance = await safelyFinishWorkerPerformanceCapture( + capture, + finish ); return { - eventLoopDelay: { - maxMs: capture.eventLoopDelay.max / 1e6, - p95Ms: capture.eventLoopDelay.percentile(95) / 1e6, - p99Ms: capture.eventLoopDelay.percentile(99) / 1e6, - }, - eventLoopUtilization: eventLoopUtilization.utilization, + error: null, + performance, + result, + success: true, }; } + +async function safelyFinishWorkerPerformanceCapture( + capture: WorkerPerformanceCapture | null, + finish: ( + capture: WorkerPerformanceCapture | null + ) => Promise +): Promise { + try { + return await finish(capture); + } catch { + if (!capture) { + return undefined; + } + try { + if (capture.workEndedEpochMs === null) { + finishWorkerPerformanceMetricBoundaries(capture); + } + markEventLoopDelayUnavailable( + capture, + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE + ); + return createWorkerPerformanceCaptureResult(capture); + } catch { + return undefined; + } + } +} + +export function registerDatabaseWorkerPerformanceCapture( + activeCaptures: Set, + capture: WorkerPerformanceCapture | null +): void { + if (!capture) { + return; + } + + if (activeCaptures.size > 0) { + for (const activeCapture of activeCaptures) { + activeCapture.invalidReason = + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS; + disableWorkerPerformanceHistogram(activeCapture); + } + capture.invalidReason = + WORKER_PERFORMANCE_INVALID_REASON.OVERLAPPING_DATABASE_WORKER_REQUESTS; + disableWorkerPerformanceHistogram(capture); + } + activeCaptures.add(capture); +} + +export function releaseDatabaseWorkerPerformanceCapture( + activeCaptures: Set, + capture: WorkerPerformanceCapture | null +): void { + if (capture) { + activeCaptures.delete(capture); + } +} 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 new file mode 100644 index 000000000..6ecaac4bd --- /dev/null +++ b/apps/electron-backend/src/app/workers/worker-performance-integration.spec.ts @@ -0,0 +1,115 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + WORKER_PERFORMANCE_UNAVAILABLE_REASON, + armWorkerPerformanceCapture, + executeWithWorkerPerformanceCapture, + startWorkerPerformanceCapture, +} from './worker-performance-capture'; +import { createFakeRuntime } from './worker-performance-capture.test-harness'; + +function readWorkerSource(filename: string): string { + return readFileSync( + join(process.cwd(), 'apps/electron-backend/src/app/workers', filename), + 'utf8' + ); +} + +describe('worker performance integration order', () => { + it('profiles overlapping database requests without serializing their execution', () => { + const source = readWorkerSource('database.worker.ts'); + const handler = source.slice( + source.lastIndexOf("parentPort.on('message'") + ); + + expect(handler).toContain('registerDatabaseWorkerPerformanceCapture'); + expect(handler).toContain('executeWithWorkerPerformanceCapture'); + expect(handler).toContain('releaseDatabaseWorkerPerformanceCapture'); + expect(handler).toContain('finally'); + expect(handler).not.toContain('finishWorkerPerformanceCapture'); + expect( + handler.indexOf('executeWithWorkerPerformanceCapture') + ).toBeLessThan(handler.indexOf('console.error')); + expect( + handler.indexOf('executeWithWorkerPerformanceCapture') + ).toBeLessThan(handler.indexOf('postMessage')); + expect(handler).not.toContain('await previous'); + expect(handler).not.toContain('requestQueue'); + }); + + it('keeps playlist response serialization after profiling completion', () => { + const source = readWorkerSource('playlist-refresh.worker.ts'); + const handler = source.slice(source.lastIndexOf('parentPort.on(')); + const executionIndex = handler.indexOf( + 'executeWithWorkerPerformanceCapture' + ); + + expect(executionIndex).toBeGreaterThanOrEqual(0); + expect(handler).not.toContain('finishWorkerPerformanceCapture'); + expect(executionIndex).toBeLessThan( + handler.indexOf('serializeError', executionIndex) + ); + expect(executionIndex).toBeLessThan( + handler.indexOf('postMessage', executionIndex) + ); + }); + + it('preserves success and business errors when the finish seam throws once', async () => { + const successHarness = createFakeRuntime(); + const successCapture = startWorkerPerformanceCapture({ + enabled: true, + runtime: successHarness.runtime, + }); + await armWorkerPerformanceCapture(successCapture); + const failingSuccessFinish = jest.fn(async () => { + throw new Error('finish failed'); + }); + + const success = await executeWithWorkerPerformanceCapture( + successCapture, + async () => 'business-result', + failingSuccessFinish + ); + + expect(success).toMatchObject({ + error: null, + result: 'business-result', + success: true, + performance: { + eventLoopDelay: null, + eventLoopDelayUnavailableReason: + WORKER_PERFORMANCE_UNAVAILABLE_REASON.EVENT_LOOP_DELAY_CAPTURE_UNAVAILABLE, + eventLoopUtilization: 0.75, + threadCpuSystemMicros: 30, + threadCpuUserMicros: 80, + }, + }); + expect(failingSuccessFinish).toHaveBeenCalledTimes(1); + + const errorHarness = createFakeRuntime(); + const errorCapture = startWorkerPerformanceCapture({ + enabled: true, + runtime: errorHarness.runtime, + }); + await armWorkerPerformanceCapture(errorCapture); + const businessError = new Error('business failed'); + const failingErrorFinish = jest.fn(async () => { + throw new Error('finish failed'); + }); + + const failure = await executeWithWorkerPerformanceCapture( + errorCapture, + async () => { + throw businessError; + }, + failingErrorFinish + ); + + expect(failure).toMatchObject({ + error: businessError, + result: undefined, + success: false, + }); + expect(failingErrorFinish).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/remote-control-web/project.json b/apps/remote-control-web/project.json index 5819a8bf5..8c65582c8 100644 --- a/apps/remote-control-web/project.json +++ b/apps/remote-control-web/project.json @@ -49,6 +49,11 @@ ], "outputHashing": "all" }, + "electron-performance": { + "optimization": true, + "outputHashing": "all", + "sourceMap": true + }, "development": { "optimization": false, "extractLicenses": false, @@ -63,6 +68,12 @@ "command": "pnpm nx run remote-control-web:build:development" } }, + "build-performance": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm nx run remote-control-web:build:electron-performance" + } + }, "serve": { "continuous": true, "executor": "@angular/build:dev-server", diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index fe4335511..d218a0165 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -42,7 +42,6 @@ export default { '^shaka-player$': '/src/test-stubs/shaka-player.js', '^video.js$': '/src/test-stubs/video-js.js', '^rxjs': '/../../node_modules/rxjs/dist/bundles/rxjs.umd.js', - '^uuid$': '/../../node_modules/uuid/wrapper.mjs', }, transform: angularEsmPreset.transform, transformIgnorePatterns: [], diff --git a/apps/web/project.json b/apps/web/project.json index f7b179709..302ea2e37 100644 --- a/apps/web/project.json +++ b/apps/web/project.json @@ -13,7 +13,7 @@ "targets": { "build": { "executor": "@angular/build:application", - "outputs": ["{options.outputPath}"], + "outputs": ["{workspaceRoot}/dist/apps/web"], "options": { "outputPath": { "base": "dist/apps/web", @@ -122,6 +122,27 @@ "extractLicenses": false, "sourceMap": true }, + "electron-performance": { + "baseHref": "./", + "serviceWorker": false, + "optimization": { + "scripts": true, + "styles": { + "minify": true, + "inlineCritical": false, + "removeSpecialComments": true + }, + "fonts": true + }, + "outputHashing": "all", + "sourceMap": true, + "fileReplacements": [ + { + "replace": "apps/web/src/environments/environment.ts", + "with": "apps/web/src/environments/environment.prod.ts" + } + ] + }, "electron-e2e": { "baseHref": "./", "serviceWorker": false, @@ -138,6 +159,12 @@ "command": "pnpm nx run web:build:electron-e2e" } }, + "build-performance": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm nx run web:build:electron-performance" + } + }, "serve": { "continuous": true, "executor": "@angular/build:dev-server", diff --git a/apps/web/src/app/settings/settings-app-update.facade.ts b/apps/web/src/app/settings/settings-app-update.facade.ts new file mode 100644 index 000000000..05659f6f7 --- /dev/null +++ b/apps/web/src/app/settings/settings-app-update.facade.ts @@ -0,0 +1,218 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { DataService, RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + ELECTRON_BRIDGE_APP_UPDATE_STATUSES, + ElectronBridgeAppUpdateStatus, +} from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { take } from 'rxjs'; +import { SettingsService } from '../services/settings.service'; +import { AppUpdateReleaseNotesDialogComponent } from './app-update-release-notes-dialog.component'; + +const APP_UPDATE_STATUS_LOAD_ATTEMPTS = 60; +const APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS = 250; + +/** + * Owns everything the About section needs to talk about versions: the + * electron-updater status stream and the "is my build outdated" message. + */ +@Injectable() +export class SettingsAppUpdateFacade { + private readonly dataService = inject(DataService); + private readonly matDialog = inject(MatDialog); + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly settingsService = inject(SettingsService); + private readonly translate = inject(TranslateService); + + /** Latest updater status, polled once and then pushed by the backend */ + readonly status = signal(null); + + /** Current version of the app */ + readonly version = signal(''); + + /** Update message to show */ + readonly updateMessage = signal(''); + + private unsubscribeStatus: (() => void) | null = null; + + /** Subscribes to status pushes and kicks off the initial status load */ + init(): void { + this.bindStatusEvents(); + void this.loadStatus(); + } + + dispose(): void { + this.unsubscribeStatus?.(); + this.unsubscribeStatus = null; + } + + async checkForAppUpdate(): Promise { + if (!this.runtime.isElectron || !window.electron?.checkForAppUpdate) { + return; + } + + this.status.set(await window.electron.checkForAppUpdate()); + } + + async downloadAppUpdate(): Promise { + if (!this.runtime.isElectron || !window.electron?.downloadAppUpdate) { + return; + } + + this.status.set(await window.electron.downloadAppUpdate()); + } + + async installAppUpdate(): Promise { + if (!this.runtime.isElectron || !window.electron?.installAppUpdate) { + return; + } + + this.status.set(await window.electron.installAppUpdate()); + } + + openManualAppUpdate(): void { + const manualDownloadUrl = this.status()?.manualDownloadUrl; + + if (!manualDownloadUrl) { + return; + } + + window.open(manualDownloadUrl, '_blank', 'noreferrer'); + } + + openReleaseNotes(): void { + const status = this.status(); + const isUpdateRelease = + status?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Available || + status?.status === + ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloading || + status?.status === ELECTRON_BRIDGE_APP_UPDATE_STATUSES.Downloaded; + const initialReleaseNotesVersion = isUpdateRelease + ? (status?.latestVersion ?? + status?.release?.version ?? + status?.currentVersion) + : status?.currentVersion; + + this.matDialog.open(AppUpdateReleaseNotesDialogComponent, { + autoFocus: false, + data: { + ...(!isUpdateRelease ? { fallbackToLatest: true } : {}), + initialVersion: initialReleaseNotesVersion, + }, + maxWidth: 'calc(100vw - 32px)', + restoreFocus: true, + width: '720px', + }); + } + + /** + * Checks whether the latest version of the application + * is used and updates the version message in the + * settings UI + */ + checkAppVersion(): void { + this.settingsService + .getAppVersion() + .pipe(take(1)) + .subscribe((version) => this.showVersionInformation(version)); + } + + /** + * Updates the message in settings UI about the used + * version of the app + * @param currentVersion current version of the application + */ + showVersionInformation(currentVersion: string): void { + const isOutdated = this.isCurrentVersionOutdated(currentVersion); + + if (isOutdated) { + this.updateMessage.set( + `${ + this.translate.instant( + 'SETTINGS.NEW_VERSION_AVAILABLE' + ) as string + }: ${currentVersion}` + ); + } else { + this.updateMessage.set( + this.translate.instant('SETTINGS.LATEST_VERSION') + ); + } + } + + /** + * Compares actual with latest version of the + * application + * @param latestVersion latest version + * @returns returns true if an update is available + */ + isCurrentVersionOutdated(latestVersion: string): boolean { + this.version.set(this.dataService.getAppVersion()); + return this.settingsService.isVersionOutdated( + this.version(), + latestVersion + ); + } + + private bindStatusEvents(): void { + if ( + !this.runtime.isElectron || + !window.electron?.onAppUpdateStatusChange + ) { + return; + } + + this.unsubscribeStatus = window.electron.onAppUpdateStatusChange( + (status) => { + this.status.set(status); + } + ); + } + + /** + * The updater IPC handlers can still be registering while the settings + * page mounts, so the first load retries until the bridge answers. + */ + private async loadStatus(): Promise { + if (!this.runtime.isElectron) { + return; + } + + let lastError: unknown; + + for ( + let attempt = 1; + attempt <= APP_UPDATE_STATUS_LOAD_ATTEMPTS; + attempt += 1 + ) { + const electron = window.electron; + + if (electron?.getAppUpdateStatus) { + try { + this.status.set(await electron.getAppUpdateStatus()); + return; + } catch (error) { + lastError = error; + } + } + + if (attempt === APP_UPDATE_STATUS_LOAD_ATTEMPTS) { + console.warn( + 'Failed to load app update status:', + lastError ?? + new Error('Desktop app update bridge is unavailable') + ); + return; + } + + await this.waitForRetry(); + } + } + + private async waitForRetry(): Promise { + await new Promise((resolve) => { + setTimeout(resolve, APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS); + }); + } +} diff --git a/apps/web/src/app/settings/settings-embedded-mpv.facade.ts b/apps/web/src/app/settings/settings-embedded-mpv.facade.ts new file mode 100644 index 000000000..cfe738c72 --- /dev/null +++ b/apps/web/src/app/settings/settings-embedded-mpv.facade.ts @@ -0,0 +1,74 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { EmbeddedMpvSupport } from '@iptvnator/shared/interfaces'; + +/** + * Probes the desktop backend for embedded MPV support so the playback + * section can hide options the current build cannot deliver. + */ +@Injectable() +export class SettingsEmbeddedMpvFacade { + private readonly runtime = inject(RuntimeCapabilitiesService); + + readonly support = signal(null); + + readonly supported = computed( + () => this.runtime.isElectron && !!this.support()?.supported + ); + + readonly frameCopyAvailable = computed( + () => this.runtime.isElectron && !!this.support()?.frameCopyAvailable + ); + + readonly frameCopyActive = computed( + () => this.support()?.engine === 'frame-copy' + ); + + async load(): Promise { + if (!this.runtime.isElectron) { + this.support.set({ + supported: false, + platform: 'web', + reason: 'Embedded MPV requires the Electron desktop build.', + }); + return; + } + + if (!window.electron?.getEmbeddedMpvSupport) { + this.support.set({ + supported: false, + platform: window.electron.platform, + reason: 'Embedded MPV support is not available in this build.', + }); + return; + } + + try { + this.support.set(await window.electron.getEmbeddedMpvSupport()); + } catch (error) { + this.support.set({ + supported: false, + platform: window.electron.platform, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Opens the native folder picker for MPV recordings. + * @returns the selected folder, or null when the user cancelled or the + * desktop bridge cannot show the dialog + */ + async selectRecordingFolder(): Promise { + if ( + !this.runtime.isElectron || + !window.electron?.selectEmbeddedMpvRecordingFolder + ) { + return null; + } + + return ( + (await window.electron.selectEmbeddedMpvRecordingFolder()) ?? null + ); + } +} diff --git a/apps/web/src/app/settings/settings-epg.facade.ts b/apps/web/src/app/settings/settings-epg.facade.ts new file mode 100644 index 000000000..7f8670e09 --- /dev/null +++ b/apps/web/src/app/settings/settings-epg.facade.ts @@ -0,0 +1,123 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { + EpgRuntimeBridgeService, + EpgService, +} from '@iptvnator/epg/data-access'; +import { DialogService } from '@iptvnator/ui/components'; +import { TranslateService } from '@ngx-translate/core'; +import { SettingsStore } from '../services/settings-store.service'; +import { SettingsFormFacade } from './settings-form.facade'; +import { SettingsSnackbarService } from './settings-snackbar.service'; + +/** + * EPG maintenance actions offered by the settings page: force refreshes and + * the destructive "clear all EPG data" flow. + */ +@Injectable() +export class SettingsEpgFacade { + private readonly dialogService = inject(DialogService); + private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly epgService = inject(EpgService); + private readonly formFacade = inject(SettingsFormFacade); + private readonly settingsSnackbar = inject(SettingsSnackbarService); + private readonly settingsStore = inject(SettingsStore); + private readonly translate = inject(TranslateService); + + readonly isClearing = signal(false); + + /** + * Force-fetch EPG for a single URL, bypassing the 12-hour freshness check. + * The plain fetchEpg would short-circuit on fresh data and click the + * refresh button would be a no-op — that's exactly not what the user + * intends when clicking "Refresh". + */ + refresh(url: string): void { + if (!this.epgBridge.supportsDataManagement || !url) { + return; + } + void this.epgBridge.forceFetchEpg( + url, + this.settingsStore.getTrustOptions() + ); + } + + /** + * Force-fetch every configured EPG URL sequentially. Empty fields are + * skipped. Each URL flows through the normal progress panel so the user + * gets visible per-URL feedback. + */ + refreshAll(): void { + if (!this.epgBridge.supportsDataManagement) return; + const options = this.settingsStore.getTrustOptions(); + this.formFacade.epgUrls.forEach( + (url) => void this.epgBridge.forceFetchEpg(url, options) + ); + } + + /** + * Clears all EPG data from database and immediately re-fetches every + * configured URL so the user isn't left staring at an empty state. + * Tracks progress with `isClearing` so the UI can show a spinner + * and block double-clicks, and surfaces failures via a dedicated snackbar. + */ + clear(): void { + this.dialogService.openConfirmDialog({ + title: this.translate.instant('SETTINGS.CLEAR_EPG_DIALOG.TITLE'), + message: this.translate.instant( + 'SETTINGS.CLEAR_EPG_DIALOG.MESSAGE' + ), + onConfirm: async (): Promise => { + if ( + !this.epgBridge.supportsDataManagement || + this.isClearing() + ) { + return; + } + + this.isClearing.set(true); + try { + const result = await this.epgBridge.clearEpgData(); + if (result && result.success === false) { + throw new Error('Clear EPG returned success=false'); + } + this.settingsSnackbar.open( + this.translate.instant('SETTINGS.EPG_DATA_CLEARED') + ); + this.refreshAll(); + } catch (error) { + console.error('Failed to clear EPG data:', error); + this.settingsSnackbar.open( + this.translate.instant('SETTINGS.EPG_DATA_CLEAR_FAILED') + ); + } finally { + this.isClearing.set(false); + } + }, + }); + } + + /** + * Re-fetches the EPG sources that were just saved so the guide reflects + * the new configuration without a restart. + */ + fetchConfiguredEpg(): void { + if (!this.formFacade.supportsEpg) { + return; + } + + const epgUrl = this.formFacade.form.value.epgUrl; + + if (!epgUrl) { + return; + } + + const validEpgUrls = (Array.isArray(epgUrl) ? epgUrl : [epgUrl]).filter( + (url): url is string => typeof url === 'string' && url !== '' + ); + + if (validEpgUrls.length > 0) { + // Fetch all EPG URLs at once + this.epgService.fetchEpg(validEpgUrls); + } + } +} diff --git a/apps/web/src/app/settings/settings-form.facade.ts b/apps/web/src/app/settings/settings-form.facade.ts new file mode 100644 index 000000000..a1b4a31a1 --- /dev/null +++ b/apps/web/src/app/settings/settings-form.facade.ts @@ -0,0 +1,197 @@ +import { DestroyRef, inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormArray, FormBuilder } from '@angular/forms'; +import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { + CoverSize, + EpgViewMode, + Language, + Theme, +} from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { SettingsStore } from '../services/settings-store.service'; +import { SettingsService } from '../services/settings.service'; +import { + applyEpgUrlsToFormArray, + createEpgUrlControl, + createSettingsForm, + createSettingsFromFormValue, + SettingsForm, +} from './settings-form.utils'; + +type SettingsFormPatch = Parameters[0]; + +/** + * Owns the settings form: creation, hydration from the store, the small + * mutations the section components trigger, and persisting on submit. + */ +@Injectable() +export class SettingsFormFacade { + private readonly destroyRef = inject(DestroyRef); + private readonly epgBridge = inject(EpgRuntimeBridgeService); + private readonly formBuilder = inject(FormBuilder); + private readonly runtime = inject(RuntimeCapabilitiesService); + private readonly settingsService = inject(SettingsService); + private readonly settingsStore = inject(SettingsStore); + private readonly translate = inject(TranslateService); + + readonly supportsEpg = + this.epgBridge.supportsImport && this.epgBridge.supportsDataManagement; + + /** Settings form object */ + readonly form = createSettingsForm(this.formBuilder, this.supportsEpg); + + /** Form array with epg sources — absent when EPG is unsupported */ + readonly epgUrl = this.form.get('epgUrl') as FormArray; + + /** Trimmed, non-empty EPG source URLs currently in the form */ + get epgUrls(): string[] { + return ((this.epgUrl?.value as string[] | undefined) ?? []) + .map((url) => url?.trim()) + .filter((url): url is string => Boolean(url)); + } + + /** Waits for the persisted settings to be available */ + loadSettings(): Promise { + return this.settingsStore.loadSettings(); + } + + /** + * Sets saved settings from the indexed db store + */ + hydrateFromStore(): void { + const currentSettings = this.settingsStore.getSettings(); + this.form.patchValue(currentSettings); + this.syncDashboardControlsEnabledState( + currentSettings.showDashboard ?? true + ); + + if (this.supportsEpg && currentSettings.epgUrl) { + this.epgUrl.clear(); + this.setEpgUrls(currentSettings.epgUrl); + } + } + + bindDashboardControlsEnabledState(): void { + this.form + .get('showDashboard') + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((showDashboard) => + this.syncDashboardControlsEnabledState(showDashboard ?? true) + ); + } + + selectTheme(theme: Theme): void { + if (this.form.value.theme === theme) { + return; + } + + this.patchAndMarkDirty({ theme }, 'theme'); + this.settingsService.changeTheme(theme); + } + + selectCoverSize(coverSize: CoverSize): void { + if (this.form.value.coverSize === coverSize) { + return; + } + + this.patchAndMarkDirty({ coverSize }, 'coverSize'); + void this.settingsStore.updateSettings({ coverSize }); + } + + selectEpgViewMode(epgViewMode: EpgViewMode): void { + if (this.form.value.epgViewMode === epgViewMode) { + return; + } + + this.patchAndMarkDirty({ epgViewMode }, 'epgViewMode'); + void this.settingsStore.updateSettings({ epgViewMode }); + } + + setRecordingFolder(recordingFolder: string): void { + this.patchAndMarkDirty({ recordingFolder }, 'recordingFolder'); + } + + /** + * Sets the epg urls to the form array + * @param epgUrls urls of the EPG sources + */ + setEpgUrls(epgUrls: string[] | string): void { + applyEpgUrlsToFormArray(this.epgUrl, epgUrls); + } + + /** + * Initializes new entry in form array for EPG URL + */ + addEpgSource(): void { + this.epgUrl.insert(this.epgUrl.length, createEpgUrlControl()); + } + + /** + * Removes entry from form array for EPG URL + * @param index index of the item to remove + */ + removeEpgSource(index: number): void { + this.epgUrl.removeAt(index); + this.form.markAsDirty(); + } + + /** + * Persists the form to the settings store and mirrors the result to the + * desktop backend. + * @param onSaved runs after the store write, before the backend is + * notified, so the UI confirmation is not delayed by IPC + */ + async save(onSaved: () => void): Promise { + const settings = createSettingsFromFormValue( + this.form, + this.settingsStore.getSettings() + ); + + await this.settingsStore.updateSettings(settings); + onSaved(); + + if (!window.electron) { + return; + } + + window.electron.updateSettings(settings); + + if (this.runtime.supportsExternalPlayerPathSettings) { + window.electron.setMpvPlayerPath(settings.mpvPlayerPath); + window.electron.setVlcPlayerPath(settings.vlcPlayerPath); + } + } + + /** Applies the saved language/theme and resets the dirty state */ + applySavedSettings(): void { + this.form.markAsPristine(); + this.translate.use(this.form.value.language ?? Language.ENGLISH); + this.settingsService.changeTheme( + this.form.value.theme ?? Theme.SystemTheme + ); + } + + private patchAndMarkDirty( + value: SettingsFormPatch, + controlName: string + ): void { + this.form.patchValue(value); + this.form.get(controlName)?.markAsDirty(); + this.form.markAsDirty(); + } + + private syncDashboardControlsEnabledState(showDashboard: boolean): void { + const dashboardRails = this.form.get('dashboardRails'); + if (!dashboardRails) { + return; + } + + if (showDashboard) { + dashboardRails.enable({ emitEvent: false }); + } else { + dashboardRails.disable({ emitEvent: false }); + } + } +} diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts index 5add3d301..8df774bc1 100644 --- a/apps/web/src/app/settings/settings-options.ts +++ b/apps/web/src/app/settings/settings-options.ts @@ -100,6 +100,33 @@ export const SETTINGS_EMBEDDED_PLAYER_OPTIONS: SettingsPlayerOption[] = [ }, ]; +export interface SettingsPlayerAvailability { + supportsEmbeddedMpv: boolean; + supportsManagedExternalPlayers: boolean; +} + +/** + * Built-in web players are always offered; the OS-backed ones only show up + * when the current runtime can actually launch them. + */ +export function buildSettingsPlayerOptions({ + supportsEmbeddedMpv, + supportsManagedExternalPlayers, +}: SettingsPlayerAvailability): SettingsPlayerOption[] { + return [ + ...SETTINGS_EMBEDDED_PLAYER_OPTIONS, + ...(supportsEmbeddedMpv + ? [ + { + id: VideoPlayer.EmbeddedMpv, + labelKey: 'SETTINGS.PLAYER_EMBEDDED_MPV', + }, + ] + : []), + ...(supportsManagedExternalPlayers ? SETTINGS_OS_PLAYER_OPTIONS : []), + ]; +} + export interface SettingsSectionVisibility { supportsEpg: boolean; supportsRemoteControl: boolean; diff --git a/apps/web/src/app/settings/settings-playlist-reset.facade.ts b/apps/web/src/app/settings/settings-playlist-reset.facade.ts index 2a427760a..8ec1a1fa8 100644 --- a/apps/web/src/app/settings/settings-playlist-reset.facade.ts +++ b/apps/web/src/app/settings/settings-playlist-reset.facade.ts @@ -1,19 +1,30 @@ -import { inject, Injectable, signal } from '@angular/core'; +import { computed, inject, Injectable, signal } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { firstValueFrom } from 'rxjs'; -import { PlaylistActions } from '@iptvnator/m3u-state'; +import { firstValueFrom, take } from 'rxjs'; +import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state'; import { DatabaseService, DbOperationEvent, PlaylistsService, RuntimeCapabilitiesService, } from '@iptvnator/services'; +import { + SettingsDeleteAllPlaylistsDialogComponent, + SettingsDeleteAllPlaylistsDialogData, +} from './settings-delete-all-playlists-dialog.component'; +import { + buildRemoveAllProgressLabel, + buildSettingsPlaylistDeleteSummary, +} from './settings-playlist-summary.utils'; +import { SettingsPlaylistDeleteSummary } from './settings.models'; import { SettingsSnackbarService } from './settings-snackbar.service'; @Injectable() export class SettingsPlaylistResetFacade { private readonly databaseService = inject(DatabaseService); + private readonly matDialog = inject(MatDialog); private readonly playlistsService = inject(PlaylistsService); private readonly runtime = inject(RuntimeCapabilitiesService); private readonly settingsSnackbar = inject(SettingsSnackbarService); @@ -23,7 +34,59 @@ export class SettingsPlaylistResetFacade { readonly isRemovingAllPlaylists = signal(false); readonly removeAllProgress = signal(null); - async removeAllConfirmed( + private readonly playlists = this.store.selectSignal( + selectAllPlaylistsMeta + ); + + readonly deleteSummary = computed(() => + buildSettingsPlaylistDeleteSummary(this.playlists()) + ); + + readonly canRemoveAll = computed( + () => !this.isRemovingAllPlaylists() && this.deleteSummary().total > 0 + ); + + readonly removeAllProgressLabel = computed(() => + buildRemoveAllProgressLabel({ + isRemovingAllPlaylists: this.isRemovingAllPlaylists(), + progress: this.removeAllProgress(), + translate: (key, params) => this.translate.instant(key, params), + }) + ); + + /** + * Asks for confirmation in a dedicated dialog before wiping every + * playlist, so the summary of what is about to be lost is visible. + */ + confirmAndRemoveAll(waitForUiFeedbackFrame: () => Promise): void { + if (!this.canRemoveAll()) { + return; + } + + this.matDialog + .open< + SettingsDeleteAllPlaylistsDialogComponent, + SettingsDeleteAllPlaylistsDialogData, + boolean + >(SettingsDeleteAllPlaylistsDialogComponent, { + autoFocus: false, + data: { + summary: this.deleteSummary(), + }, + maxWidth: 'calc(100vw - 32px)', + restoreFocus: true, + width: '460px', + }) + .afterClosed() + .pipe(take(1)) + .subscribe((confirmed) => { + if (confirmed) { + void this.removeAllConfirmed(waitForUiFeedbackFrame); + } + }); + } + + private async removeAllConfirmed( waitForUiFeedbackFrame: () => Promise ): Promise { if (this.isRemovingAllPlaylists()) { diff --git a/apps/web/src/app/settings/settings-remote-control.facade.ts b/apps/web/src/app/settings/settings-remote-control.facade.ts new file mode 100644 index 000000000..6796510cd --- /dev/null +++ b/apps/web/src/app/settings/settings-remote-control.facade.ts @@ -0,0 +1,37 @@ +import { inject, Injectable, signal } from '@angular/core'; +import { RuntimeCapabilitiesService } from '@iptvnator/services'; + +/** + * Backs the remote-control section: the reachable LAN addresses and which of + * them currently shows its pairing QR code. + */ +@Injectable() +export class SettingsRemoteControlFacade { + private readonly runtime = inject(RuntimeCapabilitiesService); + + /** Local IP addresses for remote control URL display */ + readonly localIpAddresses = signal([]); + + /** Currently visible QR code IP (null = none visible) */ + readonly visibleQrCodeIp = signal(null); + + /** + * Fetches local IP addresses for remote control URL display + */ + async fetchLocalIpAddresses(): Promise { + if ( + this.runtime.supportsRemoteControl && + window.electron?.getLocalIpAddresses + ) { + const addresses = await window.electron.getLocalIpAddresses(); + this.localIpAddresses.set(addresses); + } + } + + /** + * Toggle QR code visibility for a given IP address + */ + toggleQrCode(ip: string): void { + this.visibleQrCodeIp.update((visible) => (visible === ip ? null : ip)); + } +} diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 0ef375d3d..0bdbf2bd0 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -38,8 +38,8 @@ [themeOptions]="themeOptions" [coverSizeOptions]="coverSizeOptions" [startupBehaviorOptions]="startupBehaviorOptions" - (selectTheme)="selectTheme($event)" - (selectCoverSize)="selectCoverSize($event)" + (selectTheme)="form.selectTheme($event)" + (selectCoverSize)="form.selectCoverSize($event)" /> @@ -59,15 +59,15 @@ } @@ -80,9 +80,9 @@ } @@ -94,33 +94,33 @@