fix(desktop): make AppImage restarts come back on Linux - #4918
fix(desktop): make AppImage restarts come back on Linux#4918GenKerensky wants to merge 1 commit into
Conversation
`app.relaunch()` re-execs `process.execPath`, which under AppImage points inside the FUSE mount that is unmounted as the process exits. Every restart therefore died with "Cannot mount AppImage, please check your FUSE setup." — the app quit and never returned, with nothing on screen to say why. The AppImage path now schedules a detached, delayed re-exec of `$APPIMAGE` after the current process exits, anchored at `cwd: /` so the helper is not itself holding the mount. The spawn is awaited, so a failure to even start the helper is logged instead of falling through to `exit(0)` and vanishing. The single-instance lock is released only once the relaunch is committed, so a failure leaves the app running *with* the lock rather than letting the next launch open a second instance. Verified against a real packaged AppImage (genuine FUSE mount) across two relaunch cycles: every relaunch logged `kind: appimage-delayed` and the app came back, with zero mount errors. That testing surfaced a second bug, fixed here too. The restart worked, but each cycle stranded the previous mount with its runtime parked in `fuse_dev_do_read` forever. Chromium keeps fds open without CLOEXEC and `spawn` cannot close them, so the detached helper inherited nine of them — `app.asar`, `icudtl.dat`, the `.pak` files, the mount directory — pinning a mount nothing needed. The helper now closes every fd above 2 before sleeping. After the fix: one mount and one runtime process across both cycles. The fd cleanup is emitted only under bash. dash — `/bin/sh` on Debian and Ubuntu — parses `exec 10>&-` as a command named `10`, and a failed `exec` terminates a non-interactive shell on the spot, which would kill the helper before the re-exec and lose the app permanently. Verified in `debian:stable-slim`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UZ1f7q6ZVbn6uqWgWRzh9
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
One convention finding: a dead re-export shim added to DesktopLifecycle.ts for a helper that lives in its own canonical module. Everything else (the new ElectronApp.releaseSingleInstanceLock member and its layer/test updates, namespace effect/* imports, error construction at the failure boundary with a real cause, and the behavior-focused relaunch tests) matches the service conventions.
Posted via Macroscope — Effect Service Conventions
| } | ||
| } | ||
|
|
||
| export { resolveDesktopRelaunchPlan } from "./resolveDesktopRelaunchOptions.ts"; |
There was a problem hiding this comment.
This re-export has no consumers (DesktopLifecycle.ts itself and DesktopLifecycle.relaunch.test.ts both import from ./resolveDesktopRelaunchOptions.ts), so it only adds a second import path for the helper and interrupts the canonical module order (imports, errors, Context.Service tag, make, layer). Consider dropping it and letting consumers import the helper from its own module.
-export { resolveDesktopRelaunchPlan } from "./resolveDesktopRelaunchOptions.ts";
-Posted via Macroscope — Effect Service Conventions
| const appImagePath = input.appImagePath?.trim(); | ||
| if (appImagePath) { |
There was a problem hiding this comment.
🟠 High app/resolveDesktopRelaunchOptions.ts:38
resolveDesktopRelaunchPlan trims appImagePath and uses the trimmed value as the relaunch executable path. An AppImage whose real path contains leading or trailing whitespace is relaunched at a different, nonexistent path, so the current app exits but never comes back. Trim only to test whether the value is blank and preserve the original path for execution.
- const appImagePath = input.appImagePath?.trim();
- if (appImagePath) {
+ const trimmedAppImagePath = input.appImagePath?.trim();
+ if (trimmedAppImagePath) {
+ const appImagePath = input.appImagePath!;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/app/resolveDesktopRelaunchOptions.ts around lines 38-39:
`resolveDesktopRelaunchPlan` trims `appImagePath` and uses the trimmed value as the relaunch executable path. An AppImage whose real path contains leading or trailing whitespace is relaunched at a different, nonexistent path, so the current app exits but never comes back. Trim only to test whether the value is blank and preserve the original path for execution.
ApprovabilityVerdict: Needs human review 1 blocking correctness issue found. This PR introduces a new subprocess-based relaunch mechanism for Linux AppImage with shell command construction, process detachment, and lifecycle coordination — significant new runtime behavior beyond a simple fix. An unresolved high-severity comment also flags a potential bug in path handling that could prevent restarts. You can customize Macroscope's approvability policy. Learn more. |
What Changed
On Linux AppImage builds, restarting the app now actually brings it back.
app.relaunch()re-execsprocess.execPath, which under AppImage points inside the FUSE mount that gets unmounted as the process exits. The AppImage path now schedules a detached, delayed re-exec of$APPIMAGEinstead, anchored atcwd: /so the helper is not itself holding the mount open. Non-AppImage packaging is untouched and still usesapp.relaunch().Three supporting changes, each needed to make that safe:
spawnreports ENOENT/EAGAIN asynchronously, so without waiting, a helper that never started would fall through toexit(0)— an app that quits and never returns, with nothing logged. Failure now logs and exits non-zero instead.releaseSingleInstanceLockto theElectronAppservice.Why
Two distinct bugs, both ending with the app gone.
The restart never came back. Re-execing a path inside the outgoing FUSE mount fails with "Cannot mount AppImage, please check your FUSE setup." — the app quit and never returned, and nothing on screen said why.
Every restart stranded a mount. Found while verifying the fix on a real packaged AppImage: restarts worked, but each cycle left the previous mount behind with its runtime process parked in
fuse_dev_do_readforever. Chromium keeps fds open withoutCLOEXECandspawncannot close them, so the detached helper inherited nine of them —app.asar,icudtl.dat, the.pakfiles, and the mount directory itself at fd 1023 — pinning a mount nothing needed. The helper now closes every fd above 2 before sleeping, so the outgoing mount can release during the delay rather than after it.That cleanup is deliberately emitted only under bash. dash —
/bin/shon Debian and Ubuntu — parsesexec 10>&-as a command named10, and a failedexecterminates a non-interactive shell immediately. Emitting it for/bin/shwould kill the helper before the re-exec and lose the app permanently. Verified indebian:stable-slim:sh: 1: exec: 10: not found, and the shell exits before|| truecan run./bin/shstill relaunches correctly, just without the fd cleanup.Two smaller decisions worth flagging for review:
sleeptakes whole seconds only. POSIX specifies an integer, and a/bin/shwhosesleeprejects1.0would short-circuit&&and never exec — the exact failure this helper exists to prevent, and one nothing would report.2>>log. That redirection fails outright when the log directory is missing (bash exit 1, dash exit 2), trading a diagnosable failure for a guaranteed one — and it would also follow a successful exec, leaving the relaunched app appending Chromium stderr to an unrotated file for its whole life.Testing
Unit tests cover the plan resolution, the shell command construction (including that the fd cleanup is absent unless bash, and that the log redirect cannot block the exec), and that
scheduleAppImageRelaunchresolves only after a real spawn.Verified end-to-end on a real packaged AppImage built with
dist:desktop:linux, so a genuine FUSE mount, across two relaunch cycles:kind: appimage-delayedre-execing$APPIMAGE, and the app came back each time with zero "Cannot mount AppImage" errors./proc/*/mountinfoand the runtime's stack.Checklist
Note
Medium Risk
Changes packaged desktop exit/relaunch and single-instance behavior on Linux AppImage; mistakes could leave the app gone or strand FUSE mounts, but non-AppImage paths and dev relaunch are unchanged and coverage is strong.
Overview
Linux AppImage restarts no longer rely on
app.relaunch()re-execing paths inside the outgoing FUSE mount. WhenAPPIMAGEis set,DesktopLifecycleschedules a detached, delayed re-exec of the outer.AppImagevia a shell helper (cwd: /), with only--flag argv preserved. Other packaging still uses Electronrelaunchwith resolvedexecPathand args.The helper can close inherited Chromium fds (bash only) before sleeping so the old mount can unmount, and append relaunch failures to
relaunch.logwithout blocking a successful exec.Relaunch safety: the AppImage helper spawn is awaited before exit; on success the app releases the single-instance lock then exits 0; on failure it releases the lock and exits 1 instead of holding a dead instance.
releaseSingleInstanceLockis added to theElectronAppservice and test mocks.Reviewed by Cursor Bugbot for commit a7cdcfa. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Fix AppImage restarts on Linux by spawning a detached shell relaunch helper
APPIMAGEset,DesktopLifecyclenow resolves anappimage-delayedrelaunch plan and delegates toscheduleAppImageRelaunch, which spawns a detached shell script that sleeps, then re-execs the AppImage binary.resolveDesktopRelaunchPlanselects between the AppImage path and the standardelectron.relaunchpath based on theAPPIMAGEenv var, strips non-flag argv entries, and applies a default delay.ElectronAppgains areleaseSingleInstanceLockeffect, which is now called beforeexit(0)on successful relaunch and beforeexit(1)on failure, so the next launch isn't blocked by the single-instance lock.📊 Macroscope summarized a7cdcfa. 4 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted
🗂️ Filtered Issues
No issues evaluated.