Skip to content

fix(desktop): make AppImage restarts come back on Linux - #4918

Open
GenKerensky wants to merge 1 commit into
pingdotgg:mainfrom
GenKerensky:fix/appimage-relaunch-linux
Open

fix(desktop): make AppImage restarts come back on Linux#4918
GenKerensky wants to merge 1 commit into
pingdotgg:mainfrom
GenKerensky:fix/appimage-relaunch-linux

Conversation

@GenKerensky

@GenKerensky GenKerensky commented Jul 30, 2026

Copy link
Copy Markdown

What Changed

On Linux AppImage builds, restarting the app now actually brings it back.

app.relaunch() re-execs process.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 $APPIMAGE instead, anchored at cwd: / so the helper is not itself holding the mount open. Non-AppImage packaging is untouched and still uses app.relaunch().

Three supporting changes, each needed to make that safe:

  • The spawn is awaited. spawn reports ENOENT/EAGAIN asynchronously, so without waiting, a helper that never started would fall through to exit(0) — an app that quits and never returns, with nothing logged. Failure now logs and exits non-zero instead.
  • The single-instance lock is released only once the relaunch is committed. A failed relaunch leaves the app running with the lock, rather than letting the next launch open a second instance. This adds releaseSingleInstanceLock to the ElectronApp service.
  • The helper closes inherited file descriptors before sleeping. See below.

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_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, 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/sh on Debian and Ubuntu — parses exec 10>&- as a command named 10, and a failed exec terminates a non-interactive shell immediately. Emitting it for /bin/sh would kill the helper before the re-exec and lose the app permanently. Verified in debian:stable-slim: sh: 1: exec: 10: not found, and the shell exits before || true can run. /bin/sh still relaunches correctly, just without the fd cleanup.

Two smaller decisions worth flagging for review:

  • sleep takes whole seconds only. POSIX specifies an integer, and a /bin/sh whose sleep rejects 1.0 would short-circuit && and never exec — the exact failure this helper exists to prevent, and one nothing would report.
  • The relaunch log is appended after a guarded exec rather than wrapping the group in 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 scheduleAppImageRelaunch resolves 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:

  • Every relaunch logged kind: appimage-delayed re-execing $APPIMAGE, and the app came back each time with zero "Cannot mount AppImage" errors.
  • After the fd fix: one mount and one runtime process across both cycles, confirmed via /proc/*/mountinfo and the runtime's stack.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes — no UI changes in this PR
  • I included a video for animation/interaction changes — not applicable

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. When APPIMAGE is set, DesktopLifecycle schedules a detached, delayed re-exec of the outer .AppImage via a shell helper (cwd: /), with only -- flag argv preserved. Other packaging still uses Electron relaunch with resolved execPath and args.

The helper can close inherited Chromium fds (bash only) before sleeping so the old mount can unmount, and append relaunch failures to relaunch.log without 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. releaseSingleInstanceLock is added to the ElectronApp service 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

  • On Linux with APPIMAGE set, DesktopLifecycle now resolves an appimage-delayed relaunch plan and delegates to scheduleAppImageRelaunch, which spawns a detached shell script that sleeps, then re-execs the AppImage binary.
  • resolveDesktopRelaunchPlan selects between the AppImage path and the standard electron.relaunch path based on the APPIMAGE env var, strips non-flag argv entries, and applies a default delay.
  • ElectronApp gains a releaseSingleInstanceLock effect, which is now called before exit(0) on successful relaunch and before exit(1) on failure, so the next launch isn't blocked by the single-instance lock.
  • Relaunch details (kind, execPath, arg count, delay) are logged as structured events.
  • Behavioral Change: relaunch failures now release the lock and exit with code 1 instead of leaving the process in an indeterminate state.
📊 Macroscope summarized a7cdcfa. 4 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

`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
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b9de3676-cea3-4e41-8af2-ea752e35eb74

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Jul 30, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment on lines +38 to +39
const appImagePath = input.appImagePath?.trim();
if (appImagePath) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

@macroscopeapp

macroscopeapp Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant