From 4302aa405869cd423d42670df019f095944afa53 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 18 Jul 2026 12:40:33 +0200 Subject: [PATCH] fix(packaging): improve Linux runtime smoke diagnostics --- .github/workflows/build-and-make.yaml | 3 +- AGENTS.md | 6 +- CLAUDE.md | 11 ++- .../embedded-mpv-frame-copy-runtime.ts | 1 + .../helper-failures.spec.ts | 74 +++++++++++++++++++ .../embedded-mpv-frame-copy-runtime/probe.ts | 50 ++++++++++++- .../embedded-mpv-frame-copy-runtime/types.ts | 14 ++++ .../embedded-mpv-runtime-diagnostic.spec.ts | 5 +- docs/architecture/embedded-mpv-native.md | 6 +- tools/embedded-mpv/README.md | 3 + .../configure-linux-frame-copy-build.test.mjs | 17 +++++ 11 files changed, 179 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index defeeeaef..0c943edf9 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -333,6 +333,7 @@ jobs: sudo apt-get install --no-install-recommends -y \ appstream \ binutils \ + dbus-daemon \ flatpak \ flatpak-builder \ libarchive-tools \ @@ -1087,7 +1088,7 @@ jobs: grep -q '\''readlink -f "$SCRIPT_PATH"'\'' "${LAUNCHER_PATH}" grep -q '\''exec "$SCRIPT_DIR/iptvnator.bin"'\'' "${LAUNCHER_PATH}" ' - xvfb-run -a flatpak run \ + xvfb-run -a dbus-run-session -- flatpak run \ --env=LIBGL_ALWAYS_SOFTWARE=1 \ com.fourgray.iptvnator \ --embedded-mpv-runtime-probe diff --git a/AGENTS.md b/AGENTS.md index 546d70669..1c30ad323 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -276,8 +276,10 @@ Key files: disconnected provider returns `snap-graphics-provider-unavailable` before helper spawn. The packaging-only `--embedded-mpv-runtime-probe` app switch runs the complete cached manifest/hash/helper gate before BrowserWindow - startup and exits with one availability JSON line. Any loader failure remains - a stable native-view fallback, never a flag-enabled success. + startup and exits with one availability JSON line. A nonzero helper exit + keeps top-level reason `helper-probe-failed`; `helperReason` is present only + for an exact protocol-v1 line carrying a fixed allowlisted reason. 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 36cab5444..373d22e0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -667,10 +667,13 @@ engine` (restart required) or disconnected provider returns `snap-graphics-provider-unavailable` before helper spawn. The packaging-only `--embedded-mpv-runtime-probe` app switch runs the complete packaged gate - before BrowserWindow startup and emits one availability JSON line. 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 + before BrowserWindow startup and emits one availability JSON line. A nonzero + helper exit keeps top-level reason `helper-probe-failed`; `helperReason` is + present only for an exact protocol-v1 line carrying a fixed allowlisted + reason. 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 `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and `licenses/**`. CI caches the staged runtime plus immutable source inputs, never finished diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts index 5adbf5d02..349f0ef52 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts @@ -11,5 +11,6 @@ export type { EmbeddedMpvFrameCopyRuntimeFileSystem, EmbeddedMpvFrameCopyRuntimeMode, EmbeddedMpvFrameCopyRuntimeResult, + EmbeddedMpvHelperRuntimeProbeFailureReason, } from './embedded-mpv-frame-copy-runtime/types'; export type { LinuxFrameCopyHelperLaunchFileSystem } from './embedded-mpv-frame-copy-runtime/helper-launch'; 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 a7842b6cf..6e5ecff9e 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 @@ -123,4 +123,78 @@ describe('embedded-mpv frame-copy helper failures', () => { expect.objectContaining({ usable: false, reason }) ); }); + + it.each([ + 'mpv-create-failed', + 'mpv-initialize-failed', + 'gl-context-create-failed', + 'gl-context-bind-failed', + 'mpv-render-context-failed', + 'shared-memory-create-failed', + 'shared-memory-initialize-failed', + ])('preserves the allowlisted helper reason %s', (helperReason) => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: `${JSON.stringify({ + protocol: 1, + usable: false, + reason: helperReason, + ...(helperReason === 'mpv-create-failed' + ? {} + : { + detail: 'diagnostic detail is intentionally not propagated', + }), + })}\n`, + stderr: '', + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-failed', + helperReason, + }); + }); + + it.each([ + ['malformed JSON', 'not-json\n'], + [ + 'multiple lines', + '{"protocol":1,"usable":false,"reason":"mpv-create-failed"}\nignored\n', + ], + [ + 'wrong protocol', + '{"protocol":2,"usable":false,"reason":"mpv-create-failed"}\n', + ], + [ + 'wrong usable value', + '{"protocol":1,"usable":true,"reason":"mpv-create-failed"}\n', + ], + [ + 'non-allowlisted reason', + '{"protocol":1,"usable":false,"reason":"loader-injected"}\n', + ], + [ + 'unexpected field', + '{"protocol":1,"usable":false,"reason":"mpv-create-failed","extra":true}\n', + ], + [ + 'invalid detail', + '{"protocol":1,"usable":false,"reason":"mpv-create-failed","detail":1}\n', + ], + ])('does not propagate a helper reason from %s', (_label, stdout) => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout, + stderr: '', + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-failed', + }); + }); }); 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 0196369cd..4623d7f14 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 @@ -8,11 +8,13 @@ import { } from './contracts'; import { createLinuxFrameCopyHelperLaunch } from './helper-launch'; import { validatePackage } from './package-validator'; +import { EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS } from './types'; import type { EmbeddedMpvFrameCopyManifestContract, EmbeddedMpvFrameCopyRuntimeDependencies, EmbeddedMpvFrameCopyRuntimeFileSystem, EmbeddedMpvFrameCopyRuntimeResult, + EmbeddedMpvHelperRuntimeProbeFailureReason, ValidManifest, ValidatedPackage, } from './types'; @@ -25,6 +27,45 @@ import { isValidationFailure, } from './validation-primitives'; +const HELPER_RUNTIME_PROBE_FAILURE_REASON_ALLOWLIST = new Set( + Object.values(EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS) +); + +function parseFailedProbeReason( + stdout: unknown +): EmbeddedMpvHelperRuntimeProbeFailureReason | null { + if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(stdout.slice(0, -1)); + } catch { + return null; + } + if (!isObject(parsed)) { + return null; + } + + const hasDetail = Object.prototype.hasOwnProperty.call(parsed, 'detail'); + const fields = hasDetail + ? ['detail', 'protocol', 'reason', 'usable'] + : ['protocol', 'reason', 'usable']; + if ( + !hasExactFields(parsed, fields) || + parsed.protocol !== RUNTIME_PROBE_PROTOCOL || + parsed.usable !== false || + typeof parsed.reason !== 'string' || + !HELPER_RUNTIME_PROBE_FAILURE_REASON_ALLOWLIST.has(parsed.reason) || + (hasDetail && + (typeof parsed.detail !== 'string' || parsed.detail.length === 0)) + ) { + return null; + } + return parsed.reason as EmbeddedMpvHelperRuntimeProbeFailureReason; +} + function parseSuccessfulProbe( stdout: unknown, manifest: ValidManifest @@ -116,7 +157,14 @@ function runHelperProbe( return failure('helper-probe-signaled'); } if (result.status !== 0) { - return failure('helper-probe-failed'); + const helperReason = parseFailedProbeReason(result.stdout); + return helperReason + ? { + usable: false, + reason: 'helper-probe-failed', + helperReason, + } + : failure('helper-probe-failed'); } return parseSuccessfulProbe(result.stdout, runtimePackage.manifest); } 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 931ff0b5a..3e3c741f2 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 @@ -1,6 +1,19 @@ import type { spawnSync as nodeSpawnSync } from 'child_process'; import type * as nodeFileSystem from 'fs'; +export const EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS = { + MPV_CREATE_FAILED: 'mpv-create-failed', + MPV_INITIALIZE_FAILED: 'mpv-initialize-failed', + GL_CONTEXT_CREATE_FAILED: 'gl-context-create-failed', + GL_CONTEXT_BIND_FAILED: 'gl-context-bind-failed', + MPV_RENDER_CONTEXT_FAILED: 'mpv-render-context-failed', + SHARED_MEMORY_CREATE_FAILED: 'shared-memory-create-failed', + SHARED_MEMORY_INITIALIZE_FAILED: 'shared-memory-initialize-failed', +} as const; + +export type EmbeddedMpvHelperRuntimeProbeFailureReason = + (typeof EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS)[keyof typeof EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS]; + export type EmbeddedMpvFrameCopyRuntimeFailureReason = | 'unsupported-platform' | 'unsupported-architecture' @@ -41,6 +54,7 @@ export type EmbeddedMpvFrameCopyRuntimeResult = | { usable: false; reason: EmbeddedMpvFrameCopyRuntimeFailureReason; + helperReason?: EmbeddedMpvHelperRuntimeProbeFailureReason; }; export interface EmbeddedMpvFrameCopyRuntimeFileSystem { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts index 9b2ef763e..6e2b89c72 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts @@ -86,10 +86,11 @@ describe('embedded MPV runtime diagnostic', () => { expect(harness.continueStartup).not.toHaveBeenCalled(); }); - it('prints the unavailable reason as one JSON line, exits nonzero, and skips startup', () => { + it('prints the unavailable helper reason as one JSON line, exits nonzero, and skips startup', () => { const availability: FrameCopyRuntimeAvailability = { usable: false, reason: 'helper-probe-failed', + helperReason: 'gl-context-create-failed', }; const harness = createHarness(availability); @@ -100,7 +101,7 @@ describe('embedded MPV runtime diagnostic', () => { expect(harness.getRuntimeAvailability).toHaveBeenCalledTimes(1); expect(harness.writeStdout).toHaveBeenCalledWith( - '{"usable":false,"reason":"helper-probe-failed"}\n' + '{"usable":false,"reason":"helper-probe-failed","helperReason":"gl-context-create-failed"}\n' ); expect(harness.writeStdout).toHaveBeenCalledTimes(1); expect(harness.exit).toHaveBeenCalledWith(1); diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index c7b243dab..cbae9215c 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -275,7 +275,11 @@ named `/impv-fc-runtime-probe-`. It never opens media or enters the media or command loops. Shared-memory creation/mapping and header-initialization failures emit the stable helper reasons `shared-memory-create-failed` and `shared-memory-initialize-failed`. The probe must emit exactly one protocol-v1 -JSON line and return zero. +JSON line and return zero. When the helper exits nonzero with an otherwise +exact failure line, the application availability diagnostic keeps the +fail-closed top-level reason `helper-probe-failed` and may add only the +allowlisted helper reason as `helperReason`; malformed, multi-line, +wrong-protocol, or unknown failure output never reaches that field. The startup probe and every playback helper session use the same sanitized loader environment selected by the validated manifest's cached `runtimeMode`. diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index d884cf67a..57d245778 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -232,6 +232,9 @@ shared-memory ring named `/impv-fc-runtime-probe-`. It does not open media or enter media/command loops. A timeout, loader failure, malformed protocol, missing file, hash mismatch, unusable graphics path, or shm lifecycle failure returns a stable reason and keeps the BrowserWindow sandbox enabled. The +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. 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 diff --git a/tools/packaging/configure-linux-frame-copy-build.test.mjs b/tools/packaging/configure-linux-frame-copy-build.test.mjs index 06bc7b1d0..6ddfa75b8 100644 --- a/tools/packaging/configure-linux-frame-copy-build.test.mjs +++ b/tools/packaging/configure-linux-frame-copy-build.test.mjs @@ -518,6 +518,23 @@ test('Linux CI verifies every package family and exercises intended environments assert.doesNotMatch(buildWorkflow, /\bldd\b/); }); +test('Flatpak application runtime probe runs under an isolated D-Bus session', () => { + const installStep = workflowStep('Install Linux system dependencies'); + const flatpakVerificationStep = workflowStep( + 'Verify Flatpak payload, launcher, and sandboxed runtime' + ); + + assert.match( + flatpakVerificationStep, + /xvfb-run -a dbus-run-session -- flatpak run\s+\\\s+--env=LIBGL_ALWAYS_SOFTWARE=1\s+\\\s+com\.fourgray\.iptvnator\s+\\\s+--embedded-mpv-runtime-probe/ + ); + assert.match(installStep, /^\s+dbus-daemon\s+\\$/m); + assert.match( + flatpakVerificationStep, + /xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1\s+\\\s+flatpak run --command=sh com\.fourgray\.iptvnator/ + ); +}); + test('foreign DEB CI explicitly selects both marker-only ARM architectures', () => { const foreignDebStep = workflowStep( 'Make marker-only foreign-architecture DEB packages'