fix(packaging): improve Linux runtime smoke diagnostics

This commit is contained in:
4gray committed 2026-07-18 12:40:33 +02:00
1 parent 33573ceee4
commit 4302aa4058
11 files changed
+179 -11

No files matched your search

+2 -1
View File
@@ -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
+4 -2
View File
@@ -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
+7 -4
View File
@@ -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
@@ -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';
@@ -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',
});
});
});
@@ -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<string>(
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);
}
@@ -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 {
@@ -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);
+5 -1
View File
@@ -275,7 +275,11 @@ named `/impv-fc-runtime-probe-<pid>`. 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`.
+3
View File
@@ -232,6 +232,9 @@ shared-memory ring named `/impv-fc-runtime-probe-<pid>`. 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
@@ -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'