Skip to content

fix(updater): drive download/install/restart from backend to avoid hang#4074

Merged
farion1231 merged 2 commits into
mainfrom
fix/updater-backend-driven-restart
Jun 11, 2026
Merged

fix(updater): drive download/install/restart from backend to avoid hang#4074
farion1231 merged 2 commits into
mainfrom
fix/updater-backend-driven-restart

Conversation

@farion1231

Copy link
Copy Markdown
Owner

Summary

Revives the previously-lost backend-driven update fix (af4271f4, authored 2026-06-03). It was stacked on the codex/fix-zhipu-coding-plan-presets branch; the other two commits of that stack landed on main via separate PRs (#3524, 0512ee76), but this updater fix was left behind — v3.16.2 still ships the frontend-driven flow from 2025-09.

What this fixes (beyond #4069)

#4069 fixed the ExitRequested deadlock for all app.restart() paths. This PR fixes the remaining structural problems of the update flow itself:

  • Bundle-swap window: the old flow returned to the frontend after downloadAndInstall() and relied on the old WebView to call relaunch() — i.e. running JS in a process whose .app bundle had already been replaced. The new backend command install_update_and_restart runs the whole download → install → cleanup → restart chain in Rust.
  • Windows install ordering: install() launches the external installer and exits the process internally on Windows, so cleanup + single-instance destroy must run before install; on macOS/Linux install() returns, so install runs first and an install failure no longer wrongly stops the proxy or reverts takeover.
  • Restart vs single-instance race: restart_process() destroys the single-instance lock (socket on macOS, mutex on Windows) before tauri::process::restart(), so the freshly spawned process can't connect to the old listener and exit itself.
  • Proper cleanup on the update path: proxy/live-config restore and window-state save run synchronously in the command, while the event loop is still alive — no interleaving with loop teardown.

Adaptation from the original commit

The original af4271f4 also patched the ExitRequested handler (should_restart branch with prevent_exit() + async cleanup + restart_process). That part is dropped: prevent_exit() is silently ignored for RESTART_EXIT_CODE, so that branch would race the main thread's exit and reintroduce the window-state deadlock that #4069 fixed. With #4069's classifier in place the handler needs no restart awareness, and install_update_and_restart bypasses ExitRequested entirely — the two fixes compose cleanly.

Testing

  • cargo fmt --check, cargo clippy, cargo test pass
  • pnpm typecheck passes
  • Original commit was manually verified on macOS at the time (backend-driven update + relaunch came up cleanly)

The 3.16/3.16.1 update flow was frontend-driven: downloadAndInstall()
then relaunchApp(). relaunch() routed through AppHandle::restart(), and
the old WebView had to keep running JS after the .app bundle was already
swapped — an unstable window that could hang the update or leave the old
version running until a manual restart.

Move the whole download -> install -> cleanup -> restart chain into a new
backend command install_update_and_restart, so it no longer depends on the
old WebView running JS after the bundle is swapped.

Platform-aware install ordering (install() behaves differently per OS):
- Windows: install() launches the external installer and exits the process
  internally, so cleanup + single-instance destroy must run before install.
  Surface a recovery hint on failure since the proxy may already be stopped.
- macOS/Linux: install() returns, so install first then cleanup — an install
  failure no longer wrongly stops the proxy / reverts takeover.

Eliminate the restart vs single-instance race: restart_process() destroys
the single-instance lock (remove socket on macOS, ReleaseMutex on Windows)
before tauri::process::restart(), so the freshly spawned process can't
connect to the old listener and exit itself.

Also remove the now-dead frontend update plumbing (relaunchApp,
UpdateHandle, mapUpdateHandle) and surface backend errors in the toast.

Adapted from the original af4271f4 while rebasing onto #4069: the
ExitRequested handler changes were dropped entirely — the classifier from
#4069 already routes RESTART_EXIT_CODE to Tauri's default restart flow,
and the original should_restart branch (prevent_exit + async cleanup)
would have reintroduced the window-state deadlock that #4069 fixed.
install_update_and_restart bypasses ExitRequested entirely, so the two
fixes compose cleanly.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7d0eacac33

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/src/lib.rs
restart_process re-execs via tauri::process::restart (spawn + exit(0)),
which skips Tauri's internal cleanup_before_exit and all RunEvent::Exit
plugin hooks. Window state, proxy/live restore and the single-instance
lock were already compensated explicitly; the tray icon was not.

On macOS/Linux the OS drops the status item when the process dies, so
the gap there was cosmetic at most. The real residue risk is the
Windows branch, which never reaches restart_process at all:
update.install() exits the process inside the updater plugin
(std::process::exit(0)), bypassing TrayIcon::drop — no NIM_DELETE is
sent and a stale icon lingers in the shell until hovered, the same
failure remove_tray_icon_before_exit was originally added for on the
quit path.

Call remove_tray_icon_before_exit (set_visible(false), proxied to the
main thread via run_item_main_thread) in restart_process and before
the Windows install. Deliberately not AppHandle::cleanup_before_exit():
it drops tray icons on the calling thread, which is not safe off the
main thread on macOS (NSStatusItem).
@farion1231

Copy link
Copy Markdown
Owner Author

Re the bot's P2 on restart_process: the core fact is right — tauri::process::restart skips Tauri's internal cleanup_before_exit and all RunEvent::Exit plugin hooks — but the impact assessment had the platforms backwards:

  • Window state, proxy/live restore and the single-instance lock were already compensated explicitly in this PR; the only uncompensated piece was the tray icon.
  • On macOS/Linux (the flagged platforms) the OS removes the status item when the process dies, so the gap there was cosmetic at most.
  • The Windows branch is the one with a real residue risk, and it never reaches restart_process: update.install() exits the process inside the updater plugin (std::process::exit(0), updater.rs:865), bypassing TrayIcon::drop → no NIM_DELETE → stale icon until hover — the same failure remove_tray_icon_before_exit was originally added for on the quit path.

Fixed in 7e1c85f: remove_tray_icon_before_exit (set_visible(false), proxied to the main thread via run_item_main_thread) is now called in restart_process and before the Windows install. Deliberately not AppHandle::cleanup_before_exit() — it drops tray icons on the calling thread, which is not main-thread-safe on macOS (NSStatusItem).

@farion1231

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@farion1231
farion1231 merged commit 4f3e85f into main Jun 11, 2026
2 checks passed
simonsmh pushed a commit to simonsmh/cc-switch that referenced this pull request Jun 16, 2026
…ng (farion1231#4074)

* fix(updater): drive download/install/restart from backend to avoid hang

The 3.16/3.16.1 update flow was frontend-driven: downloadAndInstall()
then relaunchApp(). relaunch() routed through AppHandle::restart(), and
the old WebView had to keep running JS after the .app bundle was already
swapped — an unstable window that could hang the update or leave the old
version running until a manual restart.

Move the whole download -> install -> cleanup -> restart chain into a new
backend command install_update_and_restart, so it no longer depends on the
old WebView running JS after the bundle is swapped.

Platform-aware install ordering (install() behaves differently per OS):
- Windows: install() launches the external installer and exits the process
  internally, so cleanup + single-instance destroy must run before install.
  Surface a recovery hint on failure since the proxy may already be stopped.
- macOS/Linux: install() returns, so install first then cleanup — an install
  failure no longer wrongly stops the proxy / reverts takeover.

Eliminate the restart vs single-instance race: restart_process() destroys
the single-instance lock (remove socket on macOS, ReleaseMutex on Windows)
before tauri::process::restart(), so the freshly spawned process can't
connect to the old listener and exit itself.

Also remove the now-dead frontend update plumbing (relaunchApp,
UpdateHandle, mapUpdateHandle) and surface backend errors in the toast.

Adapted from the original af4271f4 while rebasing onto farion1231#4069: the
ExitRequested handler changes were dropped entirely — the classifier from
farion1231#4069 already routes RESTART_EXIT_CODE to Tauri's default restart flow,
and the original should_restart branch (prevent_exit + async cleanup)
would have reintroduced the window-state deadlock that farion1231#4069 fixed.
install_update_and_restart bypasses ExitRequested entirely, so the two
fixes compose cleanly.

* fix(updater): clear tray icon on the direct-restart update path

restart_process re-execs via tauri::process::restart (spawn + exit(0)),
which skips Tauri's internal cleanup_before_exit and all RunEvent::Exit
plugin hooks. Window state, proxy/live restore and the single-instance
lock were already compensated explicitly; the tray icon was not.

On macOS/Linux the OS drops the status item when the process dies, so
the gap there was cosmetic at most. The real residue risk is the
Windows branch, which never reaches restart_process at all:
update.install() exits the process inside the updater plugin
(std::process::exit(0)), bypassing TrayIcon::drop — no NIM_DELETE is
sent and a stale icon lingers in the shell until hovered, the same
failure remove_tray_icon_before_exit was originally added for on the
quit path.

Call remove_tray_icon_before_exit (set_visible(false), proxied to the
main thread via run_item_main_thread) in restart_process and before
the Windows install. Deliberately not AppHandle::cleanup_before_exit():
it drops tray icons on the calling thread, which is not safe off the
main thread on macOS (NSStatusItem).
gfunc pushed a commit to gfunc/cc-switch that referenced this pull request Jun 19, 2026
…ng (farion1231#4074)

* fix(updater): drive download/install/restart from backend to avoid hang

The 3.16/3.16.1 update flow was frontend-driven: downloadAndInstall()
then relaunchApp(). relaunch() routed through AppHandle::restart(), and
the old WebView had to keep running JS after the .app bundle was already
swapped — an unstable window that could hang the update or leave the old
version running until a manual restart.

Move the whole download -> install -> cleanup -> restart chain into a new
backend command install_update_and_restart, so it no longer depends on the
old WebView running JS after the bundle is swapped.

Platform-aware install ordering (install() behaves differently per OS):
- Windows: install() launches the external installer and exits the process
  internally, so cleanup + single-instance destroy must run before install.
  Surface a recovery hint on failure since the proxy may already be stopped.
- macOS/Linux: install() returns, so install first then cleanup — an install
  failure no longer wrongly stops the proxy / reverts takeover.

Eliminate the restart vs single-instance race: restart_process() destroys
the single-instance lock (remove socket on macOS, ReleaseMutex on Windows)
before tauri::process::restart(), so the freshly spawned process can't
connect to the old listener and exit itself.

Also remove the now-dead frontend update plumbing (relaunchApp,
UpdateHandle, mapUpdateHandle) and surface backend errors in the toast.

Adapted from the original af4271f4 while rebasing onto farion1231#4069: the
ExitRequested handler changes were dropped entirely — the classifier from
farion1231#4069 already routes RESTART_EXIT_CODE to Tauri's default restart flow,
and the original should_restart branch (prevent_exit + async cleanup)
would have reintroduced the window-state deadlock that farion1231#4069 fixed.
install_update_and_restart bypasses ExitRequested entirely, so the two
fixes compose cleanly.

* fix(updater): clear tray icon on the direct-restart update path

restart_process re-execs via tauri::process::restart (spawn + exit(0)),
which skips Tauri's internal cleanup_before_exit and all RunEvent::Exit
plugin hooks. Window state, proxy/live restore and the single-instance
lock were already compensated explicitly; the tray icon was not.

On macOS/Linux the OS drops the status item when the process dies, so
the gap there was cosmetic at most. The real residue risk is the
Windows branch, which never reaches restart_process at all:
update.install() exits the process inside the updater plugin
(std::process::exit(0)), bypassing TrayIcon::drop — no NIM_DELETE is
sent and a stale icon lingers in the shell until hovered, the same
failure remove_tray_icon_before_exit was originally added for on the
quit path.

Call remove_tray_icon_before_exit (set_visible(false), proxied to the
main thread via run_item_main_thread) in restart_process and before
the Windows install. Deliberately not AppHandle::cleanup_before_exit():
it drops tray icons on the calling thread, which is not safe off the
main thread on macOS (NSStatusItem).
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.

1 participant