Skip to content

feat(macos): prompt to move Buzz to /Applications when run from installer media - #1792

Open
matt2e wants to merge 8 commits into
mainfrom
run-from-dmg
Open

matt2e wants to merge 8 commits into
mainfrom
run-from-dmg

Conversation

@matt2e

@matt2e matt2e commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

If you open Buzz straight from the downloaded DMG (or from a Gatekeeper-translocated copy, another read-only volume, or ~/Downloads), things break later: updates can't write to the bundle, the disk image stays mounted, and state ends up on a copy that won't last. This detects those locations and offers to move Buzz into /Applications with a native prompt.

  • Detection (src-tauri/src/installer_media.rs): on macOS release builds, Buzz finds the running .app bundle and classifies where it lives: installed in /Applications or ~/Applications, disk image, translocated, read-only volume, or somewhere else. It never prompts for an installed copy, and if detection fails it skips the prompt.
  • Prompt: an app-modal NSAlert. Its wording depends on whether the move is a fresh install, an upgrade, a downgrade, a reinstall, or a replacement of an unreadable version (compared as semver from both bundles' Info.plist). The "Don't ask again" box writes a suppress-move-to-applications marker to the app data dir, whichever button is clicked.
  • Move: Buzz dittos itself to a hidden staging copy with one fixed name, /Applications/.Buzz.app.staging. A copy left there by an interrupted earlier move is removed first; if it can't be removed, the move stops rather than copying into it. Buzz then quits any running instance of the installed bundle, moves the installed copy to the Trash, renames the staged copy into place and clears quarantine. It spawns a detached shell that relaunches the installed copy after this process exits (detaching the disk image if Buzz was running from one), then tears down as Quit does (terminals, plugin and agent processes, MCP hosts, the login-shell probe) and exits. All interpolated paths are single-quote escaped. If any step fails, the current copy keeps running and the alert explains how to move it by hand. Buzz never asks for elevated privileges.
  • Scheduling fix (second commit): calling runModal() directly in the RunEvent::Ready callback deadlocked. tao holds a non-reentrant handler Mutex while it delivers that callback, and the modal's first CA flush redraws Buzz's already-visible main window, which needs the same lock. The prompt now goes through dispatch2::DispatchQueue::main().exec_async, so it runs on the next main-run-loop turn after the lock is released. run_on_main_thread wouldn't help because it goes through the same locked handler.
  • Old Buzz parity (third commit): see the next section.
  • Accept-path follow-ups (last three commits): teardown before exiting, the fixed staging name, and a 3 s quit grace for a running installed copy.
  • README: the macOS download row now mentions the prompt.

The classification, scenario, copy, path and shell-quoting helpers are pure and build on every platform, with unit tests. Only the AppKit, statfs and ditto code is macOS-only. New direct deps: semver, plist, and macOS-only dispatch2 (already in Cargo.lock transitively, pinned like the other objc2 crates); objc2-app-kit gains the NSWorkspace, NSRunningApplication and libc features.

A leftover Sprout.app is not handled.

Replacing an existing install: old Buzz vs. previous Buzz

Many machines have the old Buzz from block/buzz at /Applications/Buzz.app: bundle identifier xyz.block.buzz.app, executable buzz-desktop, CFBundleShortVersionString ≤ 0.5.28, CFBundleVersion 0.5.28-block on Block-signed builds (0.5.28 on OSS builds, X.Y.Z-test.N / X.Y.Z-intel-test.N on canaries). This app is xyz.block.buzz.desktop with executable Buzz. The flow treats both identically and cannot tell them apart:

  • Matched by bundle path only. The installed copy is /Applications/<running bundle's file name> (installed_copy_path). Nothing reads CFBundleIdentifier, CFBundleExecutable, CFBundleName, the signing identity or the data dir to locate, compare, trash or replace it. The doc comment on the lookup says this is deliberate.
  • Same Upgrade prompt and replacement. Versions are compared as semver on CFBundleShortVersionString, which both apps ship as clean X.Y.Z. CFBundleVersion is read but never parsed, so 0.5.28-block can't push the scenario to the neutral "Replace" wording; a -test.N suffix on the short string parses as a prerelease and still sorts below 0.7.x. Every version block/buzz has shipped is < 0.6.0, so an installed old Buzz always produces the same Upgrade dialog ("Upgrade Buzz in your Applications folder? … You have Buzz 0.5.28 installed. This copy is the newer version 0.7.0 …" / "Upgrade and Relaunch") that a prior 0.7.x build of this app would, and the same ditto → Trash → rename replacement. Unit tests named old_buzz_* cover 0.5.28 + 0.5.28-block, 0.5.9-block, 0.3.18, 0.5.29-test.123, 0.5.28-intel-test.45 and 0.0.1-test.3, and assert that 0.5.28 and 0.7.0 installs yield the same scenario and dialog copy. The helpers were also run against the real /Applications/Buzz.app/Contents/Info.plist (0.5.28 / 0.5.28-block, xyz.block.buzz.app) on a dev machine: Upgrade { from: "0.5.28", to: "0.7.0" }.
  • Running instance quit by path before trashing. tauri-plugin-single-instance keeps a previous build of this app from running alongside the prompt, but the old Buzz has a different identifier, keeps running after its window is closed (tray icon, self-updater that auto-downloads), and isn't covered by that guard. Before trashing, the accept path quits every NSRunningApplication whose bundleURL canonicalises to the installed bundle path (not by identifier, so it covers both apps without naming either): terminate(), up to 3 s of polling (isTerminated plus kill(pid, 0), since NSRunningApplication properties only update while the main run loop spins), then forceTerminate(). The cap is 3 s because old Buzz's own quit SIGTERMs its agents and waits up to 2 s before SIGKILL; force-quitting it sooner would orphan agents still running from the Trash. The wait ends as soon as the app exits. The outcome is logged and the move continues either way; trashing a running bundle succeeds on macOS.

How to test both cases (on a release build of this branch on a DMG, with a disposable identity):

  1. Old Buzz installed: install block/buzz desktop 0.5.28 into /Applications/Buzz.app, launch it and close its window (it keeps running in the tray). Open the new build from the DMG. Expect the Upgrade alert naming 0.5.28; accept. Expect the old process to quit, /Applications/Buzz.app to be the new build (CFBundleIdentifier xyz.block.buzz.desktop), the old bundle in the Trash, the new app relaunched, and the DMG detached. Old-Buzz data under ~/Library/Application Support/xyz.block.buzz.app is untouched.
  2. Previous new-Buzz build installed: put an earlier build of this app (e.g. a 0.7.0 DMG from a previous run) in /Applications/Buzz.app, quit it, and open a newer build from the DMG. Expect the same Upgrade alert and the same replacement sequence. With the same version installed, the Reinstall wording appears instead.

Demo

Both recordings use an ad-hoc-signed release build of this branch on a hand-made UDZO DMG, with the bundle identifier overridden so no real Buzz data was touched. They are composites of per-window stills (~5 fps) with captions burned in from the harness's event log, not raw screen recordings. The accept-path clip was recorded with the scheduling fix in this PR; the decline clip predates it and used an equivalent local patch.

The two alert variants exercised so far — fresh install (nothing in /Applications) and reinstall (same version already installed):

Left: fresh-install alert with "Move to Applications Folder" as the default button. Right: reinstall alert naming the installed version, with "Reinstall and Relaunch".

Accept path (fresh install): launch from the DMG → alert at ~1.2 s → "Move to Applications Folder" → the DMG copy exits, /Applications/Buzz.app relaunches ~0.2 s later, and the disk image is detached ~1.4 s after that.

take1-move-to-applications.mp4

Decline + suppress path (reinstall): launch again with 0.7.0 already installed → reinstall wording → tick "Don't ask again" → "Do Not Move" keeps the DMG copy running and writes the suppress-move-to-applications marker → a third launch from the DMG shows no alert.

take2-decline-and-suppress.mp4

Testing

  • Unit tests for classification, scenario selection, alert copy, shell quoting, the path-keyed installed-copy lookup and path normalisation, the old-Buzz version cases, and the fixed staging name with its fail-closed cleanup (cargo test -p Buzz installer_media: 30 passed). cargo fmt --check, cargo clippy -p Buzz --all-targets -- -D warnings and cargo build --release -p Buzz on macOS are clean.
  • Ran an ad-hoc-signed release build from a hand-made UDZO DMG, using an identifier override so real app data was untouched. Before the scheduling fix it hung at 0 % CPU with zero-size windows (2/2 runs). After the fix the alert appeared about 1.1 s after launch; pressing "Move to Applications Folder" exited the app, the copy in /Applications relaunched about 0.2 s later, and the DMG detached about 1.4 s after that. The decline and "Don't ask again" flow was also tried.
  • Accept-path follow-ups, run live with ad-hoc-signed release builds (0.7.1 over 0.7.0) on hand-made DMGs. The bundle was renamed BuzzCheck.app so nothing touched /Applications/Buzz.app:
    • Teardown: a login-shell probe held in sleep by a test .zshenv survived the pre-fix build's exit, reparented to launchd. With this branch it is gone once the DMG copy exits.
    • Single instance: with the installed copy (same identifier) running, a DMG copy exits in 0.3 s without an alert.
    • Upgrade: the 0.7.0 → 0.7.1 Upgrade alert. Accepting moves 0.7.0 to the Trash, installs and relaunches 0.7.1 (signature valid) and detaches the DMG, leaving no .staging.
    • Quit wait: a running stand-in with a different identifier, window closed, at the installed path quits politely in 0.2 s. Under SIGSTOP it is force-quit 3.1 s after the press, and the move completes either way.
    • Interrupted move: a kill -9 during that wait leaves .BuzzCheck.app.staging. The next move removes it, including a planted stale file, and the signature verifies. The pre-fix build left .BuzzCheck.app.<pid>.staging, still there after the next move.
    • Leftover that can't be removed: shows "Couldn't move Buzz…" with the error naming the path. The installed copy is untouched and the DMG copy keeps running.

Not yet tested live: quarantined downloads, App Translocation, the terminate step and the full replace flow against the real old Buzz (only a buzz-app stand-in with a different identifier), an old-Buzz agent that ignores SIGTERM, and a Developer-ID-signed bundle. No human has tested this in the app yet.

🤖 Generated with Claude Code

@matt2e
matt2e marked this pull request as ready for review October 28, 2026 10:56
@matt2e
matt2e requested a review from a team as a code owner October 28, 2026 10:56

@kalvinnchau kalvinnchau 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.

🤖 Four installation lifecycle findings. Native installation remains untested; bounded probes and macOS compilation do not cover AppKit, Trash, or relaunch behavior.

match move_to_applications_and_relaunch(&bundle_path, &volume, location) {
Ok(()) => {
eprintln!("Relaunching Buzz from /Applications");
std::process::exit(0);

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.

🤖 [P2] Run application teardown before exiting

Accepting installation calls std::process::exit(0), bypassing RunEvent::Exit -> shut_down() (lib.rs:943–962) and Rust drops. Plugin host processes use separate process groups and rely on HostProcesses::shutdown() to kill them (host_process.rs:268–279,373). If a long-lived plugin process starts before acceptance, it can outlive the exiting copy. An isolated process-group probe confirmed survival after direct parent exit and termination with explicit cleanup; a complete native Buzz reproduction remains untested. Use the normal Tauri exit path or the established shutdown-before-exit pattern in sign_out.rs:802–806.

return Err(error);
}
}
if let Err(error) = std::fs::rename(&staging, &target) {

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.

🤖 [P2] Restore the previous installation when rename fails

Once Trash succeeds, a failure renaming the staged bundle leaves /Applications/Buzz.app absent. The error path does not restore the old bundle, and move_to_trash discards its recovery URL. A source-extracted function probe with real ditto/rename and injected post-Trash permission denial reproduced the absent installed path; Trash and termination were stubbed. This requires manual recovery, not demonstrated permanent data loss. Preserve the recovery location and restore on failure, or use an atomic exchange before trashing the replaced bundle.

}
let outcome = if has_exited(&app, pid) {
"quit before replacement"
} else if app.forceTerminate() {

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.

🤖 [P2] Exclude the current process from replacement termination

If /Applications/Buzz.app is a symlink to the non-installed release bundle currently running, the source-path classification still prompts, while canonicalizing the replacement target makes it match the current application. This loop has no current-PID exclusion before termination, so it can request termination of the installer before replacement/relaunch completes. A disposable filesystem probe established the alias condition; actual AppKit termination was not exercised. Exclude the current PID and handle canonical source/target identity before replacement.

Comment thread src-tauri/src/installer_media.rs Outdated

@kalvinnchau kalvinnchau 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 additional shutdown-grace finding. The previously reported rollback and self-termination findings still apply.

Comment thread src-tauri/src/installer_media.rs Outdated
/// it inside that window would orphan those agents, still running from
/// the Trash, so the cap leaves room for it. The wait ends as soon as the
/// app exits, so the cap only costs time while it is still stopping.
const TERMINATE_GRACE: std::time::Duration = std::time::Duration::from_secs(3);

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.

🤖 [P3] Account for terminal teardown before the agent grace period

Old Buzz 0.5.28 (block/buzz at c1370cc65) shuts down terminal sessions sequentially before stopping agents (shutdown.rs:20–31; terminal_runtime.rs:324–333). Each interactive shell can consume its 250 ms TERM grace (buzz-terminal/src/lifecycle.rs:58,102–117), followed by the agents' two-second grace (shutdown.rs:187–217). With roughly four or more terminals and an agent that needs the full grace, this three-second cap can force-terminate old Buzz before it SIGKILLs its agents, leaving them running from the trashed bundle. The comment's claim that agents stop first is incorrect.

Avoid coupling forced termination to a fixed estimate of another application's teardown. For a copy that accepted terminate(), consider allowing teardown to finish while proceeding with the supported Trash/rename path; correct the ordering comment as well. Evidence is source tracing plus a local interactive-shell SIGTERM check, not an actual old-Buzz runtime reproduction.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account.

Changes are still needed:

  • P2: Restore the previous installation if the staged rename fails; the existing rollback finding remains at lines 610–612.
  • P2: Handle canonical source/target identity and exclude the current PID before termination; the existing self-termination finding remains.
  • P3: Avoid force-killing accepted graceful termination using the fixed three-second estimate. Old Buzz stops terminals serially before its agents’ two-second grace, so the shutdown finding remains.

One additional public-material finding is inline. Shutdown-before-exit and interrupted-staging cleanup are addressed in source.

Optional: if spawning the relaunch shell fails after installation, tell the user the move succeeded and to open Buzz from Applications, rather than reporting that the move failed (installer_media.rs:624).

Rocket, Mantis and Groot supplied independent lanes. Head a830b362495f600d7fff2efbf76149c51c2adc9e, base 1192cd7fd544d55d16a6c942a1df4d2db559a0db. Source-only: no tests, app execution or CI verification; native installation/focus and human acceptance remain unverified. The screenshot was inspected; two videos were not.

Comment thread src-tauri/src/installer_media.rs Outdated

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account.

Three prior P2 findings remain: rollback after a failed rename, canonical source/target identity and current-PID exclusion, and private references in public material. Their requested repairs still apply.

The fixed shutdown-grace finding is addressed: accepted termination requests are no longer force-killed after three seconds. No new production defect found in this follow-up. Update the description’s three-second wait/force-quit claims and label the old SIGSTOP/interrupted-wait results as historical; they do not validate this revision.

Rocket, Mantis and Groot completed independent lanes. Head a6a5255404e3b98d99da739708d848cf1fb1e4a5, base 1192cd7fd544d55d16a6c942a1df4d2db559a0db. Source-only: no tests, app execution or CI verification; native installation/focus and human acceptance remain unverified. Screenshot inspected; two videos uninspected.

matt2e and others added 8 commits October 29, 2026 17:23
…ller media

Users who open Buzz straight from the downloaded DMG (or from a Gatekeeper
translocated copy, another read-only volume, or ~/Downloads) hit confusing
failures later: automatic updates can't write to the bundle, the image
stays mounted, and state lands on an ephemeral copy. Detect those
locations and offer to move Buzz into /Applications with a native prompt.

On macOS release builds, once the Tauri event loop reports `Ready`,
`installer_media` resolves the running `.app` bundle, classifies its
location (installed in /Applications or ~/Applications, disk image,
translocated, read-only volume, other), and — unless a
`suppress-move-to-applications` marker exists in the app data dir — shows
an app-modal NSAlert whose wording reflects whether the move is a fresh
install, upgrade, downgrade, reinstall, or replacement of an unreadable
version (compared as semver from both bundles' Info.plist). Accepting
stages a `ditto` copy under a hidden name in /Applications, trashes any
installed copy via NSFileManager, renames the staged copy into place,
clears quarantine, spawns a detached shell that relaunches the installed
copy after this process exits (detaching the disk image when running from
one), and exits. All shell-interpolated paths are single-quote escaped.
Failures keep the current copy running and explain how to move it by
hand. Declining continues normally; the suppression checkbox is honoured
regardless of which button was clicked. Detection is fail-open and the
flow never escalates privileges.

The prompt runs from `RunEvent::Ready` rather than `setup` because the
synchronous `runModal()` would otherwise block setup and race the webview.
Pure classification, scenario, copy, and shell-quoting helpers build on
every platform and are covered by unit tests; only the AppKit/statfs/ditto
code is macOS-only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…llback

The installer-media prompt from 8e7e7eb5c never appeared in a release build
launched from a DMG: the process logged "running from installer media …
prompting to move to /Applications" and then hung at 0 % CPU with zero-size
windows.

`NSAlert.runModal()` was called inline from the `RunEvent::Ready` arm of the
Tauri `run` callback. tao delivers that callback from `did_finish_launching`
while holding its non-reentrant handler `Mutex`. The modal's first
`CA::Transaction::flush` displays the already-visible main window, whose
`drawRect` calls back into tao (`view::draw_rect` → `handle_redraw` →
`Handler::handle_nonuser_event`) and blocks on the same lock on the same
thread — a deadlock, reproducible 2/2 (tao 0.37.1, tauri 2.12.4).
`app.run_on_main_thread` would not help because it is delivered through the
same locked handler.

The hang needs a visible window: an app whose main window is still hidden at
`Ready` has nothing to draw during the modal, so the re-entrant redraw never
happens. Buzz shows its main window before `Ready`.

Fix: schedule `maybe_prompt_move_to_applications` with
`dispatch2::DispatchQueue::main().exec_async` so it runs on the next
main-run-loop turn, after the callback has returned and the lock is released.
`dispatch2` was already in Cargo.lock transitively; it is now a direct
macOS-only dependency pinned like the other objc2 crates. The comments that
claimed running from `Ready` (rather than `setup`) avoided the blocking
problem now state the actual constraint.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…he same way

An installed old Buzz from block/buzz (`Buzz.app`, `xyz.block.buzz.app`,
executable `buzz-desktop`, `CFBundleShortVersionString` ≤ 0.5.28,
`CFBundleVersion` `0.5.28-block`) and an installed previous build of this
app (`Buzz.app`, `xyz.block.buzz.desktop`, executable `Buzz`) now go
through exactly the same Upgrade/Reinstall/Downgrade flow in the
move-to-/Applications prompt, and the flow cannot tell which of the two
it is replacing.

Why that holds:

- Same file name, path-keyed lookup. The installed copy is
  `/Applications/<running bundle's file name>`, now a single
  `installed_copy_path` helper shared by the prompt and the move, with a
  doc comment stating the identity indifference is deliberate. The audit
  confirmed nothing in the module reads `CFBundleIdentifier`,
  `CFBundleExecutable`, `CFBundleName`, the signing identity or the
  data dir to locate, compare, trash or replace the installed copy; the
  suppression marker lives in the running app's own data dir and is not
  an installed-copy lookup.
- Semver on the short version only. `compare_versions` parses
  `CFBundleShortVersionString`, which both apps ship as clean semver;
  `CFBundleVersion` is read into `BundleVersion::build` but never
  parsed, so the Block-signed `-block` suffix cannot degrade the
  scenario to `ExistingUnknown`. A `-test.N` / `-intel-test.N` canary
  suffix on the short string parses as a semver prerelease and still
  sorts below every 0.7.x build. Every version block/buzz has shipped is
  < 0.6.0, so an installed old Buzz is always an Upgrade.
- Path-keyed terminate. `tauri_plugin_single_instance` keeps a previous
  build of this app from running alongside the prompt, but the old Buzz
  has a different identifier, keeps running after its window closes
  (tray icon, self-updater that auto-downloads) and is not covered by
  that guard. Before trashing the installed bundle, the accept path now
  quits every running application whose `NSRunningApplication.bundleURL`
  normalises (via `canonicalize`) to the installed bundle path: polite
  `terminate()`, up to 2 s of polling, then `forceTerminate()`. The
  outcome is logged and the move continues either way, since trashing a
  running bundle succeeds. Liveness is checked through `isTerminated`
  plus `kill(pid, 0)`, because `NSRunningApplication` properties only
  update when the main run loop spins, which it does not while we poll
  on the main thread. Enabled `NSWorkspace`, `NSRunningApplication` and
  `libc` on the macOS-only `objc2-app-kit` dependency.

A leftover `Sprout.app` is not handled.

Verified:

- New unit tests: `old_buzz_from_block_buzz_is_always_an_upgrade` (0.5.28
  with `0.5.28` and `0.5.28-block` builds, 0.5.9-block, 0.3.18,
  `0.5.29-test.123`, `0.5.28-intel-test.45`, `0.0.1-test.3`, each against
  the version in `tauri.conf.json`),
  `old_buzz_prerelease_suffix_never_degrades_to_replace`,
  `old_buzz_and_previous_buzz_app_build_get_the_same_upgrade_dialog`
  (0.5.28 and 0.7.0 → 0.7.1 yield the same scenario variant, message and
  button, and informative text identical apart from the numbers),
  `installed_copy_path_uses_only_the_bundle_file_name`, and
  `normalized_bundle_path_matches_through_symlinks`.
- `cargo test -p Buzz installer_media`: 26 passed. `cargo fmt --check`,
  `cargo clippy -p Buzz --all-targets -- -D warnings` and
  `cargo build --release -p Buzz` on macOS: clean.
- The pure helpers were run (via a throwaway test, not committed)
  against the real `/Applications/Buzz.app/Contents/Info.plist` on this
  machine without launching or moving anything:
  identifier `xyz.block.buzz.app`, executable `buzz-desktop`,
  installed `0.5.28` / `0.5.28-block`, running `0.7.0` →
  `Upgrade { from: "0.5.28", to: "0.7.0" }`; dialog "Upgrade Buzz in
  your Applications folder?" / "You have Buzz 0.5.28 installed. This
  copy is the newer version 0.7.0. Buzz can replace the installed copy
  and relaunch from it." / button "Upgrade and Relaunch".

Not exercised live: the terminate step against a running old Buzz, and
the full DMG → replace flow over an installed old Buzz.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Accepting the move-to-/Applications prompt ended with
`std::process::exit(0)` and skipped `crate::shut_down`, which Quit
(`RunEvent::Exit`) and the fenced sign-out (`exit_fenced`) both run before
exiting. Anything Buzz had started in its own process group outlived the
DMG copy and kept running beside the relaunched installed copy: the
login-shell probe that setup always spawns (its 10 s deadline is enforced
by a tokio task that dies with the process), plugin host processes and the
bundled agent runtimes, which run from `/Volumes/…`, terminal sessions and
MCP hosts.

The `Ok` arm now calls `crate::shut_down(app)` once the move and the
relaunch shell have succeeded, then exits. Not earlier: the `Err` arm
keeps the app running and still needs all of that. It is safe here: the
block runs on the main thread from the main dispatch queue with tao's
handler lock free, and Quit already calls the same function from inside
that lock. It cannot stop the relaunch shell, which stays in Buzz's own
process group; `shut_down` signals only child groups and the probe's
group. `app.exit(0)` was rejected because it returns to the run loop and
leaves the window interactive after `/Applications` has been replaced.

Verified live with ad-hoc-signed release builds on hand-made DMGs, the
bundle renamed `BuzzCheck.app` and the identifier overridden so neither
`/Applications/Buzz.app` nor real app data was touched. A `ZDOTDIR`
`.zshenv` held the login-shell probe in `/bin/sleep 4711`:

- Before (pre-fix 0.7.0 build): after accepting, the held probe was still
  running, reparented to launchd (ppid 1).
- After (0.7.1 build of this change, upgrading an installed 0.7.0): the
  held probe was gone once the DMG copy exited; the installed copy
  relaunched 0.54 s after the press and the DMG detached at 1.77 s.

`cargo fmt --check` and `cargo clippy -p Buzz --all-targets -- -D
warnings` are clean. No unit test: the change is one call in AppKit-only
code, and the teardown pieces have their own tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
The move staged its copy at `/Applications/.Buzz.app.<pid>.staging`, so
the `staging.exists()` cleanup only matched on pid reuse. A copy left by
an interrupted earlier move (most likely a force-quit during the wait for
a running installed copy to quit) was never removed, and each
interruption added another hidden 66 MB bundle. The cleanup also dropped
its error, and `ditto` merges into an existing destination, so a leftover
that couldn't be removed would leave stale files in the new copy and
break its signature.

The staging copy now has one fixed name, `/Applications/.Buzz.app.staging`
(`staging_path`), and `clear_staging` removes whatever is there before
`ditto` runs. Nothing there is fine; any other removal error stops the
move and reaches the existing failure alert, naming the path. A fixed name
is safe because the single-instance socket is keyed by identifier only, so
a DMG copy and the installed copy never move at the same time for one
user; another user's staging tree can't be removed, which now fails
closed. The name still ends in `.staging`, not `.app`, so Launch Services
never registers it. Globbing pid-suffixed names was rejected: those only
exist on machines that ran this unreleased branch.

Verified:

- New unit tests `staging_path_is_hidden_fixed_and_beside_the_target`,
  `clear_staging_removes_a_leftover_copy`,
  `clear_staging_accepts_nothing_to_clear` and
  `clear_staging_fails_when_a_leftover_cannot_be_removed`. With the old
  behaviour restored (pid in the name, removal errors ignored) the first
  and last fail. `cargo test -p Buzz installer_media`: 30 passed.
- Live, same harness as the previous commit (`BuzzCheck.app`, overridden
  identifier):
  - Interrupted move: with a stand-in installed at
    `/Applications/BuzzCheck.app` and held under SIGSTOP, `kill -9` of the
    DMG copy after `ditto` finished left `.BuzzCheck.app.staging`. With a
    stale file planted in it and the stand-in quit, the next accepted move
    removed it: the installed copy has no stale file, `codesign --verify
    --deep --strict` passes and no `.staging` remains. Before (pre-fix
    build), an interrupted move left `.BuzzCheck.app.8740.staging`, which
    was still there after the next move completed.
  - Leftover that can't be removed (`Contents` at 0555): accepting showed
    "Couldn't move Buzz to your Applications folder", the error logged and
    passed to it was "failed to remove leftover staging copy
    /Applications/.BuzzCheck.app.staging: Permission denied (os error 13)",
    the installed copy was untouched and the DMG copy kept running after
    OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…rce-quitting

Before trashing the installed copy, the accept path asks any running copy
of it to quit and force-quits it after `TERMINATE_GRACE`. The old Buzz from
block/buzz never refuses or delays the quit (tao implements no
`applicationShouldTerminate:` and its `ExitRequested` arm never prevents
exit), but its quit stops its managed agents first: SIGTERM to each
agent's process group, up to 2 s, then SIGKILL for any still running. Our
2 s cap starts slightly earlier, so `forceTerminate()` landed just before
that SIGKILL fallback. Agents still alive then were orphaned, kept running
from `~/.Trash/Buzz.app`, and nothing would ever sweep them.

`TERMINATE_GRACE` is now 3 s, with a doc comment tying it to that
teardown. The wait ends as soon as the app exits, so the longer cap only
costs time while the old copy is still stopping. The wait stays
synchronous on the main thread: a shorter cap or force-quitting first
would orphan more agents, and waiting without blocking would let tao and
Tauri callbacks, IPC and Quit run while /Applications is half replaced.

Verified live with the same harness, using a stand-in buzz-app build with
a different identifier (`xyz.block.buzz.desktop.dmgdemo.standin`)
installed as `/Applications/BuzzCheck.app`, launched, and its window
closed so it keeps running hidden like old Buzz:

- Running normally: "Running copy of /Applications/BuzzCheck.app (pid
  71631): quit before replacement" 0.21 s after the press; the move
  completed.
- Held under SIGSTOP: "force-quit before replacement" 3.12 s after the
  press (the 3 s grace after `ditto`); the move completed and the new copy
  relaunched.

`cargo fmt --check`, `cargo clippy -p Buzz --all-targets -- -D warnings`
and `cargo test -p Buzz installer_media` (30 passed) are clean on this
commit. Not exercised: old Buzz's own quit under `terminate()`, and an
agent that ignores SIGTERM.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Before trashing the installed copy, the accept path asked any running copy
of it to quit, polled for up to `TERMINATE_GRACE` (3 s), then force-quit
it. No fixed cap fits the old Buzz from block/buzz, which is the copy this
realistically hits: its quit closes terminal sessions one at a time (up to
250 ms each, and an interactive zsh ignores SIGTERM, so each takes the
full 250 ms), then gives its agents up to 2 s before SIGKILL, and only its
later orphan/system sweeps kill agent workers in their own process
groups. With a busy agent and a few terminal tabs, `forceTerminate()`
landed mid-teardown and orphaned agents that kept running from
`~/.Trash/Buzz.app`, connected to their relays, with nothing in buzz-app to
ever clean them up.

The move doesn't need the old copy to have exited, because trashing a
running bundle succeeds on macOS. So a copy that accepts `terminate()` is
now left to finish its own shutdown, from the Trash if need be, and the
move goes ahead at once. `forceTerminate()` is used only for a copy that
didn't accept the request. `TERMINATE_GRACE`, the polling loop and
`has_exited` are gone, and with them the synchronous main-thread wait. The
doc comment no longer claims old Buzz stops its agents first.

Tradeoffs: old Buzz's tray icon can stay visible for a few seconds after
the new copy relaunches, and a hung old Buzz that accepted the request
keeps running from the Trash instead of being killed (force-quitting it
would orphan its agents anyway).

`cargo fmt --check`, `cargo clippy -p Buzz --all-targets -- -D warnings`
and `cargo test -p Buzz installer_media` (30 passed) are clean. Not
exercised live: the accept path against a running installed copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Review on #1792 flagged that the `installer_media` module comment pointed
to a private repository and issue, which public contributors can't open
and which exposes internal coordination details. That line is removed.
The comment still names its public provenance, the Potion Factory
`LetsMove` flow it mirrors. No license or authorship attribution is lost:
the referenced code is Block's own, and none of it was copied from a
third party.

The same reference and other discussion of that internal implementation
were also removed from the branch's earlier commit messages and from the
PR description.

Comment-only change; not built or tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account.

Two prior P2s remain: restore the installation after a failed rename and handle canonical source/target identity and exclude the current PID. Their requested repairs still apply.

The private-reference finding is resolved across source, commit messages and description. No new production defect found. The previous description correction still applies: remove the obsolete three-second polling/force-quit claims and identify historical test results.

Rocket, Mantis and Groot completed independent lanes. Head af927628c55dcb773a0b9b848ec2f104ec866ae8, base 8385e11f72b2671cf30f3007635320553760514a. Source-only: no tests, app execution or CI verification; native installation/focus and human acceptance remain unverified. Screenshot inspected; two videos uninspected.

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.

3 participants