fix(trusty-installer): stop tctl upgrade double-installing daemons, and make its restart real (#4964 phases 0+1) - #5011
Conversation
…nd make its restart real (#4964 phases 0+1) Phase 0 — correctness fixes independent of where binaries land: - Add `trusty_common::bin_resolve::canonical_bin_dir` (+ pure `canonical_bin_dir_from`). Five call sites restated "where does cargo install put binaries"; two restated it wrongly — `install_all`'s fallback hardcoded `~/.cargo/bin` and never read `CARGO_HOME`, and `self_update::cargo_install_dir` treated `CARGO_HOME=""` as a real value, resolving the relative path `bin`. All of them, plus `tctl sign`'s `--dir` default and `update::candidate_bin_dirs`, now share the one helper. - Pass the concrete just-installed path to `<binary> service install` instead of a bare name. That process bakes its own `current_exe()` into the plist's `ProgramArguments[0]`, so a stale copy winning the PATH lookup persisted the stale path into launchd, which respawned it at every boot forever. - `binary_size` reads the path the install actually wrote, not a name joined onto the cargo bin dir the prebuilt path never touches. - Correct the claim that `~/.local/bin` avoids cdhash issues on macOS. The atomic rename in the download layer provides that, in any directory. Phase 1 — one call site, two defects: - `tctl upgrade`'s daemon branches no longer call `upgrade_and_restart`. On the prebuilt path the binary was already on disk and that function ran `cargo install` anyway — a second copy in a second directory, from one command. Six of seven stable-set members are daemons, so it fired on nearly every upgrade, and on a toolchain-free machine it errored after the new binary had landed. - Its restart is `std::process::exit(1)` plus launchd KeepAlive: correct for the daemon restarting itself, a guaranteed no-op for `tctl`, which launchd does not supervise. The hint it returned was reported as success, so tctl upgrade has never restarted a daemon member. Both branches now bounce the member through the same path `tctl restart` uses — port guard, bootout, bootstrap. Deliberately NOT included: phase 2 (cargo ownership conflict), phase 3 (destination flip), phase 4 (migration), phase 5 (ADR). Homebrew stays out of scope per the owner's ruling. Refs #4964 🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools
Code Critic — Verdict: WARNAdversarial review of the diff against #4964 Phases 0+1, from an isolated worktree at Findings
Zero CRITICAL. One HIGH → WARN. The HIGH is a red required check, so it must be green before merge regardless of verdict. What I attacked, and what held1. The restart path (blast radius). No path bounces a daemon that a no-op run would have left alone: the bounce is gated behind a newer version existing, user confirmation, a successful placement, and a passing concrete-path health gate. 2. The declined Phase 1b variant — the reasoning is correct. A place-nothing variant of 3. 4. The consolidation is behaviour-preserving where it should be. Walked all seven old restatements against 5. Test honesty — five of six breaks applied, all red exactly where named. Each break reddens its named test and nothing else. On 6. Both gate claims are true. The bare run does skip the whole The
Other gates I ran: Required before merge
NotesScope honoured: trusty-agents, trusty-code, Homebrew, and Phases 2-5 were not reviewed. No daemon was restarted, nothing was installed, and no file outside 🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools |
…t the upgrade restart guarantees (#4964) The doc-comment pointer lint failed on `lifecycle.rs:269`, which cited `daemon_restart_routes_by_manage_strategy` — a name from an earlier draft, renamed to `restart_plan_*` when `RestartPlan` was extracted. `upgrade.rs:465` carried the SAME stale name and escaped the gate only because the parser stops extracting at the first non-backticked token. Both now cite the real tests. Two more `Test:` blocks led with prose, so the lint silently skipped them. Restructured `bin_resolve::canonical_bin_dir`'s to lead with its citation so it is actually checked. `sign::default_bin_dir`'s citation is cross-crate and can never be verified from trusty-installer, so it now says plainly that the item has no local test and where the rule is proven, instead of naming a test the gate cannot resolve. Doc claims qualified, no mechanism change (review finding 2): - `upgrade.rs`'s module doc promised launchd members "come back on" the new binary. `bootout`/`bootstrap` re-exec whatever path `ProgramArguments[0]` names, and nothing on this path rewrites it — so on a host whose plist was baked from the other bin directory the bounce costs a downtime window and brings the same old binary back while reporting success. Phase 4 owns the plist regeneration; the doc now says what the code delivers today. - `lifecycle::own_verb_control` resolves the binary through `which::which`, so it can bounce a different copy than the concrete path this PR health-gates. Noted where it happens; the fix is one destination (Phase 3), not another path-threading change. Same qualification added to the changelog fragment. Refs #4964 🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools
Code Critic — Re-review at
|
| Severity | File | Line | Issue | Fix | Disposition |
|---|---|---|---|---|---|
| LOW | PR body | 40 | Still says upgrade_and_restart "serves its four in-daemon callers". There are five, and the fifth — crates/trusty-mpm/src/bin/tm/commands/reinstall.rs:218-220 — is not in-daemon; its own comment reads "May not return: upgrade_and_restart exits(1)", which from a terminal tm is the same never-restarts defect Phase 1a exists to fix. Not a code defect: the function is unchanged and all five compile. |
Add it to the epic's call-site inventory. | Parent |
Zero CRITICAL, zero HIGH → APPROVE.
Finding 1 (HIGH) — cleared, and the gate now reaches further than it did
Both pointers cite real tests and name the file they live in. Every name resolves, in the file named:
cargo_install_dir_ends_in_bin → self_update.rs
restart_plan_daemons_restart → upgrade_tests.rs
restart_plan_non_daemon_is_a_noop → upgrade_tests.rs
restart_plan_launchd_member_off_macos_is_manual → upgrade_tests.rs
no_installer_call_site_invokes_upgrade_and_restart → upgrade_tests.rs
applied_report_all_ok_reflects_shadow_failure → upgrade_tests.rs
binary_size_reads_the_concrete_path → install_tests.rs
service_install_target_prefers_the_concrete_path_over_path_lookup → service_bootstrap_tests.rs
No surviving citation of the deleted cargo_bin_dir_from_env_* tests — the one remaining mention is a plain // comment in install_tests.rs:276 explaining the move, not a Test: pointer. The daemon_restart_* hits elsewhere in the tree are trusty-mpm's unrelated daemon_restart_command.
The lint is untouched and un-weakened: git diff origin/main...245db8f0 -- scripts/ .test-pointer-allowlist.tsv .github/workflows/test-pointers.yml is empty. No deletion, no exclusion, no allowlist row. I reran it from a clean checkout of the head:
test-pointers: resolved 22088 Test: citation(s) (floor 200) — 0 dangling pointers — OK.
Two blocks the parser never examined — the right correction. canonical_bin_dir was reworded to lead with `canonical_bin_dir_from_*` rather than "the rule is covered by …", which moves it under the gate instead of around it. That is the fix that generalises: the parser stops at the first non-backticked token, so a prose-led block is invisible, and rewriting it to lead with the citation is the only way to make it verifiable. Good catch — it is the class of hole I did not look for last round.
The sign::default_bin_dir decision is correct, and it does not hide a gap. I verified the "two-line delegation" claim:
fn default_bin_dir() -> PathBuf {
trusty_common::bin_resolve::canonical_bin_dir()
.unwrap_or_else(|| PathBuf::from("/usr/local/bin"))
}The lint scopes citations to the citing file's own crate, so a trusty-common test name cited from trusty-installer is a permanently unresolvable pointer — leading with it would trade a silent hole for a permanent red. The only behaviour local to this function is the /usr/local/bin last resort, reachable solely when canonical_bin_dir() returns None, which needs both an absent CARGO_HOME and a failing dirs::home_dir(). There is no seam for that here (the function takes no parameters and is #[cfg(target_os = "macos")]), and the None case of the rule itself is tested — canonical_bin_dir_from_is_none_without_either_input. So the untested surface is one unwrap_or_else literal, and the same shape sits untested at install.rs, install_one, and upgrade_one. Stating "no test in this crate, here is why, here is where the rule is proven" names the gap; it does not conceal it. Parameterising a two-line macOS-only helper to close it would be more churn than the risk warrants.
Finding 2 (MEDIUM) — the promise is withdrawn, not softened
No "may not" anywhere. upgrade.rs:9-21 states the condition, the consequence, and the verdict on its own report:
the daemon comes back on the NEW binary only when the plist's
ProgramArguments[0]already points at the directory this upgrade wrote to … the bounce costs a downtime window and brings the SAME OLD BINARY back — while the report still says "upgraded to X; restarted". That is a false success report
Phase 4 is named as the prerequisite for the guarantee and Phase 3 as what removes the divergence. The changelog fragment carries the same declarative form ("this change stops the daemon being left un-bounced, it does not yet guarantee which binary it comes back on") — correct to qualify there too, since a user reading only the changelog would otherwise infer the guarantee. own_verb_control's which::which resolution is noted at lifecycle.rs:437-444 where it happens, stated as fact ("can therefore restart a different binary than the one it verified") with the deferral reasoned rather than asserted. No mechanism change, as instructed.
No regression across the fix
The change is doc comments plus a changelog fragment, and I confirmed that mechanically rather than by eye — every changed .rs line in e9a2a9a4..245db8f0 is a /// or //! line:
git diff e9a2a9a4..245db8f0 -- '*.rs' | grep '^[+-]' | grep -v '^[+-] *\(///\|//!\|//\)'
→ (no output)
No code moved while the comments around it were edited.
Gates at this head: cargo fmt --check && cargo test -p trusty-installer → EXIT=0, 562 passed, 0 failed, 4 ignored. Pointer lint green as quoted. The remaining gates were verified at e9a2a9a4 and cannot be affected by a doc-only diff.
Notes
The MEDIUM mechanism gap (the bounce re-execs whatever the plist names) is now documented in three places and stays with epic #4964 Phase 4 — that was the disposition, and it was honoured. Nothing installed, no daemon restarted, no file outside .claude/worktrees/critic-5011 touched.
🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Phases 0 and 1 of #4964. Both are destination-independent correctness fixes — they land safely regardless of the later destination flip.
Covered here: Phase 0 (0.1
canonical_bin_dir, 0.2 absolute path to service bootstrap, 0.3 the two intra-installer divergences) and Phase 1 (1a working restart, 1b stop the double-write).Deliberately NOT covered: Phase 2 (cargo ownership conflict), Phase 3 (destination flip), Phase 4 (migration), Phase 5 (doctor check, guard script, ADR). Phases 2 and 3 must ship together, and Phase 3 is blocked behind a separate
binary_provenance/ ADR-0021 reconciliation — shipping any part of it here would make thetm doctorfalse-positive real on a daily driver. The epic stays open.Homebrew is out of scope for the epic, as accepted residual risk (owner ruling).
bobmatnyc/homebrew-trustyis live andbin.installlands in$(brew --prefix)/bin, which this repo cannot redirect.Phase 0
0.1 — one implementation of "where does
cargo installput binaries". Addedtrusty_common::bin_resolve::canonical_bin_dir, plus the purecanonical_bin_dir_from(home, cargo_home)that holds the rule. Five sites restated it; two restated it wrongly:install.rs'sinstall_allfallback hardcoded~/.cargo/binand never readCARGO_HOME, while the sibling fallback ininstall_one— same job, same file — did.self_update::cargo_install_dirtreatedCARGO_HOME=""(how a shell exports a variable it never assigned) as a real value and resolved the relative pathbin.Also routed through it:
tctl sign's--dirdefault,upgrade.rs's health-gate destination,install.rs'scargo_bin_dir, andtrusty_common::update::candidate_bin_dirs's first entry.0.2 — the stale-daemon respawn loop.
install_allpassed a bare binary name tobootstrap_member_service, which resolved it withwhich::whichand spawned it. That process bakes its owncurrent_exe()into the launchd plist'sProgramArguments[0], andKeepAliverespawns exactly that path at every boot — nothing rewrites the plist unlessservice installruns again, and re-running reproduces the same resolution. So on a host where a stale copy sits earlier onPATH,tctl installplaced a new binary and persisted the old one into launchd. It now passesinstalled.path, the concrete path the install just wrote.tctl start, which installs nothing, keeps thePATHlookup via an explicitNone.0.3 — the two intra-installer divergences.
binary_sizejoined a bare name onto the cargo bin dir while the prebuilt path writes elsewhere, so the component table's size column described a stale copy or reported zero; it now takes the concrete path, which makes the divergence structurally impossible on either branch and under any future destination.tctl sign's default andinstall.rs's helper now sharecanonical_bin_dir. The post-install hook's set loop still signs underinstall_dir— that is the directory the binaries are actually in today, and it becomes canonical for free at Phase 3.The false comment.
install.rsjustified~/.local/binas chosen "to avoid cdhash issues on macOS". It is not. The atomic rename indownload/fetch.rsis what keeps the cdhash cache consistent, and that holds in any directory. Corrected in place.Phase 1
Both defects lived in one call,
commands/upgrade.rs'supgrade_and_restarton the daemon branches.1b — the double-write. On the prebuilt branch the binary was already on disk, and
upgrade_and_restart's first step iscargo install <crate> --locked, landing a second copy in a second directory from one command. The comment saying that step was "a no-op if the binary is already current" was wrong: cargo skips only when its own.crates2.jsonrecords that exact version, and the prebuilt path writes no cargo metadata. Six of the seven stable-set members are daemons, so it fired on nearly every upgrade — and on a machine with no Rust toolchain it errored out after the new binary had landed.What the restart does NOT yet guarantee
bootout/bootstrapre-exec whatever path the plist'sProgramArguments[0]names, and nothing on this path rewrites it. The prebuilt branch writes~/.local/bin; the fallback branchcargo installs to$CARGO_HOME/bin. So on a host whose plist was baked from the other directory,tctl upgradespends a downtime window bouncing the daemon and brings the same old binary back, while reportingupgraded to X; restarted.That is a false success report. Phase 4 owns the plist regeneration that fixes it and Phase 3 removes the directory divergence that makes it reachable — neither is in scope here. What this PR changes is that the daemon is no longer left un-bounced entirely; it does not yet guarantee which binary comes back. Both
upgrade.rs's module doc and the changelog fragment now say exactly that instead of promising the outcome. Separately noted in place:lifecycle::own_verb_controlresolvestrusty-mpmthroughwhich::which, so it can bounce a different copy than the concrete path this PR health-gates — same root cause, same Phase 3 fix.1a — the restart.
upgrade_and_restartrestarts by callingstd::process::exit(1)and letting launchd'sKeepAliverespawn the process that just exited. That is correct for its other callers —trusty-search upgrade,trusty-memory upgrade, and the two MCPupgradetools all run inside the supervised daemon.tctlis a terminal process launchd has never heard of, so the supervision check evaluatedtctl, returned false every time, and the manual-restart hint it produced was reported as success.tctl upgradehas never restarted a daemon member.The fix is not a restart-only variant of
upgrade_and_restart—exit(1)fromtctlstill restarts nothing. Both daemon branches now health-gate the concrete path and then bounce the member throughlifecycle::restart_member, the same pathtctl restartalready uses: port guard first, thenbootout, thenbootstrap, honouring theExitTimeOutdrain window. Neverlaunchctl kickstart -k, which sends SIGKILL. The fallback branch gets the restart too — new behaviour there as well.trusty_common::update::upgrade_and_restartitself is unchanged and still serves its four in-daemon callers.Tests
Every new test was confirmed red against a deliberate break of the behaviour it names.
bin_resolve::canonical_bin_dir_from_honours_cargo_home(+3 siblings)CARGO_HOMEservice_install_target_prefers_the_concrete_path_over_path_lookupwhichlookupbootstrap_one_forwards_the_concrete_exe_path_to_service_installbootstrap_onedrops the path before the spawnbinary_size_reads_the_concrete_pathbinary_sizegoes back to joining a name onto the cargo bin dirrestart_plan_daemons_restart(+2 siblings)no_installer_call_site_invokes_upgrade_and_restartsrc/restart_planis a pure function precisely so the dispatch is testable — invokingrestart_memberin a unit test would bounce the host's live daemons.upgrade.rscrossed the 500-SLOC production cap, so its tests moved to a siblingupgrade_tests.rs, the splitservice_bootstrap.rsalready uses.Two
cargo_bin_dir_from_env_*tests were deleted frominstall_tests.rs— the local copy of the rule they covered no longer exists, and the same three cases (plus a fourth for the no-home case) are now intrusty-commonbeside the one implementation.Gates — rung 4 (cross-crate: trusty-installer + trusty-common)
The pointer lint was missing from this list on the first push, and it is what
went red:
lifecycle.rs:269citeddaemon_restart_routes_by_manage_strategy,a name from an earlier draft renamed to
restart_plan_*whenRestartPlanwasextracted.
upgrade.rs:465carried the same stale name and passed the gate onlybecause the parser stops extracting at the first non-backticked token — fixed
too. Two further
Test:blocks led with prose and were therefore skippedunchecked; one now leads with its citation so the gate covers it, and the other
(
sign::default_bin_dir, whose only proof is cross-crate and unverifiable fromthis crate) says so plainly rather than naming a test the gate cannot resolve.
*
--workspace --all-targetsfails ontrusty-mpm-guiandtrusty-code-guiin any fresh worktree:tauri::generate_context!panics becausefrontendDist = "ui/dist"has not been built. Pre-existing and environmental — this branch touches no file in either crate. Those two are--excluded from the local run only; CI builds the UI and covers them.The
--features update-checkrun is separate on purpose:trusty-common's default feature set is empty, so a barecargo test -p trusty-commonnever compiles theupdatemodule at all and exits 0 having skippedcandidate_bin_dirsentirely.No consumer suites were run beyond that. The only
trusty-commonitem this branch changes iscandidate_bin_dirs, which ispub(crate)— no crate outsidetrusty-commoncan call it, and its four existing tests pin the output as unchanged.canonical_bin_diris new, and its only consumer istrusty-installer, which is fully tested here.Neither crate needs a version bump:
trusty-installeris at 0.5.1 against 0.5.0 live,trusty-commonat 0.29.0 against 0.28.1 live.Refs #4964 — the epic stays open for phases 2 through 5.
🤖🤖🤖 Generated with trusty-mpm — https://github.com/bobmatnyc/trusty-tools