From 65ac5a772878168655ddb8a577655f825f535f9a Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 29 Sep 2026 22:59:01 -0700 Subject: [PATCH] fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts (#277) --- changes/appimage-sandboxed-launch.md | 4 ++ src/main/appimage-mount-keepalive.test.ts | 66 +++++++++++++++++++---- src/main/appimage-mount-keepalive.ts | 46 ++++++++++++++++ 3 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 changes/appimage-sandboxed-launch.md diff --git a/changes/appimage-sandboxed-launch.md b/changes/appimage-sandboxed-launch.md new file mode 100644 index 00000000..2868cea6 --- /dev/null +++ b/changes/appimage-sandboxed-launch.md @@ -0,0 +1,4 @@ +type: fixed +area: linux + +- Fixed the AppImage exiting without opening a window when launched inside a sandbox that mounts the image itself, such as `firejail --appimage` (used by the AppImage catalog test). The detached background app now runs straight from the sandbox's squashfs mount instead of trying to re-mount the AppImage over FUSE. diff --git a/src/main/appimage-mount-keepalive.test.ts b/src/main/appimage-mount-keepalive.test.ts index 8fcfbdc3..44d1c192 100644 --- a/src/main/appimage-mount-keepalive.test.ts +++ b/src/main/appimage-mount-keepalive.test.ts @@ -8,18 +8,31 @@ import { APPIMAGE_MOUNT_KEEPALIVE_LABEL, APPIMAGE_MOUNT_KEEPALIVE_SCRIPT, resolveAppImageMountKeepaliveInvocation, + resolveMountFsType, } from './appimage-mount-keepalive'; +const FUSE_EXEC_PATH = '/tmp/.mount_SubMinAb12Cd/SubMiner'; +const MOUNT_INFO = [ + '1 0 0:1 / / rw,relatime - btrfs /dev/nvme0n1p2 rw', + '773 75 0:95 / /tmp/.mount_SubMinAb12Cd ro,nosuid shared:809 - fuse.SubMiner.AppImage SubMiner.AppImage ro', + '1238 1154 7:5 / /run/firejail/appimage ro,nosuid - squashfs /dev/loop5 ro', + '900 75 0:99 / /mnt/with\\040space rw - ext4 /dev/sdb1 rw', +].join('\n'); + +function resolveOnFuse(env: NodeJS.ProcessEnv, platform: NodeJS.Platform) { + return resolveAppImageMountKeepaliveInvocation(env, platform, FUSE_EXEC_PATH, () => MOUNT_INFO); +} + 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); + assert.equal(resolveOnFuse(env, 'win32'), null); + assert.equal(resolveOnFuse(env, 'darwin'), null); + assert.notEqual(resolveOnFuse(env, 'linux'), null); }); test('resolveAppImageMountKeepaliveInvocation requires APPIMAGE env', () => { - assert.equal(resolveAppImageMountKeepaliveInvocation({}, 'linux'), null); - assert.equal(resolveAppImageMountKeepaliveInvocation({ APPIMAGE: ' ' }, 'linux'), null); + assert.equal(resolveOnFuse({}, 'linux'), null); + assert.equal(resolveOnFuse({ APPIMAGE: ' ' }, 'linux'), null); }); test('resolveAppImageMountKeepaliveInvocation honors disable env', () => { @@ -27,14 +40,47 @@ test('resolveAppImageMountKeepaliveInvocation honors disable env', () => { APPIMAGE: '/opt/SubMiner.AppImage', SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE: '1', }; - assert.equal(resolveAppImageMountKeepaliveInvocation(env, 'linux'), null); + assert.equal(resolveOnFuse(env, 'linux'), null); +}); + +test('resolveAppImageMountKeepaliveInvocation skips kernel squashfs mounts owned by a sandbox', () => { + const env = { APPIMAGE: '/tmp/SubMiner.AppImage' }; + assert.equal( + resolveAppImageMountKeepaliveInvocation( + env, + 'linux', + '/run/firejail/appimage/SubMiner', + () => MOUNT_INFO, + ), + null, + ); + assert.notEqual( + resolveAppImageMountKeepaliveInvocation( + env, + 'linux', + '/tmp/appimage_extracted_42c4346b/SubMiner', + () => MOUNT_INFO, + ), + null, + 'extract-and-run directories are deleted with the bootstrap, so they keep the supervisor', + ); + assert.notEqual( + resolveAppImageMountKeepaliveInvocation(env, 'linux', FUSE_EXEC_PATH, () => null), + null, + 'unknown mount type keeps the supervisor', + ); +}); + +test('resolveMountFsType picks the longest containing mount point', () => { + assert.equal(resolveMountFsType(FUSE_EXEC_PATH, MOUNT_INFO), 'fuse.SubMiner.AppImage'); + assert.equal(resolveMountFsType('/run/firejail/appimage/SubMiner', MOUNT_INFO), 'squashfs'); + assert.equal(resolveMountFsType('/mnt/with space/SubMiner', MOUNT_INFO), 'ext4'); + assert.equal(resolveMountFsType('/tmp/.mount_SubMinAb12CdX/SubMiner', MOUNT_INFO), 'btrfs'); + assert.equal(resolveMountFsType('/usr/bin/sh', ''), null); }); test('resolveAppImageMountKeepaliveInvocation builds sh invocation with AppImage path', () => { - const invocation = resolveAppImageMountKeepaliveInvocation( - { APPIMAGE: '/opt/SubMiner.AppImage' }, - 'linux', - ); + const invocation = resolveOnFuse({ APPIMAGE: '/opt/SubMiner.AppImage' }, 'linux'); assert.ok(invocation); assert.equal(invocation.command, '/bin/sh'); assert.deepEqual(invocation.args, [ diff --git a/src/main/appimage-mount-keepalive.ts b/src/main/appimage-mount-keepalive.ts index 3cc3aa2b..02f76bc8 100644 --- a/src/main/appimage-mount-keepalive.ts +++ b/src/main/appimage-mount-keepalive.ts @@ -8,6 +8,15 @@ // 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. +// +// A kernel squashfs mount is the exception: whoever mounted it owns its lifetime, +// not the runtime. `firejail --appimage` (used by the AppImage catalog test) +// loop-mounts the image for the sandbox's lifetime and sets NoNewPrivs, so FUSE +// cannot mount there and the supervisor could never start the detached app. +// Extract-and-run directories are NOT exempt: the runtime deletes them when the +// bootstrap exits, so those still need the supervisor's re-run. + +import fs from 'node:fs'; export interface AppImageMountKeepaliveInvocation { command: string; @@ -60,14 +69,51 @@ kill "$holder" 2>/dev/null exit "$rc" `; +function unescapeMountInfoPath(value: string): string { + return value.replace(/\\([0-7]{3})/g, (_, octal: string) => + String.fromCharCode(parseInt(octal, 8)), + ); +} + +// Filesystem type of the mount containing `filePath`, from /proc//mountinfo +// text. Null when no mount matches. +export function resolveMountFsType(filePath: string, mountInfo: string): string | null { + let best: { mountPoint: string; fsType: string } | null = null; + for (const line of mountInfo.split('\n')) { + const [mountFields, fsFields] = line.split(' - '); + const mountPointField = mountFields?.split(' ')[4]; + const fsType = fsFields?.split(' ')[0]; + if (!mountPointField || !fsType) continue; + const mountPoint = unescapeMountInfoPath(mountPointField); + const contains = + mountPoint === '/' || filePath === mountPoint || filePath.startsWith(`${mountPoint}/`); + if (contains && (!best || mountPoint.length >= best.mountPoint.length)) { + best = { mountPoint, fsType }; + } + } + return best?.fsType ?? null; +} + +function readSelfMountInfo(): string | null { + try { + return fs.readFileSync('/proc/self/mountinfo', 'utf8'); + } catch { + return null; + } +} + export function resolveAppImageMountKeepaliveInvocation( env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform, + execPath: string = process.execPath, + readMountInfo: () => string | null = readSelfMountInfo, ): AppImageMountKeepaliveInvocation | null { if (platform !== 'linux') return null; if (env[DISABLE_ENV] === '1') return null; const appImagePath = env.APPIMAGE?.trim(); if (!appImagePath) return null; + const mountInfo = readMountInfo(); + if (mountInfo !== null && resolveMountFsType(execPath, mountInfo) === 'squashfs') return null; return { command: '/bin/sh', args: ['-c', APPIMAGE_MOUNT_KEEPALIVE_SCRIPT, APPIMAGE_MOUNT_KEEPALIVE_LABEL, appImagePath],