mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -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,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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user