feat(installer): ask before an upgrade ends live sessions - #1010
Conversation
An upgrade force-stopped the daemon unconditionally before swapping any executable, so a `curl … | sh` upgrade silently destroyed whatever was running in it. The only mitigation was a notice printed by the daemon being replaced, which meant you had to upgrade twice before ever seeing it, and it came after the fact rather than at a decision point. The pre-upgrade stop now tries `min stop` first. That command already refuses while sessions are live, so its refusal — matched on the message the CLI prints for that case, never on a bare non-zero exit, which also covers no daemon, a failed connect, a `min` too old to know the subcommand, and a transport drop on an otherwise successful stop — is reused as the signal. On it the installer lists the running sessions and asks; only an explicit yes escalates to `min stop --force`. Declining exits non-zero before the first rename, dropping the temp download and leaving the daemon, its sessions, and the install record untouched. The answer is read from the controlling terminal, not stdin: under `curl … | sh` stdin is the script pipe. With no terminal to open there is nobody to consent, so the run aborts naming the escape hatch instead of hanging or forcing. That hatch is `--force-stop` (filtered out of the arguments wherever it appears, so the target stays the sole positional) or a non-empty MINIMAL_INSTALL_FORCE_STOP, deliberately spelled unlike uninstall's `--force`. Every other stop outcome keeps today's silent, best-effort semantics and still falls through to the force stop. The installer spec's R5.5 and the install guide's upgrade note, which both described the old unconditional force-stop, now describe this. Closes gominimal/inbox#366 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pin the CLI string the upgrade prompt depends on. `scripts/install.sh` decides whether an upgrade is about to destroy live work by matching `min stop`'s refusal message, and nothing tied that literal to the `bail!` that produces it: a reword on the Rust side would silently return the installer to an unconditional force-stop with every installer test still green, because the harness stubs `min` with its own copy of the string. A workspace test now reads both files and asserts the installer's literal still appears in the CLI source. Correct the abort message. The stop runs at the first `bin`/`lib` component, so "nothing was installed" overclaimed — a `data` row ordered ahead of it in the manifest would already have been replaced. It now reports that no executables were replaced, which is what the abort point actually guarantees, and the spec says so rather than resting on today's manifest ordering. Read every `min` invocation from /dev/null. They run inside the component loop, whose stdin is the applicable-manifest file; nothing reads stdin there today, but a child that ever did would consume the rows still to be installed. Show the flag as it is actually passed (`sh -s -- --force-stop`) in the install guide, which documents a `curl … | sh` pipeline, and give the option-filter test a non-default target so it distinguishes "the positional survived" from "the default kicked in". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThe installer now attempts graceful daemon shutdowns during upgrades, prompts before force-stopping active sessions, supports ChangesSession-safe upgrade flow
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
Comment |
The R5.5 scenarios claimed `$root/h16` and `$root/h17`, the same two homes the gvproxy rename-migration scenarios claim. Each R5.5 scenario deliberately leaves its home holding a `sessions.live` marker and a `min` that refuses to stop while it exists, and the declining scenario aborts before its upgrade completes, so that stub stays on disk. The later "seed install" into the same home was therefore not the fresh install it reads as: it found a stale component to replace, ran the pre-upgrade stop, was refused, had no terminal to confirm on, and aborted with exit 1 — failing two assertions that are correct. Move the six live-session homes to their own HL prefix, following the HAA_/HD/HU families already in the file, and record why they must not rejoin the plain H<n> run. No installer behaviour changes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
scripts/install_test.sh (1)
668-678: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueOnly one
--force-stopordering is tested.HL6 only covers
target --force-stop; there's no case for--force-stop target, even though R2.1 explicitly requires the flag be "recognized wherever it appears in the arguments." Worth a symmetric case for regression coverage.➕ Suggested additional case
+HL7="$root/hl7"; mkdir -p "$HL7" +stage_live_upgrade "$HL7" liveforceprepos +run liveforceprepos "$HL7" --force-stop unstable +check 0 "$rc" "--force-stop before target exits 0 (R5.5/R2.1)" +want_ok "the target survives when the flag comes first (R2.1)" grep -q "target 'unstable'" "$OUT" +want_ok "the leading flag still force-stops (R5.5)" grep -qx "stop --force" "$HL7/stop.calls"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@scripts/install_test.sh` around lines 668 - 678, Extend the install test coverage around stage_live_upgrade to add the symmetric argument-order case with --force-stop before the target. Use a separate mock upgrade directory or reset its state, invoke the command as --force-stop followed by unstable, and assert the same successful exit, target selection, and forced-stop behavior covered by the existing HL6 case.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@scripts/install_test.sh`:
- Around line 668-678: Extend the install test coverage around
stage_live_upgrade to add the symmetric argument-order case with --force-stop
before the target. Use a separate mock upgrade directory or reset its state,
invoke the command as --force-stop followed by unstable, and assert the same
successful exit, target selection, and forced-stop behavior covered by the
existing HL6 case.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 24341da6-c026-4287-8bb0-03001ef450f2
📒 Files selected for processing (5)
crates/common/tests/installer_stop_signal.rsdocs/guide/install.mddocs/specs/07-spec-installer/07-spec-installer.mdscripts/install.shscripts/install_test.sh
Root cause
scripts/install.sh:535(onmain) unconditionally force-stopped the daemon before the firstexecutable swap:
Called from
scripts/install.sh:650, before themv -fof the firstbin/libcomponent. So acurl … | shupgrade destroyed every live session with no signal and no way out. The daemon-sidemitigation ("minimald is shutting down and disconnecting") does not help: it is printed by the
daemon being replaced, so it only exists if you already upgraded past the release that added it,
and it is a notice after the fact rather than a decision point.
min stopalready knows the answer —crates/minimal/src/lib.rs:2137:The installer never asked it. It went straight to
--force.Fix
The pre-upgrade stop is now graceful-first (
scripts/install.sh:597):Design choices and why:
sessions_live_msg,scripts/install.sh:567).A bare non-zero from
min stopalso means no daemon, a failed connect, amintoo old to knowthe subcommand, or a transport drop on an otherwise successful stop. Gating on the exit code
would turn every upgrade into a prompt and would fail installs that are otherwise fine. Only the
active-sessions refusal escalates; everything else keeps today's silent, best-effort behaviour
and still falls through to the force stop.
confirm_force_stop,scripts/install.sh:576). Undercurl … | shstdin is the script; reading it would consume theinstaller. Inside the component loop stdin is the applicable-manifest file, which is worse.
${MINIMAL_OVERRIDE_TTY:-/dev/tty}is the source; the harness overrides it.(exec <"$_tty") 2>/dev/nullprobesopenability; if there is nobody to consent the run exits non-zero naming the escape hatch.
--force-stopor a non-emptyMINIMAL_INSTALL_FORCE_STOP(
scripts/install.sh:74,:97). Deliberately not--force, which already means "removemodified files too" in uninstall mode. The flag is filtered out of
"$@"wherever it appears, sothe target stays the sole positional.
scripts/install.sh:736), dropping thetemp download; no record is written. The message says
no executables were replacedrather than"nothing was installed" — the stop fires at the first
bin/librow, so adatarow orderedahead of it in the manifest may already have been replaced, and the message claims only what the
abort point actually guarantees.
The graceful-first path is also a strict improvement on the healthy case: an upgrade with no
sessions now stops cleanly and never force-stops at all.
crates/common/tests/installer_stop_signal.rspins the cross-language coupling: it readsscripts/install.shandcrates/minimal/src/lib.rsas text and asserts the installer's literal isstill present in the CLI source. Without it, rewording the
bail!silently returns the installer toan unconditional force-stop — the exact bug this PR fixes — with every installer test still green,
because the harness stubs
minwith its own copy of the string.Acceptance
min stopis attempted first, and its signal is reused rather than a parallelsession check —
scripts/install_test.sh:535(H8daemonupgrade): asserts the on-diskminis called with exactly
stop, once, however many components are replaced, never escalating to--force, and asking nothing.scripts/install_test.sh:601(H16): assertssession-alphaappears in the output and thatContinue?is asked.min stop --forceand continues the upgrade —scripts/install_test.sh:601(H16, answery): assertsstopthenstop --forceinstop.callsand that the replaced component hashes to the manifest value.scripts/install_test.sh:616(H17, answern): exit 1,stop --forcenever called, the stale component still on disk, no
*.tmp.*left behind, and the abort reportsno executable was replaced.
scripts/install_test.sh:631(H18, noopenable terminal: exits non-zero naming the hatch, never prompts, never forces, installs
nothing);
:642(H19,--force-stop: skips the graceful stop, asks nothing, completes);:652(H20,
MINIMAL_INSTALL_FORCE_STOP, for a pipeline with no argv);:666(H21, flag after thetarget: the target still resolves).
min stopfailure may prompt or fail the install —scripts/install_test.sh:572(H9, amintoo old, exiting 2 and writing to both streams): exit0, neither stream leaked, nothing asked, nothing listed, the upgrade completed, and it still fell
through to
stop --force.crates/common/tests/installer_stop_signal.rs.Non-vacuity was checked by mutation, not assumed. Against a scratch copy: reverting
stop_running_daemonto the unconditional force-stop → 13 failures; reading the answer from stdininstead of
/dev/tty→ 3 failures; treating any non-zero stop as "sessions live" → 6 failures(including H9's "a failing
min stopdoes not fail the install"); dropping every positional in theoption filter → 7 failures, one of them H21's
target 'unstable'; rewordingsessions_live_msg→installer_stop_signalfails with the intended message.Test plan
Run on macOS arm64 (Darwin 25.4.0), all green on the committed tree:
just test-installeris the same gateci-shell-installerruns, and the lane triggers: bothinstaller paths are in its path filter.
Delegated to CI (cannot run on macOS —
minimalddoes not build here, so the workspace test suiteis out of reach locally, per AGENTS.md "Platform matrix"):
ci-linux-native/ci-linux-kvm— the workspace test run, which is what actually executescrates/common/tests/installer_stop_signal.rsin CI. Verified locally withcargo test -p common;commonhas nominimalddependency, so this one is genuinely runnableon macOS and was run.
ci— workspace rustfmt (run locally) and workspace clippy (only-p commonrunnable here; thenew file is the only Rust change and it is in
common).ci-macos— its scope is-p minvmd -p sessions; no Rust in either crate changed.No CI lane executes
install.shitself (release.ymlonly uploads it;nightly.ymlhand-assemblesthe layout it would write), so no lane can newly block on the prompt.
Notes
MINIMAL_INSTALL_FORCE_STOP=0counts as "on" because the check is-n. Real footgun, but"non-empty" is the convention this file already uses for its env overrides
(
MINIMAL_INSTALL_TARGET_OVERRIDE,scripts/install.sh:499) and it is documented as non-emptyin the usage header, the spec, and the guide. Changing the semantics of a knob is a separate,
deliberate decision, not review cleanup.
curl … | sh &) that still has a controlling terminal passes theopenability probe and would then take SIGTTIN on the
read, stopping rather than aborting.There is no portable way to detect foreground process-group membership in POSIX sh, and
[ -t 0 ]is wrong undercurl | sh. The documented escape hatch is the way out. Related tothe SIGTTOU footgun already recorded in AGENTS.md.
docs/specs/07-spec-installer/07-spec-installer.mdR5.5 was rewritten to describe the newbehaviour, plus a new user story under R2.1 for
--force-stop. Spec prose is functionalthroughout — no issue numbers, PR numbers, or names.
.github/workflows/is untouched.Closes gominimal/inbox#366
Note
Ask for confirmation before stopping live sessions during upgrade in install.sh
min ls, opens/dev/tty, and prompts the user before issuing a force stop.--force-stopflag andMINIMAL_INSTALL_FORCE_STOPenv var to skip the prompt and proceed unconditionally.sessions_live_msgin install.sh no longer matches the string in crates/minimal/src/lib.rs.Macroscope summarized 6f5beae.
Summary by CodeRabbit
New Features
--force-stopandMINIMAL_INSTALL_FORCE_STOPoptions for scripted or non-interactive upgrades.Documentation
Tests