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
+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 });
}
},
);