diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 72e0ea762..897afeb87 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -1003,6 +1003,9 @@ jobs: '$1 == "shared-memory" && $2 == "iptvnator:shared-memory" && $3 == ":shared-memory" { found=1 } END { exit !found }' xvfb-run -a env \ LIBGL_ALWAYS_SOFTWARE=1 \ + IPTVNATOR_TRACE_PLAYER=1 \ + EGL_LOG_LEVEL=debug \ + LIBGL_DEBUG=verbose \ __EGL_VENDOR_LIBRARY_FILENAMES=/tmp/hostile-egl-vendor.json \ GBM_BACKEND=/tmp/hostile-gbm \ MESA_LOADER_DRIVER_OVERRIDE=/tmp/hostile-dri \ diff --git a/AGENTS.md b/AGENTS.md index 68e060a6b..01fd8a4f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events - `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events - - `IPTVNATOR_TRACE_PLAYER=1` traces external-player launch/reuse/polling debug output + - `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 - GPU/compositor debugging: @@ -280,8 +280,13 @@ Key files: keeps top-level reason `helper-probe-failed`; `helperReason` is present only for an exact protocol-v1 line carrying a fixed allowlisted reason, and its optional `helperDetail` must be 1–1024 printable ASCII characters. Invalid - detail suppresses both helper fields. Any loader failure remains a stable - native-view fallback, never a flag-enabled success. + detail suppresses both helper fields. With `IPTVNATOR_TRACE_PLAYER=1`, a + non-empty helper stderr capture is emitted separately as one JSON-escaped + stderr line whose `stderr` field is limited to 16,384 characters and whose + `truncated` field is always explicit; trace-write failure cannot change the + capability result. Installed-Snap CI enables Mesa EGL/GL diagnostics through + this bounded channel. Any loader failure remains a stable native-view + fallback, never a flag-enabled success. - In the exact packaged Flatpak `/app` context, reconstruct only Freedesktop Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its GL extension loader path comes from the sandbox cache. Flatpak CI must invoke diff --git a/CLAUDE.md b/CLAUDE.md index 5c059f334..56652a1ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,7 +127,7 @@ Useful narrower flags: - `IPTVNATOR_TRACE_DB=1` traces DB worker requests and DB progress events - `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in both main and worker connections - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle -- `IPTVNATOR_TRACE_PLAYER=1` traces external-player launch/reuse/polling debug output +- `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 For GPU/compositor debugging: @@ -671,8 +671,13 @@ engine` (restart required) or helper exit keeps top-level reason `helper-probe-failed`; `helperReason` is present only for an exact protocol-v1 line carrying a fixed allowlisted reason, and its optional `helperDetail` must be 1–1024 printable ASCII - characters. Invalid detail suppresses both helper fields. The exact packaged - Flatpak `/app` context reconstructs only Freedesktop Platform 24.08's immutable + characters. Invalid detail suppresses both helper fields. With + `IPTVNATOR_TRACE_PLAYER=1`, non-empty helper stderr is emitted separately as + one JSON-escaped stderr line with a 16,384-character `stderr` limit and an + explicit `truncated` field; trace-write failure cannot change availability. + Installed-Snap CI enables Mesa EGL/GL diagnostics through this bounded + channel. The exact packaged Flatpak `/app` context reconstructs only + Freedesktop Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its CI smoke invokes that application-level probe instead of the helper directly. Bundled Linux packages carry hash-validated diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts index 0a5bdc476..25dcb8fbd 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts @@ -253,4 +253,86 @@ describe('embedded-mpv frame-copy helper failures', () => { }); } ); + + it('does not trace helper stderr without the exact player trace flag', () => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n', + stderr: 'libEGL debug output\n', + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-failed', + helperReason: 'gl-context-create-failed', + }); + expect(context.writeRuntimeProbeStderr).not.toHaveBeenCalled(); + }); + + it('traces helper stderr as one JSON-escaped line when player tracing is enabled', () => { + const fixture = createFixture(context.rootDir); + const helperStderr = + 'libEGL warning: vendor "mesa"\nfailed path: C:\\driver'; + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n', + stderr: helperStderr, + }); + + expect( + context.createProbe({ + env: { + PATH: '/usr/bin', + IPTVNATOR_TRACE_PLAYER: '1', + }, + })(fixture.helperPath) + ).toEqual({ + usable: false, + reason: 'helper-probe-failed', + helperReason: 'gl-context-create-failed', + }); + expect(context.writeRuntimeProbeStderr).toHaveBeenCalledWith( + `${JSON.stringify({ + event: 'embedded-mpv-helper-runtime-probe-stderr', + stderr: helperStderr, + truncated: false, + })}\n` + ); + expect(context.writeRuntimeProbeStderr).toHaveBeenCalledTimes(1); + expect( + context.writeRuntimeProbeStderr.mock.calls[0][0].split('\n') + ).toHaveLength(2); + }); + + it('bounds traced helper stderr to 16384 characters and reports truncation', () => { + const fixture = createFixture(context.rootDir); + const helperStderr = `${'x'.repeat(16_384)}discarded`; + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n', + stderr: helperStderr, + }); + + context.createProbe({ + env: { + PATH: '/usr/bin', + IPTVNATOR_TRACE_PLAYER: '1', + }, + })(fixture.helperPath); + + const trace = JSON.parse( + context.writeRuntimeProbeStderr.mock.calls[0][0] + ); + expect(trace).toEqual({ + event: 'embedded-mpv-helper-runtime-probe-stderr', + stderr: 'x'.repeat(16_384), + truncated: true, + }); + expect(trace.stderr).toHaveLength(16_384); + expect(context.writeRuntimeProbeStderr).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts index f39bbfb72..229756efb 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts @@ -30,6 +30,9 @@ import { const HELPER_RUNTIME_PROBE_FAILURE_REASON_ALLOWLIST = new Set( Object.values(EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS) ); +const HELPER_RUNTIME_PROBE_STDERR_LIMIT = 16_384; +const HELPER_RUNTIME_PROBE_STDERR_EVENT = + 'embedded-mpv-helper-runtime-probe-stderr'; interface ParsedFailedProbe { helperReason: EmbeddedMpvHelperRuntimeProbeFailureReason; @@ -130,6 +133,31 @@ function parseSuccessfulProbe( }; } +function traceHelperProbeStderr( + stderr: unknown, + dependencies: EmbeddedMpvFrameCopyRuntimeDependencies +): void { + if ( + dependencies.env.IPTVNATOR_TRACE_PLAYER !== '1' || + typeof stderr !== 'string' || + stderr.length === 0 + ) { + return; + } + + const boundedStderr = stderr.slice(0, HELPER_RUNTIME_PROBE_STDERR_LIMIT); + const output = `${JSON.stringify({ + event: HELPER_RUNTIME_PROBE_STDERR_EVENT, + stderr: boundedStderr, + truncated: stderr.length > boundedStderr.length, + })}\n`; + try { + dependencies.writeStderr(output); + } catch { + // Opt-in diagnostics must never change the fail-closed probe result. + } +} + function runHelperProbe( runtimePackage: ValidatedPackage, dependencies: EmbeddedMpvFrameCopyRuntimeDependencies @@ -157,6 +185,7 @@ function runHelperProbe( } catch { return failure('helper-probe-spawn-error'); } + traceHelperProbeStderr(result.stderr, dependencies); if ( result.error && @@ -207,6 +236,9 @@ export function createEmbeddedMpvFrameCopyRuntimeProbe( env: process.env, fileSystem: defaultFileSystem, spawnSync: nodeSpawnSync, + writeStderr: (output) => { + nodeFileSystem.writeSync(process.stderr.fd, output); + }, ...overrides, }; const resultCache = new Map(); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-harness.test-helpers.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-harness.test-helpers.ts index 3f27c66fa..adbdbef73 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-harness.test-helpers.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-harness.test-helpers.ts @@ -18,6 +18,7 @@ import { SUCCESS_OUTPUT } from './runtime.spec-data'; export interface RuntimeTestContext { rootDir: string; spawnRuntimeProbe: jest.Mock; + writeRuntimeProbeStderr: jest.Mock; fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem']; createProbe( overrides?: Partial @@ -33,6 +34,7 @@ export function createRuntimeTestContext(): RuntimeTestContext { stdout: SUCCESS_OUTPUT, stderr: '', })); + const writeRuntimeProbeStderr = jest.fn(); const fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem'] = { accessSync: jest.fn((filePath: string, mode: number) => accessSync(filePath, mode) @@ -44,6 +46,7 @@ export function createRuntimeTestContext(): RuntimeTestContext { const context: RuntimeTestContext = { rootDir, spawnRuntimeProbe, + writeRuntimeProbeStderr, fileSystem, createProbe( overrides: Partial = {} @@ -59,6 +62,7 @@ export function createRuntimeTestContext(): RuntimeTestContext { }, fileSystem: context.fileSystem, spawnSync: context.spawnRuntimeProbe as typeof spawnSync, + writeStderr: context.writeRuntimeProbeStderr, ...overrides, }); }, diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts index 8108dd3c1..96240b24c 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts @@ -71,6 +71,7 @@ export interface EmbeddedMpvFrameCopyRuntimeDependencies { env: NodeJS.ProcessEnv; fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem; spawnSync: typeof nodeSpawnSync; + writeStderr(output: string): void; } export interface RuntimeFile { diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index aab5ada81..a2087fa10 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -282,7 +282,12 @@ allowlisted helper reason as `helperReason`. An optional `helperDetail` is copied only from the same exact line when it contains 1–1024 printable ASCII characters; an invalid detail rejects both helper fields. Malformed, multi-line, wrong-protocol, or unknown failure output never reaches either -field. +field. When `IPTVNATOR_TRACE_PLAYER=1`, the probe also emits non-empty captured +helper stderr separately as one JSON line. JSON escaping keeps embedded +newlines on that single line, the `stderr` field is limited to the first 16,384 +characters, and the `truncated` boolean is always present. A missing flag, +empty capture, or trace-writer failure produces no trace and never changes the +cached availability result or the application diagnostic's stdout protocol. The startup probe and every playback helper session use the same sanitized loader environment selected by the validated manifest's cached `runtimeMode`. @@ -304,6 +309,10 @@ GL extension `add-ld-path` is supplied through the sandbox loader cache, so no ambient `LD_LIBRARY_PATH` is needed. Flatpak CI runs the application-level `--embedded-mpv-runtime-probe`; direct helper execution is only a package layout check and cannot substitute for the real gate. +The installed-Snap smoke enables `IPTVNATOR_TRACE_PLAYER=1`, +`EGL_LOG_LEVEL=debug`, and `LIBGL_DEBUG=verbose`, so GLVND/Mesa loader failures +remain observable through the bounded stderr record while the same hostile +ambient-path assertions and fail-closed application gate stay active. Inside a genuine Snap mount, filtered absolute `SNAP_LIBRARY_PATH` entries below `/var/lib/snapd/lib/gl` follow `native/lib`. The exact `$SNAP/graphics/usr/lib/x86_64-linux-gnu` content-provider roots come next, diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index f1163abb5..57361da99 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -236,14 +236,20 @@ application diagnostic retains `helper-probe-failed` as the top-level reason for nonzero helper exits and adds `helperReason` only when the helper emitted one exact protocol-v1 line with a fixed allowlisted reason. Its optional `helperDetail` is restricted to 1–1024 printable ASCII characters; invalid -detail suppresses both helper fields. The +detail suppresses both helper fields. With `IPTVNATOR_TRACE_PLAYER=1`, non-empty +captured helper stderr is written separately as one JSON-escaped stderr line: +its `stderr` field contains at most the first 16,384 characters and its +`truncated` boolean is always explicit. Empty captures, disabled tracing, and +trace-writer failures do not emit a record or alter availability. The installed-Snap probe therefore tests the private shared-memory confinement needed by playback rather than only loader and graphics startup. Packaging CI invokes the same gate through `snap run iptvnator --embedded-mpv-runtime-probe`. This packaging-only application switch runs before BrowserWindow startup, emits one availability JSON line, and returns zero only for a usable runtime; it never directly loads -libmpv in Electron. +libmpv in Electron. The installed-Snap smoke adds `EGL_LOG_LEVEL=debug` and +`LIBGL_DEBUG=verbose` under that bounded trace channel to expose GLVND/Mesa +loader failures without weakening the hostile-environment gate. Electron Builder excludes `electron-backend/native{,/**/*}` from `app.asar`. Only `afterPack` writes the profile-normalized diff --git a/tools/packaging/configure-linux-frame-copy-build.test.mjs b/tools/packaging/configure-linux-frame-copy-build.test.mjs index 99a9e1051..7dfc4f9eb 100644 --- a/tools/packaging/configure-linux-frame-copy-build.test.mjs +++ b/tools/packaging/configure-linux-frame-copy-build.test.mjs @@ -709,6 +709,16 @@ test('Snap verifier preserves fail-closed status while exposing captured diagnos assert.ok(gnomeConnectionAssertion < sharedMemoryConnectionAssertion); assert.ok(sharedMemoryConnectionAssertion < successfulRuntimeProbe); assert.match(snapStep, /snap run iptvnator --embedded-mpv-runtime-probe/); + for (const traceSelector of [ + 'IPTVNATOR_TRACE_PLAYER=1', + 'EGL_LOG_LEVEL=debug', + 'LIBGL_DEBUG=verbose', + ]) { + assert.match( + snapStep.slice(secondGraphicsConnect), + new RegExp(traceSelector) + ); + } for (const hostileOverride of [ '__EGL_VENDOR_LIBRARY_FILENAMES', 'GBM_BACKEND',