diff --git a/changes/fix-appimage-quit-service-crash.md b/changes/fix-appimage-quit-service-crash.md new file mode 100644 index 00000000..0bddadb7 --- /dev/null +++ b/changes/fix-appimage-quit-service-crash.md @@ -0,0 +1,4 @@ +type: fixed +area: app + +- Fixed "Service Crash" desktop notifications (KDE DrKonqi) after closing a video when running the Linux AppImage: on quit, the AppImage runtime unmounted the FUSE squashfs while Chromium utility children (notably the network service) were still shutting down, killing them with SIGBUS. Background launches (`--background`, used by the mpv plugin and the launcher) now run through a small supervisor that mounts the AppImage via `--appimage-mount`, executes `AppRun` from that mount, and releases the mount only after no process is still executing from it. Set `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1` to restore the old direct launch. diff --git a/src/main-entry.ts b/src/main-entry.ts index e4c84563..5fb83305 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -20,6 +20,7 @@ import { shouldHandleStatsDaemonCommandAtEntry, } from './main-entry-runtime'; import { requestSingleInstanceLockEarly } from './main/early-single-instance'; +import { resolveAppImageMountKeepaliveInvocation } from './main/appimage-mount-keepalive'; import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config'; import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client'; import { @@ -289,11 +290,18 @@ async function runEntryProcess(): Promise { if (shouldDetachBackgroundLaunch(process.argv, process.env)) { const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1); - const child = spawn(process.execPath, childArgs, { - detached: true, - stdio: 'ignore', - env: sanitizeBackgroundEnv(process.env), - }); + const keepalive = resolveAppImageMountKeepaliveInvocation(process.env); + const child = keepalive + ? spawn(keepalive.command, [...keepalive.args, ...childArgs], { + detached: true, + stdio: 'ignore', + env: sanitizeBackgroundEnv(process.env), + }) + : spawn(process.execPath, childArgs, { + detached: true, + stdio: 'ignore', + env: sanitizeBackgroundEnv(process.env), + }); child.unref(); process.exit(0); return; diff --git a/src/main/appimage-mount-keepalive.test.ts b/src/main/appimage-mount-keepalive.test.ts new file mode 100644 index 00000000..2d86e8e4 --- /dev/null +++ b/src/main/appimage-mount-keepalive.test.ts @@ -0,0 +1,176 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { + APPIMAGE_MOUNT_KEEPALIVE_LABEL, + APPIMAGE_MOUNT_KEEPALIVE_SCRIPT, + resolveAppImageMountKeepaliveInvocation, +} from './appimage-mount-keepalive'; + +test('resolveAppImageMountKeepaliveInvocation is linux-only', () => { + const env = { APPIMAGE: '/opt/SubMiner.AppImage' }; + assert.equal(resolveAppImageMountKeepaliveInvocation(env, 'win32'), null); + assert.equal(resolveAppImageMountKeepaliveInvocation(env, 'darwin'), null); + assert.notEqual(resolveAppImageMountKeepaliveInvocation(env, 'linux'), null); +}); + +test('resolveAppImageMountKeepaliveInvocation requires APPIMAGE env', () => { + assert.equal(resolveAppImageMountKeepaliveInvocation({}, 'linux'), null); + assert.equal(resolveAppImageMountKeepaliveInvocation({ APPIMAGE: ' ' }, 'linux'), null); +}); + +test('resolveAppImageMountKeepaliveInvocation honors disable env', () => { + const env = { + APPIMAGE: '/opt/SubMiner.AppImage', + SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE: '1', + }; + assert.equal(resolveAppImageMountKeepaliveInvocation(env, 'linux'), null); +}); + +test('resolveAppImageMountKeepaliveInvocation builds sh invocation with AppImage path', () => { + const invocation = resolveAppImageMountKeepaliveInvocation( + { APPIMAGE: '/opt/SubMiner.AppImage' }, + 'linux', + ); + assert.ok(invocation); + assert.equal(invocation.command, '/bin/sh'); + assert.deepEqual(invocation.args, [ + '-c', + APPIMAGE_MOUNT_KEEPALIVE_SCRIPT, + APPIMAGE_MOUNT_KEEPALIVE_LABEL, + '/opt/SubMiner.AppImage', + ]); +}); + +function runKeepaliveScript( + appImagePath: string, + extraArgs: string[] = [], +): Promise<{ status: number }> { + return new Promise((resolve, reject) => { + execFile( + '/bin/sh', + ['-c', APPIMAGE_MOUNT_KEEPALIVE_SCRIPT, APPIMAGE_MOUNT_KEEPALIVE_LABEL, appImagePath, ...extraArgs], + { timeout: 30_000 }, + (error) => { + if (error && typeof error.code !== 'number') { + reject(error); + return; + } + resolve({ status: typeof error?.code === 'number' ? error.code : 0 }); + }, + ); + }); +} + +function writeExecutable(filePath: string, content: string): void { + fs.writeFileSync(filePath, content, { mode: 0o755 }); +} + +test( + 'keepalive script releases the mount only after straggler processes exit', + { skip: process.platform !== 'linux' }, + async () => { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-')); + const resultsDir = path.join(workDir, 'results'); + fs.mkdirSync(resultsDir); + const mountDir = path.join(workDir, 'fake-mount'); + fs.mkdirSync(mountDir); + + // AppRun leaves behind a straggler that keeps executing *from the mount* + // after AppRun itself exits — mimicking Chromium utility children. + fs.copyFileSync('/usr/bin/sleep', path.join(mountDir, 'straggler')); + fs.chmodSync(path.join(mountDir, 'straggler'), 0o755); + writeExecutable( + path.join(mountDir, 'AppRun'), + [ + '#!/bin/sh', + `"${mountDir}/straggler" 1 &`, + `date +%s%N > "${resultsDir}/apprun-exited"`, + 'exit 42', + ].join('\n'), + ); + + const fakeAppImage = path.join(workDir, 'Fake.AppImage'); + writeExecutable( + fakeAppImage, + [ + '#!/bin/sh', + 'if [ "${1:-}" = "--appimage-mount" ]; then', + ` echo "${mountDir}"`, + ` trap 'date +%s%N > "${resultsDir}/holder-released"; exit 0' TERM INT`, + ' while :; do sleep 0.05; done', + 'fi', + `date +%s%N > "${resultsDir}/direct-run"`, + 'exit 0', + ].join('\n'), + ); + + try { + const { status } = await runKeepaliveScript(fakeAppImage); + + assert.equal(status, 42, 'exit code of AppRun must be propagated'); + assert.ok( + !fs.existsSync(path.join(resultsDir, 'direct-run')), + 'must not fall back to direct AppImage run when mount succeeds', + ); + // The script does not wait for the holder to finish handling SIGTERM + // (the real runtime unmounts on its own after the signal), so poll. + const releasedMarker = path.join(resultsDir, 'holder-released'); + const pollDeadline = Date.now() + 2000; + while (!fs.existsSync(releasedMarker) && Date.now() < pollDeadline) { + await new Promise((r) => setTimeout(r, 25)); + } + assert.ok(fs.existsSync(releasedMarker), 'holder must be released'); + + const appRunExited = Number( + fs.readFileSync(path.join(resultsDir, 'apprun-exited'), 'utf8').trim(), + ); + const holderReleased = Number(fs.readFileSync(releasedMarker, 'utf8').trim()); + const drainNs = holderReleased - appRunExited; + assert.ok( + drainNs >= 0.8e9, + `holder must outlive the 1s straggler (drained after ${drainNs / 1e9}s)`, + ); + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } + }, +); + +test( + 'keepalive script falls back to direct run when --appimage-mount fails', + { skip: process.platform !== 'linux' }, + async () => { + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-keepalive-test-')); + const resultsDir = path.join(workDir, 'results'); + fs.mkdirSync(resultsDir); + + const fakeAppImage = path.join(workDir, 'Fake.AppImage'); + writeExecutable( + fakeAppImage, + [ + '#!/bin/sh', + 'if [ "${1:-}" = "--appimage-mount" ]; then', + ' exit 1', + 'fi', + `printf '%s\\n' "$@" > "${resultsDir}/direct-run"`, + 'exit 7', + ].join('\n'), + ); + + try { + const { status } = await runKeepaliveScript(fakeAppImage, ['--start', '--background']); + assert.equal(status, 7, 'direct-run exit code must be propagated'); + assert.equal( + fs.readFileSync(path.join(resultsDir, 'direct-run'), 'utf8'), + '--start\n--background\n', + 'launch args must be forwarded to the direct run', + ); + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } + }, +); diff --git a/src/main/appimage-mount-keepalive.ts b/src/main/appimage-mount-keepalive.ts new file mode 100644 index 00000000..3cc3aa2b --- /dev/null +++ b/src/main/appimage-mount-keepalive.ts @@ -0,0 +1,75 @@ +// Background AppImage launches must not execute directly from a FUSE mount whose +// lifetime is owned by a short-lived helper's AppImage runtime: when the app later +// quits, the runtime unmounts the squashfs while Chromium utility children (network +// service et al.) are still mid-shutdown, and they die with SIGBUS on their mmapped +// executable — surfacing as "Service Crash" desktop notifications on every quit. +// +// Fix: detach a tiny POSIX-sh supervisor instead of the raw process. It mounts the +// AppImage via `--appimage-mount` (holder process keeps the mount alive), runs +// AppRun from that mount, and after the app exits waits until no process is still +// executing from the mount before releasing the holder. + +export interface AppImageMountKeepaliveInvocation { + command: string; + args: string[]; +} + +const DISABLE_ENV = 'SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE'; + +// $0 is set to this label so the supervisor is identifiable in `ps` output. +export const APPIMAGE_MOUNT_KEEPALIVE_LABEL = 'subminer-appimage-keepalive'; + +// POSIX sh only; every failure path falls back to executing the AppImage directly, +// which is exactly the pre-wrapper behavior. +export const APPIMAGE_MOUNT_KEEPALIVE_SCRIPT = ` +set -u +appimage=$1 +shift +run_direct() { + exec "$appimage" "$@" +} +fifo=$(mktemp -u "\${TMPDIR:-/tmp}/subminer-appimage-mount-XXXXXX") || run_direct "$@" +mkfifo "$fifo" || run_direct "$@" +"$appimage" --appimage-mount >"$fifo" 2>/dev/null & +holder=$! +mount_point="" +IFS= read -r mount_point <"$fifo" || true +rm -f "$fifo" +app_run="" +if [ -n "$mount_point" ] && [ -x "$mount_point/AppRun" ]; then + app_run="$mount_point/AppRun" +fi +if [ -z "$app_run" ]; then + kill "$holder" 2>/dev/null + run_direct "$@" +fi +APPDIR="$mount_point" "$app_run" "$@" +rc=$? +# Do not release the mount while any process still executes from it; releasing +# early SIGBUSes Chromium children that are mid-shutdown. +tries=0 +while [ "$tries" -lt 100 ]; do + if readlink /proc/[0-9]*/exe 2>/dev/null | grep -qF "$mount_point/"; then + sleep 0.1 + tries=$((tries + 1)) + else + break + fi +done +kill "$holder" 2>/dev/null +exit "$rc" +`; + +export function resolveAppImageMountKeepaliveInvocation( + env: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, +): AppImageMountKeepaliveInvocation | null { + if (platform !== 'linux') return null; + if (env[DISABLE_ENV] === '1') return null; + const appImagePath = env.APPIMAGE?.trim(); + if (!appImagePath) return null; + return { + command: '/bin/sh', + args: ['-c', APPIMAGE_MOUNT_KEEPALIVE_SCRIPT, APPIMAGE_MOUNT_KEEPALIVE_LABEL, appImagePath], + }; +}