fix(desktop): honor tauri relaunch() on macOS - #2085
Conversation
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); |
There was a problem hiding this 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)
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.There was a problem hiding this comment.
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.rs — pub 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.
There was a problem hiding this comment.
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.
| if path.contains('\'') { | ||
| eprintln!("cap relaunch: bundle path contains a quote; not respawning: {path}"); | ||
| return; | ||
| } |
There was a problem hiding this 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.
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.There was a problem hiding this comment.
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.
| 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(); | ||
| } |
There was a problem hiding this 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)
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.There was a problem hiding this comment.
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.
|
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
|
@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. |
|
@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 |
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_CODEand respawning after the event loop unwinds (tauri-2.8.5/src/app.rs— the run-event callback fires first, thencleanup_before_exit(), then therestart_on_exit→process::restart()check).Every macOS exit path in
cap-desktopfunnels intoforce_exit()'s_exit():RunEvent::Exit→force_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 code2147483647(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 whenExitRequestedcarriesRESTART_EXIT_CODE;force_exit()acts on it. One choke point coversRunEvent::Exit,finalize_app_exit, the SIGTERM handler,applicationShouldTerminate, and the watchdog.swap()keeps it exactly-once if the watchdog races the main path.sleep 0.7; open <bundle>.opengoes 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 sotauri-plugin-single-instancenever meets a live listener (its macOS impl unlinks the socket onRunEvent::Exit, and a stale socket yieldsECONNREFUSED→ the new instance claims singleton, so this is fail-open either way)..appbundle (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 intoexit_shutdown.rsso that derivation is unit-testable.RESTART_EXIT_CODEfromprevent_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— onlyRESTART_EXIT_CODEarms the respawn;None,Some(0)andSome(1)do not.cargo fmt --check,cargo check -p cap-desktop, and the tests all pass againstmain.Notes / open questions
#[cfg]; other platforms keep tauri's own respawn, which works there becausefinalize_app_exitusesapp.exit(). One pre-existing gap I did not touch: on all platformsspawn_exit_watchdoghard-exits, so a restart lost to the watchdog timeout stays lost. Happy to address that here if you'd prefer.tauri-2.8.5andtauri-plugin-single-instance-2.3.4rather than assumed, including theprevent_exitcarve-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.
force_exit.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
Reviews (2): Last reviewed commit: "fix(desktop): harden the macOS relaunche..." | Re-trigger Greptile
Context used: