Skip to content

feat(installer): ask before an upgrade ends live sessions - #1010

Merged
norrietaylor merged 4 commits into
mainfrom
feat/inbox-366-installer-active-session-prompt
Jul 29, 2026
Merged

feat(installer): ask before an upgrade ends live sessions#1010
norrietaylor merged 4 commits into
mainfrom
feat/inbox-366-installer-active-session-prompt

Conversation

@norrietaylor

@norrietaylor norrietaylor commented Jul 29, 2026

Copy link
Copy Markdown
Member

Root cause

scripts/install.sh:535 (on main) unconditionally force-stopped the daemon before the first
executable swap:

"$bindir/min" stop --force >/dev/null 2>&1 || true

Called from scripts/install.sh:650, before the mv -f of the first bin/lib component. So a
curl … | sh upgrade destroyed every live session with no signal and no way out. The daemon-side
mitigation ("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 stop already knows the answer — crates/minimal/src/lib.rs:2137:

bail!("daemon has active sessions; pass --force to shut down anyway")

The installer never asked it. It went straight to --force.

Fix

The pre-upgrade stop is now graceful-first (scripts/install.sh:597):

if _stop_out="$("$bindir/min" stop 2>&1 </dev/null)"; then
    return 0
fi
case "$_stop_out" in
    *"$sessions_live_msg"*) confirm_force_stop || return 1 ;;
esac
# Any other failure falls through to the force stop, silent as before.

Design choices and why:

  • Matched on the message, not the exit status (sessions_live_msg, scripts/install.sh:567).
    A bare non-zero from min stop also means no daemon, a failed connect, a min too old to know
    the 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.
  • Answer read from the controlling terminal, never stdin (confirm_force_stop,
    scripts/install.sh:576). Under curl … | sh stdin is the script; reading it would consume the
    installer. 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.
  • No terminal means abort, not force and not hang. (exec <"$_tty") 2>/dev/null probes
    openability; if there is nobody to consent the run exits non-zero naming the escape hatch.
  • Escape hatch is --force-stop or a non-empty MINIMAL_INSTALL_FORCE_STOP
    (scripts/install.sh:74, :97). Deliberately not --force, which already means "remove
    modified files too" in uninstall mode. The flag is filtered out of "$@" wherever it appears, so
    the target stays the sole positional.
  • Declining aborts before the first executable swap (scripts/install.sh:736), dropping the
    temp download; no record is written. The message says no executables were replaced rather than
    "nothing was installed" — the stop fires at the first bin/lib row, so a data row ordered
    ahead 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.rs pins the cross-language coupling: it reads
scripts/install.sh and crates/minimal/src/lib.rs as text and asserts the installer's literal is
still present in the CLI source. Without it, rewording the bail! silently returns the installer to
an unconditional force-stop — the exact bug this PR fixes — with every installer test still green,
because the harness stubs min with its own copy of the string.

Acceptance

  • Graceful min stop is attempted first, and its signal is reused rather than a parallel
    session check
    scripts/install_test.sh:535 (H8 daemonupgrade): asserts the on-disk min
    is called with exactly stop, once, however many components are replaced, never escalating to
    --force, and asking nothing.
  • An active-sessions refusal lists what is running and prompts
    scripts/install_test.sh:601 (H16): asserts session-alpha appears in the output and that
    Continue? is asked.
  • Only explicit confirmation falls through to min stop --force and continues the upgrade
    scripts/install_test.sh:601 (H16, answer y): asserts stop then stop --force in
    stop.calls and that the replaced component hashes to the manifest value.
  • Declining abortsscripts/install_test.sh:616 (H17, answer n): exit 1, stop --force
    never called, the stale component still on disk, no *.tmp.* left behind, and the abort reports
    no executable was replaced.
  • Non-interactive / CI invocations do not hangscripts/install_test.sh:631 (H18, no
    openable 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 the
    target: the target still resolves).
  • No other min stop failure may prompt or fail the install
    scripts/install_test.sh:572 (H9, a min too old, exiting 2 and writing to both streams): exit
    0, neither stream leaked, nothing asked, nothing listed, the upgrade completed, and it still fell
    through to stop --force.
  • The signal cannot rot silentlycrates/common/tests/installer_stop_signal.rs.

Non-vacuity was checked by mutation, not assumed. Against a scratch copy: reverting
stop_running_daemon to the unconditional force-stop → 13 failures; reading the answer from stdin
instead of /dev/tty → 3 failures; treating any non-zero stop as "sessions live" → 6 failures
(including H9's "a failing min stop does not fail the install"); dropping every positional in the
option filter → 7 failures, one of them H21's target 'unstable'; rewording sessions_live_msg
installer_stop_signal fails with the intended message.

Test plan

Run on macOS arm64 (Darwin 25.4.0), all green on the committed tree:

/opt/homebrew/bin/shellcheck scripts/install.sh scripts/install_test.sh
  → clean

/opt/homebrew/bin/just test-installer
  → shellcheck --shell=sh: clean
  → install_test.sh under sh   (macOS bash 3.2 in POSIX mode): 244 passed, 0 failed
  → install_test.sh under dash:                                244 passed, 0 failed

/opt/homebrew/bin/just fmt-check          → clean
cargo clippy -p common --all-targets -- -D warnings → clean
cargo test -p common                      → 44 + 1 + 1 passed, 0 failed
  (includes tests/installer_stop_signal.rs and the pre-existing shell_lint gate)

just test-installer is the same gate ci-shell-installer runs, and the lane triggers: both
installer paths are in its path filter.

Delegated to CI (cannot run on macOS — minimald does not build here, so the workspace test suite
is out of reach locally, per AGENTS.md "Platform matrix"):

  • ci-linux-native / ci-linux-kvm — the workspace test run, which is what actually executes
    crates/common/tests/installer_stop_signal.rs in CI. Verified locally with
    cargo test -p common; common has no minimald dependency, so this one is genuinely runnable
    on macOS and was run.
  • ci — workspace rustfmt (run locally) and workspace clippy (only -p common runnable here; the
    new 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.sh itself (release.yml only uploads it; nightly.yml hand-assembles
the layout it would write), so no lane can newly block on the prompt.

Notes

  • Two reviewer findings were deliberately not applied:
    • MINIMAL_INSTALL_FORCE_STOP=0 counts 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-empty
      in the usage header, the spec, and the guide. Changing the semantics of a knob is a separate,
      deliberate decision, not review cleanup.
    • A backgrounded pipeline (curl … | sh &) that still has a controlling terminal passes the
      openability 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 under curl | sh. The documented escape hatch is the way out. Related to
      the SIGTTOU footgun already recorded in AGENTS.md.
  • docs/specs/07-spec-installer/07-spec-installer.md R5.5 was rewritten to describe the new
    behaviour, plus a new user story under R2.1 for --force-stop. Spec prose is functional
    throughout — 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

  • When an upgrade detects active sessions, the installer now lists them via min ls, opens /dev/tty, and prompts the user before issuing a force stop.
  • Adds --force-stop flag and MINIMAL_INSTALL_FORCE_STOP env var to skip the prompt and proceed unconditionally.
  • If no controlling terminal is available and sessions are live, the installer aborts without replacing any executables, naming the escape hatch.
  • Graceful stop is attempted first; only if the CLI refuses with the active-sessions message does the prompt appear. Other failures fall through to a silent force stop.
  • Adds a cross-file sync test that fails if sessions_live_msg in install.sh no longer matches the string in crates/minimal/src/lib.rs.

Macroscope summarized 6f5beae.

Summary by CodeRabbit

  • New Features

    • Upgrades now detect active daemon sessions and ask for confirmation before stopping them.
    • Added --force-stop and MINIMAL_INSTALL_FORCE_STOP options for scripted or non-interactive upgrades.
    • Upgrades can proceed without prompts when forced; declined or unavailable confirmations safely abort before replacement.
  • Documentation

    • Updated installation guidance and specifications to explain session handling and forced upgrades.
  • Tests

    • Added coverage for interactive, non-interactive, forced, declined, and target-selection upgrade scenarios.

norrietaylor and others added 2 commits July 28, 2026 18:10
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>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The installer now attempts graceful daemon shutdowns during upgrades, prompts before force-stopping active sessions, supports --force-stop and MINIMAL_INSTALL_FORCE_STOP, and adds specifications, documentation, shell scenarios, and a cross-file CLI message contract test.

Changes

Session-safe upgrade flow

Layer / File(s) Summary
Upgrade contract and guidance
docs/specs/07-spec-installer/07-spec-installer.md, docs/guide/install.md
Specifications and installation guidance document active-session prompts, force-stop controls, target parsing, and upgrade outcomes.
Installer stop and replacement gate
scripts/install.sh
The installer parses force-stop controls, performs graceful stops, prompts through the controlling terminal for active sessions, and aborts before executable replacement when stopping is declined or unavailable.
Stop-flow scenario validation
scripts/install_test.sh
Installer scenarios cover terminal confirmation, declines, missing terminals, non-session failures, force-stop controls, stop invocations, and positional target preservation.
CLI refusal signal contract
crates/common/tests/installer_stop_signal.rs
An integration test verifies that the installer’s active-session sentinel matches text present in the Minimal CLI source.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • gominimal/inbox#366 — The PR implements its proposal for graceful-stop detection, interactive prompting, and explicit force-stop controls.

Possibly related PRs

  • gominimal/minimal#768 — Both changes update the installer upgrade path to stop the daemon before swapping executable components.

Suggested reviewers: twitchyliquid64

Poem

A rabbit watched the sessions glow,
“Ask before the bytes must go.”
Graceful stops, then prompts appear,
Force flags make the path clear.
Safe upgrades hop along—
No surprise disconnections in the song!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy #366 by prompting on active sessions, supporting non-interactive override, and preserving the graceful-stop workflow.
Out of Scope Changes check ✅ Passed The added docs, spec updates, and tests are directly related to the installer behavior change.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title is concise and clearly reflects the installer change to confirm before stopping live sessions during upgrades.
Description check ✅ Passed The description is detailed and covers the reason, fix, testing, and acceptance criteria, even though it doesn't use the template's exact Summary/Testing/Checklist headings.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

norrietaylor and others added 2 commits July 28, 2026 18:52
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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
scripts/install_test.sh (1)

668-678: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Only one --force-stop ordering 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

📥 Commits

Reviewing files that changed from the base of the PR and between 5aba97a and 6f5beae.

📒 Files selected for processing (5)
  • crates/common/tests/installer_stop_signal.rs
  • docs/guide/install.md
  • docs/specs/07-spec-installer/07-spec-installer.md
  • scripts/install.sh
  • scripts/install_test.sh

@norrietaylor
norrietaylor merged commit 0f08972 into main Jul 29, 2026
31 checks passed
@norrietaylor
norrietaylor deleted the feat/inbox-366-installer-active-session-prompt branch July 29, 2026 04:37
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.

2 participants