Skip to content

fix(desktop): honor tauri relaunch() on macOS - #2085

Open
atlasautomates wants to merge 2 commits into
CapSoftware:mainfrom
atlasautomates:fix/macos-relaunch-honors-restart-exit-code
Open

fix(desktop): honor tauri relaunch() on macOS#2085
atlasautomates wants to merge 2 commits into
CapSoftware:mainfrom
atlasautomates:fix/macos-relaunch-honors-restart-exit-code

Conversation

@atlasautomates

@atlasautomates atlasautomates commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

On macOS, relaunch() quits the app without restarting it. The onboarding "Restart Required" prompt ("Restart, I've granted permission") and any other caller of the process plugin's restart therefore terminate Cap and never bring it back — on the onboarding path the user is left with a closed app and the permission they just granted never re-evaluated.

Reproduced on 0.5.7 (macOS 26.4.1, M1 Max) including the official signed build; the code path is unchanged on main.

Cause

tauri restarts by exiting with RESTART_EXIT_CODE and respawning after the event loop unwinds (tauri-2.8.5/src/app.rs — the run-event callback fires first, then cleanup_before_exit(), then the restart_on_exitprocess::restart() check).

Every macOS exit path in cap-desktop funnels into force_exit()'s _exit():

  • RunEvent::Exitforce_exit(0)
  • finalize_app_exit()AppExitAction::Process(code)force_exit(code)
  • spawn_exit_watchdog()force_exit(0)

_exit() never returns, so the loop never unwinds and the respawn is unreachable. Log signature is an exit with code 2147483647 (i32::MAX) and no subsequent launch.

The hard exit is deliberate — it is what bounds shutdown — so this honors the restart intent at that choke point instead of removing it.

Changes

  • note_exit_requested_code() records intent when ExitRequested carries RESTART_EXIT_CODE; force_exit() acts on it. One choke point covers RunEvent::Exit, finalize_app_exit, the SIGTERM handler, applicationShouldTerminate, and the watchdog. swap() keeps it exactly-once if the watchdog races the main path.
  • Respawn uses a detached sleep 0.7; open <bundle>. open goes through LaunchServices so the new instance gets its own TCC identity by code signature — which matters here, because the onboarding restart exists precisely to re-evaluate screen-recording permission. The delay lets the old process die first so tauri-plugin-single-instance never meets a live listener (its macOS impl unlinks the socket on RunEvent::Exit, and a stale socket yields ECONNREFUSED → the new instance claims singleton, so this is fail-open either way).
  • Outside a .app bundle (dev runs) the executable is spawned directly: open(1) hands a bare Mach-O to Terminal, which would both fail to relaunch properly and re-attribute TCC to Terminal. relaunch_target() is extracted into exit_shutdown.rs so that derivation is unit-testable.
  • Marks the crash sentinel clean on the restart path. tauri exempts RESTART_EXIT_CODE from prevent_exit, so the runtime exits before the async cleanup that normally disarms the sentinel completes — without this, every relaunch is reported as an unexpected termination on the next launch.

Tests

  • relaunch_target_tests — bundle-path derivation across /Applications, paths containing spaces, /Volumes, and non-bundle/dev layouts (table-driven).
  • relaunch_intent_tests::restart_exit_code_sets_relaunch_intent — only RESTART_EXIT_CODE arms the respawn; None, Some(0) and Some(1) do not.

cargo fmt --check, cargo check -p cap-desktop, and the tests all pass against main.

Notes / open questions

  • macOS-only by #[cfg]; other platforms keep tauri's own respawn, which works there because finalize_app_exit uses app.exit(). One pre-existing gap I did not touch: on all platforms spawn_exit_watchdog hard-exits, so a restart lost to the watchdog timeout stays lost. Happy to address that here if you'd prefer.
  • Verified against vendored tauri-2.8.5 and tauri-plugin-single-instance-2.3.4 rather than assumed, including the prevent_exit carve-out for restart codes and the plugin's socket-cleanup ordering.

Greptile Summary

The PR makes macOS restart requests survive Cap’s hard-exit shutdown path while preserving clean-shutdown crash tracking.

  • Records Tauri restart intent and consumes it exactly once at force_exit.
  • Relaunches bundles through LaunchServices and development executables directly after a short delay.
  • Passes relaunch paths as discrete arguments and adds coverage for bundle layouts, hostile path characters, and restart intent.
  • Updates the desktop package lockfile version to 0.5.8.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains in the previously reported restart-intent, quoted-path, or development singleton-race paths.

Important Files Changed

Filename Overview
apps/desktop/src-tauri/src/lib.rs Integrates restart-intent tracking, clean-exit sentinel handling, and exactly-once relaunch spawning into the macOS shutdown flow; the previously reported stale-intent concern is contradicted by the restart-code exit contract.
apps/desktop/src-tauri/src/exit_shutdown.rs Adds bundle-target and argv derivation with tests showing quoted and spaced paths remain discrete arguments and development relaunches use the delayed path.
Cargo.lock Updates only the cap-desktop package version from 0.5.7 to 0.5.8.

Reviews (2): Last reviewed commit: "fix(desktop): harden the macOS relaunche..." | Re-trigger Greptile

Context used:

tauri restarts by exiting with RESTART_EXIT_CODE and respawning once the
event loop unwinds. On macOS every exit path funnels into force_exit()'s
hard _exit(), so the loop never unwinds and the respawn never runs: the
onboarding "Restart Required" prompt and the updater's restart both quit
without coming back (observed as exit code 2147483647 with no subsequent
launch).

Record the intent when ExitRequested carries RESTART_EXIT_CODE and honor it
at the force_exit choke point, which also covers the exit watchdog. The
respawn uses a detached `open` on the .app bundle so LaunchServices gives the
new instance its own TCC identity by code signature — important here, since
the onboarding restart exists to re-evaluate screen-recording permission.
Outside a bundle (dev runs) the executable is spawned directly, because
open(1) hands a bare Mach-O to Terminal and would re-attribute TCC to it.

Also marks the crash sentinel clean on this path: tauri exempts restart
requests from prevent_exit, so the runtime exits before the async cleanup
that normally disarms the sentinel can finish, and every relaunch was
reported as an unexpected termination on the next launch.

Co-Authored-By: Claude <noreply@anthropic.com>
}
tauri::RunEvent::ExitRequested { code, api, .. } => {
info!(?code, "App exit requested");
note_exit_requested_code(code);

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.

P1 Prevented restart leaves state armed

When relaunch is requested during an active export, note_exit_requested_code records restart intent and marks the crash sentinel clean before handle_exit_requested prevents the exit. The running process therefore retains the restart flag, causing a later ordinary quit to relaunch Cap, while an intervening crash is no longer reported as an unexpected termination.

Knowledge Base Used: Desktop Tauri App (Rust Backend)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/lib.rs
Line: 6108

Comment:
**Prevented restart leaves state armed**

When relaunch is requested during an active export, `note_exit_requested_code` records restart intent and marks the crash sentinel clean before `handle_exit_requested` prevents the exit. The running process therefore retains the restart flag, causing a later ordinary quit to relaunch Cap, while an intervening crash is no longer reported as an unexpected termination.

**Knowledge Base Used:** [Desktop Tauri App (Rust Backend)](https://app.greptile.com/cap/-/custom-context/knowledge-base/capsoftware/cap/-/docs/desktop-tauri-app.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This scenario can't occur on the vendored runtime: in tauri 2.8.5, ExitRequestApi::prevent_exit() is a no-op when the request carries RESTART_EXIT_CODE (tauri-2.8.5/src/app.rspub fn prevent_exit(&self) { if self.code != Some(RESTART_EXIT_CODE) { … } }). So an exit requested with the restart code cannot be prevented — not during an active export, not by any caller. Once note_exit_requested_code observes the restart code, the process is unconditionally on its way to force_exit, which consumes the flag via swap; the intent and the sentinel state armed here are always consumed by the exit they precede, never left behind in a live process.

Arming only after the prevent decision would introduce the opposite bug: a relaunch requested mid-export still exits (tauri ignores the prevention), but would then neither respawn nor mark the sentinel — a lost restart plus a phantom crash report.

I've extended the comment at the arming site to state this invariant explicitly, with the tauri source reference.

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.

That's correct — I missed that prevent_exit is a no-op for RESTART_EXIT_CODE in the vendored runtime. Your reasoning holds: once note_exit_requested_code observes the restart code, the process is unconditionally exiting, force_exit consumes the flag via swap, and there's no live-process state left behind. The alternative ordering you describe would create exactly the inverse defect.

The added comment at the arming site spelling out this invariant with the tauri source reference is the right call. Issue 1 is not valid given the vendored runtime behavior.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Comment thread apps/desktop/src-tauri/src/lib.rs Outdated
Comment on lines +479 to +482
if path.contains('\'') {
eprintln!("cap relaunch: bundle path contains a quote; not respawning: {path}");
return;
}

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.

P1 Quoted bundle paths skip relaunch

When Cap is installed under a valid path containing an apostrophe, such as Alice's Apps/Cap.app, this branch abandons the relaunch instead of passing the path safely to the command. force_exit still terminates the current process, so restart closes Cap without launching a replacement.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/lib.rs
Line: 479-482

Comment:
**Quoted bundle paths skip relaunch**

When Cap is installed under a valid path containing an apostrophe, such as `Alice's Apps/Cap.app`, this branch abandons the relaunch instead of passing the path safely to the command. `force_exit` still terminates the current process, so restart closes Cap without launching a replacement.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The command now reaches the shell as positional arguments — /bin/sh -c '/bin/sleep 0.7; exec "$@"' cap-relaunch /usr/bin/open <bundle> — so the path is never interpolated into the script and the apostrophe bail is gone entirely. Argv construction is extracted into exit_shutdown::relaunch_argv() and unit-tested: the bundle shape (["/usr/bin/open", <bundle>]) is asserted against an apostrophe+space path arriving as a single argv element, and the non-bundle shape ([<exe>]) is pinned too. As a bonus the old bundle.display().to_string() lossiness on non-UTF8 paths is gone — OsString end to end.

Comment thread apps/desktop/src-tauri/src/lib.rs Outdated
Comment on lines +493 to +498
None => {
// Dev / non-bundle run: open(1) would route a bare Mach-O to
// Terminal and re-attribute TCC to it — spawn the executable
// directly instead, like tauri's own process::restart does.
let _ = std::process::Command::new(&exe).spawn();
}

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.

P1 Development relaunch races singleton listener

When relaunch runs from a macOS development or other non-bundle executable, this branch starts the replacement immediately while the old process's single-instance listener remains active. The replacement is redirected to the terminating instance and exits before the old process calls _exit, leaving Cap stopped instead of relaunched.

Knowledge Base Used: Desktop Tauri App (Rust Backend)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/lib.rs
Line: 493-498

Comment:
**Development relaunch races singleton listener**

When relaunch runs from a macOS development or other non-bundle executable, this branch starts the replacement immediately while the old process's single-instance listener remains active. The replacement is redirected to the terminating instance and exits before the old process calls `_exit`, leaving Cap stopped instead of relaunched.

**Knowledge Base Used:** [Desktop Tauri App (Rust Backend)](https://app.greptile.com/cap/-/custom-context/knowledge-base/capsoftware/cap/-/docs/desktop-tauri-app.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. Both paths now go through the same detached delayed spawn — the dev/non-bundle path gets the identical /bin/sleep 0.7; exec "$@" treatment (still exec'ing the executable directly rather than handing a bare Mach-O to open(1), for the TCC-attribution reason in the comment) — so the replacement never starts while the old instance's single-instance listener is still up.

@richiemcilroy

Copy link
Copy Markdown
Member

hey can you pls address the issues and get this to a 5/5?

- Pass the relaunch command to /bin/sh as positional arguments ("$@")
  instead of interpolating the bundle path into the script: paths with
  apostrophes, spaces, or non-UTF8 bytes now relaunch instead of being
  abandoned, and the quote bail-out is gone.
- Give the dev/non-bundle path the same delayed detached spawn as the
  bundle path, so the replacement never races the old instance's live
  single-instance listener.
- Extract relaunch_argv() and the RELAUNCH_SH script into exit_shutdown
  and pin both argv shapes plus the no-interpolation property with a
  behavioral test that runs the real /bin/sh (the doubled space in the
  hostile path is what makes an unquoted $@ observable).
- /bin/sleep by absolute path; report relauncher spawn failure on
  stderr; allow(dead_code) off-macOS for the clippy -D warnings matrix.
- Cargo.lock: cap-desktop 0.5.7 -> 0.5.8, aligning with Cargo.toml at
  the PR base (upstream main's lock already has 0.5.8); required for
  the --locked CI jobs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGzjw5CMyzkgwdARCQnaP8
@atlasautomates

Copy link
Copy Markdown
Contributor Author

@greptileai review

All three findings addressed in 6ee0bec — issues 2 and 3 fixed (positional-arg spawn, unified delayed relaunch on the dev path, both pinned by tests that run the real /bin/sh), issue 1 rebutted in the thread: tauri 2.8.5's prevent_exit() is a no-op for RESTART_EXIT_CODE, so the armed state cannot outlive the exit that set it. Also fixed in passing: the Cargo.lock line the --locked CI jobs need (cap-desktop 0.5.7 → 0.5.8, matching Cargo.toml at the PR base), and off-macOS dead_code allowances so the Windows clippy job stays green.

@atlasautomates

Copy link
Copy Markdown
Contributor Author

@richiemcilroy Done — Greptile's at 5/5 now ("appears safe to merge"). Two of its findings are fixed in 6ee0bec (quote-safe relaunch spawn + the dev-path singleton race, both pinned with tests); the third was a false positive — tauri's prevent_exit() is a no-op for RESTART_EXIT_CODE, which Greptile confirmed in the thread. Lmk if this is fine

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants