Repository navigation
Conversation
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 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); |
There was a problem hiding this comment.
🤖 [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) { |
There was a problem hiding this comment.
🤖 [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() { |
There was a problem hiding this comment.
🤖 [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.
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 One additional shutdown-grace finding. The previously reported rollback and self-termination findings still apply.
| /// 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); |
There was a problem hiding this comment.
🤖 [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
left a comment
There was a problem hiding this comment.
🤖 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.
wesbillman
left a comment
There was a problem hiding this comment.
🤖 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.
…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
left a comment
There was a problem hiding this comment.
🤖 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.
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/Applicationswith a native prompt.src-tauri/src/installer_media.rs): on macOS release builds, Buzz finds the running.appbundle and classifies where it lives: installed in/Applicationsor~/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.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 asuppress-move-to-applicationsmarker to the app data dir, whichever button is clicked.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.runModal()directly in theRunEvent::Readycallback deadlocked. tao holds a non-reentrant handlerMutexwhile 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 throughdispatch2::DispatchQueue::main().exec_async, so it runs on the next main-run-loop turn after the lock is released.run_on_main_threadwouldn't help because it goes through the same locked handler.The classification, scenario, copy, path and shell-quoting helpers are pure and build on every platform, with unit tests. Only the AppKit,
statfsanddittocode is macOS-only. New direct deps:semver,plist, and macOS-onlydispatch2(already inCargo.locktransitively, pinned like the other objc2 crates);objc2-app-kitgains theNSWorkspace,NSRunningApplicationandlibcfeatures.A leftover
Sprout.appis 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 identifierxyz.block.buzz.app, executablebuzz-desktop,CFBundleShortVersionString≤0.5.28,CFBundleVersion0.5.28-blockon Block-signed builds (0.5.28on OSS builds,X.Y.Z-test.N/X.Y.Z-intel-test.Non canaries). This app isxyz.block.buzz.desktopwith executableBuzz. The flow treats both identically and cannot tell them apart:/Applications/<running bundle's file name>(installed_copy_path). Nothing readsCFBundleIdentifier,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.CFBundleShortVersionString, which both apps ship as cleanX.Y.Z.CFBundleVersionis read but never parsed, so0.5.28-blockcan't push the scenario to the neutral "Replace" wording; a-test.Nsuffix on the short string parses as a prerelease and still sorts below0.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 sameditto→ Trash → rename replacement. Unit tests namedold_buzz_*cover0.5.28+0.5.28-block,0.5.9-block,0.3.18,0.5.29-test.123,0.5.28-intel-test.45and0.0.1-test.3, and assert that0.5.28and0.7.0installs 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" }.tauri-plugin-single-instancekeeps 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 everyNSRunningApplicationwhosebundleURLcanonicalises to the installed bundle path (not by identifier, so it covers both apps without naming either):terminate(), up to 3 s of polling (isTerminatedpluskill(pid, 0), sinceNSRunningApplicationproperties only update while the main run loop spins), thenforceTerminate(). 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):
/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.appto be the new build (CFBundleIdentifierxyz.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.appis untouched./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):Accept path (fresh install): launch from the DMG → alert at ~1.2 s → "Move to Applications Folder" → the DMG copy exits,
/Applications/Buzz.apprelaunches ~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-applicationsmarker → a third launch from the DMG shows no alert.take2-decline-and-suppress.mp4
Testing
cargo test -p Buzz installer_media: 30 passed).cargo fmt --check,cargo clippy -p Buzz --all-targets -- -D warningsandcargo build --release -p Buzzon macOS are clean./Applicationsrelaunched 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.BuzzCheck.appso nothing touched/Applications/Buzz.app:sleepby a test.zshenvsurvived the pre-fix build's exit, reparented to launchd. With this branch it is gone once the DMG copy exits..staging.kill -9during 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.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