mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-27 04:49:49 -07:00
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:
@@ -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.
|
||||
+13
-5
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user