fix(app): keep AppImage mount alive until Chromium children finish shutdown

Background AppImage launches executed directly from a FUSE mount owned by a
short-lived helper's AppImage runtime. On quit, the runtime unmounted the
squashfs while utility children (network service) were still mid-shutdown,
SIGBUSing them and triggering KDE Service Crash notifications on every video
close. Detach a POSIX-sh supervisor instead: mount via --appimage-mount, run
AppRun from the mount, and release the holder only once no process still
executes from the mount. Falls back to the old direct launch on any failure
or with SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1.
This commit is contained in:
2026-07-12 02:09:42 -07:00
parent 4b7f750919
commit 7d81342f0f
4 changed files with 268 additions and 5 deletions
+13 -5
View File
@@ -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<void> {
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;
+176
View File
@@ -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 });
}
},
);
+75
View File
@@ -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],
};
}