Skip to main content

agentmux_launcher/
upgrade.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Phase 1 of the fast-startup redesign
5//! (`docs/specs/SPEC_FAST_STARTUP_UPGRADE_OWNS_MIGRATIONS_AND_UPDATES_2026_09_15.md`,
6//! tracked by issue #3258) — the primitives a "quiesce srv, run pending
7//! migrations, come back" upgrade action needs, built and tested in
8//! isolation ahead of wiring them into a live caller.
9//!
10//! WHY THIS IS SPLIT FROM ITS EVENTUAL CALLER. The spec's §4.3 sequence
11//! (maintenance state → quiesce → snapshot → migrate → relaunch) has two
12//! halves with very different risk profiles: the actual quiesce/migrate
13//! logic below is a self-contained, thoroughly-testable async function; the
14//! "suspend the live supervisor loop's respawn/teardown handling" half
15//! means editing `supervisor/windows.rs`'s and `supervisor/unix.rs`'s
16//! `tokio::select!` state machines — ~700- and ~1200-line functions this
17//! repo has no way to exercise end-to-end outside a real multi-process
18//! launcher run (see the I1-I6 isolation invariants in
19//! `docs/specs/SPEC_MULTI_INSTANCE_ISOLATION_HARDENING_2026_06_03.md`;
20//! this is exactly the surface they exist to protect). §4.2 also hasn't landed yet, which is what
21//! decides WHEN this runs at all — an upgrade-only srv mode most likely
22//! means srv is never handed to the normal supervised loop as a
23//! respawn-on-exit child in the first place, so the "suspend the loop"
24//! problem may look completely different once that exists. Wiring this into
25//! today's loops now would be guessing at an integration shape that phase 2
26//! gets to actually decide. This module is deliberately the callable,
27//! tested half; the calling half is follow-up work once §4.2 lands.
28//! ([`quiesce_srv`] itself is live: both supervisors stop srv with it on
29//! quit, after their loops have ended, so no respawn handling is involved.)
30//!
31//! Constraint 2 from the spec — "migrations only run with no live srv
32//! holding that data dir" (`341faa981`'s data-loss incident) — is what
33//! [`quiesce_srv`] exists to guarantee: it returns only once the OS has
34//! genuinely reaped the process (`Child::wait()`'s own contract), replacing
35//! `crates/cef/src/commands/backend.rs`'s dev-mode-only 300ms sleep
36//! heuristic with a real wait for real exit.
37//!
38//! No snapshot step lives here on purpose: `agentmux-srv migrate`'s own
39//! `apply_pending` (shared with the in-process daemon path) already takes
40//! its own backup under the same cross-process migration lock before
41//! applying anything (`crates/srv/src/migrations/runner.rs`'s
42//! `backup_stores` call) — a launcher-side snapshot would be redundant with,
43//! not a replacement for, that one. The spec's §4.3 step 3 was written
44//! assuming the launcher would re-implement what `bootstrap/stores.rs` does for
45//! the in-process path; going through the CLI subcommand instead means that
46//! safety property already exists on this path.
47
48use std::path::Path;
49use std::time::Duration;
50
51use tokio::process::Child;
52
53use crate::data_dir::DataPaths;
54use crate::srv_spawner::{run_migrate, SrvSpawnError};
55use crate::startup_events::StartupEventSink;
56
57/// How long [`quiesce_srv`] waits for srv to exit on its own before
58/// escalating to a hard kill: long enough for srv to close every agent
59/// (`agent_teardown::app_exit`, capped at `SRV_APP_EXIT_CAP`).
60pub const GRACEFUL_QUIESCE_TIMEOUT: Duration = agentmux_common::process::SRV_EXIT_WAIT;
61
62/// How [`quiesce_srv`] actually stopped the process — logged, and useful in
63/// tests, but callers don't need to branch on it: either variant means the
64/// same thing (the process is genuinely gone).
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum QuiesceOutcome {
67    /// The child had already exited before this call (e.g. a crash that
68    /// raced the upgrade request).
69    AlreadyExited,
70    /// Exited on its own within the graceful window, after being told to.
71    ExitedGracefully,
72    /// Didn't exit in time and was force-killed.
73    ExitedAfterForceKill,
74}
75
76/// Stop srv and return only once it has genuinely exited — never a fixed
77/// sleep. The launcher's one way of stopping srv: on every quit (both
78/// supervisors) and before a migration ([`run_migration_upgrade`]).
79///
80/// Telling srv to stop is dropping `stdin`, its keepalive pipe: srv reads
81/// EOF as "shut down" on every platform (`bootstrap::install_shutdown_handlers`)
82/// and closes every agent gracefully on the way out
83/// (`agent_teardown::app_exit`). Unix also gets SIGTERM, which does the same.
84/// Only if srv hasn't exited after `graceful_timeout` is it force-killed —
85/// on Windows, whatever it still runs then goes with J0.
86pub async fn quiesce_srv(
87    child: &mut Child,
88    stdin: Option<tokio::process::ChildStdin>,
89    graceful_timeout: Duration,
90) -> std::io::Result<(std::process::ExitStatus, QuiesceOutcome)> {
91    if let Ok(Some(status)) = child.try_wait() {
92        return Ok((status, QuiesceOutcome::AlreadyExited));
93    }
94    drop(stdin);
95    #[cfg(not(target_os = "windows"))]
96    crate::host_spawn::terminate_child_gracefully(child);
97    match tokio::time::timeout(graceful_timeout, child.wait()).await {
98        Ok(status) => Ok((status?, QuiesceOutcome::ExitedGracefully)),
99        Err(_elapsed) => {
100            child.start_kill()?;
101            let status = child.wait().await?;
102            Ok((status, QuiesceOutcome::ExitedAfterForceKill))
103        }
104    }
105}
106
107/// Why [`run_migration_upgrade`] didn't complete.
108#[derive(Debug)]
109pub enum UpgradeError {
110    /// Stopping the running srv failed at the OS level (not "srv refused to
111    /// stop" — [`quiesce_srv`] always eventually forces that; this is e.g.
112    /// a `wait()` syscall failure).
113    Quiesce(std::io::Error),
114    /// The migration subprocess itself failed — see [`SrvSpawnError`] for
115    /// which case (spawn failure, a real `AGENTMUXSRV-MIGRATION-FAILED`,
116    /// etc). The data is left exactly as `agentmux-srv migrate` left it:
117    /// possibly partially migrated (no batch transaction — see the spec's
118    /// §4.3 "partial application" note), with a pre-migration backup
119    /// already written under `~/.agentmux/shared/backups/`.
120    Migrate(SrvSpawnError),
121}
122
123impl std::fmt::Display for UpgradeError {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        match self {
126            Self::Quiesce(e) => write!(f, "failed to stop the running srv: {e}"),
127            Self::Migrate(e) => write!(f, "migration failed: {e}"),
128        }
129    }
130}
131
132/// Quiesce the running srv, then run pending migrations via the
133/// `agentmux-srv migrate` subprocess. Does NOT relaunch srv afterward and
134/// does NOT install a staged app update first — both are the caller's
135/// responsibility once one exists (§4.2's boot gate for the former;
136/// `app-update-check.md`'s still-unimplemented `install_update` for the
137/// latter, per the spec's §5.4 ordering — a staged update must be installed
138/// BEFORE this runs, so its migrations are the ones that end up applied).
139///
140/// `srv_child` must be the handle for the ALREADY-RUNNING srv this upgrade
141/// is replacing — this function does not spawn or discover it — and
142/// `srv_stdin` its stdin keepalive (see [`quiesce_srv`]).
143// No caller until phase 1b of SPEC_FAST_STARTUP_UPGRADE_OWNS_MIGRATIONS_AND_UPDATES_2026_09_15.md §8.
144#[allow(dead_code)]
145pub async fn run_migration_upgrade(
146    launcher_exe_dir: &Path,
147    paths: &DataPaths,
148    srv_child: &mut Child,
149    srv_stdin: Option<tokio::process::ChildStdin>,
150    sink: &StartupEventSink,
151) -> Result<(), UpgradeError> {
152    let (status, outcome) = quiesce_srv(srv_child, srv_stdin, GRACEFUL_QUIESCE_TIMEOUT)
153        .await
154        .map_err(UpgradeError::Quiesce)?;
155    crate::log(&format!(
156        "upgrade: srv quiesced ({:?}, exit={:?}) — starting migration",
157        outcome, status
158    ));
159
160    run_migrate(launcher_exe_dir, paths, sink)
161        .await
162        .map_err(UpgradeError::Migrate)
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    #[cfg(not(target_os = "windows"))]
169    use tokio::io::{AsyncBufReadExt, BufReader};
170
171    /// Portable "runs for a while, does nothing" child: `sleep` on Unix,
172    /// `ping` on Windows (`timeout.exe` refuses to run with no console —
173    /// "Input redirection is not supported" — the standard Windows-
174    /// automation workaround for a long-lived, headless-safe dummy process
175    /// is a ping loop against loopback, which never needs a console).
176    fn spawn_long_lived() -> Child {
177        #[cfg(not(target_os = "windows"))]
178        {
179            tokio::process::Command::new("sleep")
180                .arg("30")
181                .kill_on_drop(true)
182                .spawn()
183                .expect("spawn sleep")
184        }
185        #[cfg(target_os = "windows")]
186        {
187            tokio::process::Command::new("cmd")
188                .args(["/C", "ping", "-n", "30", "127.0.0.1"])
189                .kill_on_drop(true)
190                .spawn()
191                .expect("spawn ping")
192        }
193    }
194
195    /// A child that ignores SIGTERM, forcing `quiesce_srv`'s escalation
196    /// path (it has no stdin to close). Unix only: Windows sends no signal
197    /// to ignore, so its escalation path is covered by the live-child test.
198    ///
199    /// Blocks until the child confirms its trap is actually installed
200    /// before returning — sending SIGTERM immediately after `spawn()` races
201    /// the shell's own startup (parse `-c`, run `trap`, THEN start `sleep`),
202    /// and on a loaded CI runner that race is real: a signal arriving
203    /// before `trap ''` executes finds SIGTERM at its default disposition
204    /// and the shell dies immediately, which is exactly the CI-only flake
205    /// this fixes (`ExitedGracefully` instead of `ExitedAfterForceKill`,
206    /// ubuntu-latest, PR #3352). Piping stdout and reading one line back is
207    /// a real synchronization point, not a fixed delay that would just
208    /// narrow the window instead of closing it.
209    #[cfg(not(target_os = "windows"))]
210    async fn spawn_sigterm_immune() -> Child {
211        let mut child = tokio::process::Command::new("sh")
212            .args(["-c", "trap '' TERM; echo ready; sleep 30"])
213            .stdout(std::process::Stdio::piped())
214            .kill_on_drop(true)
215            .spawn()
216            .expect("spawn sigterm-immune shell");
217
218        let stdout = child.stdout.take().expect("piped stdout");
219        let mut lines = BufReader::new(stdout).lines();
220        let line = lines
221            .next_line()
222            .await
223            .expect("read readiness line")
224            .expect("readiness line present");
225        assert_eq!(line.trim(), "ready", "trap must be installed before the test signals this child");
226
227        child
228    }
229
230    #[tokio::test]
231    async fn already_exited_child_is_reported_without_signaling_anything() {
232        let mut child = tokio::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
233            .args(if cfg!(windows) { vec!["/C", "exit 0"] } else { vec![] })
234            .kill_on_drop(true)
235            .spawn()
236            .expect("spawn a trivially-exiting child");
237        // Let it actually exit and get reaped before quiescing.
238        let _ = tokio::time::timeout(Duration::from_secs(5), child.wait())
239            .await
240            .expect("child did not exit");
241
242        let (status, outcome) = quiesce_srv(&mut child, None, Duration::from_secs(1))
243            .await
244            .expect("quiesce_srv must not error on an already-exited child");
245
246        assert_eq!(outcome, QuiesceOutcome::AlreadyExited);
247        assert!(status.success());
248    }
249
250    #[tokio::test]
251    async fn a_live_child_is_actually_stopped_and_reaped() {
252        // The core guarantee this function exists for: not a sleep, a real
253        // wait for a real exit. A long-lived process that were merely
254        // signaled-and-assumed-dead (the bug this replaces) would still be
255        // running here; this asserts the OS has actually reaped it.
256        let mut child = spawn_long_lived();
257        let pid = child.id().expect("live child has a pid");
258
259        // A short graceful window: on Windows nothing here tells the dummy
260        // to stop, so it is force-killed when the window ends.
261        let (_status, _outcome) = tokio::time::timeout(
262            Duration::from_secs(15),
263            quiesce_srv(&mut child, None, Duration::from_secs(1)),
264        )
265        .await
266        .expect("quiesce_srv hung instead of returning once the child exited")
267        .expect("quiesce_srv errored");
268
269        // A second wait on the same handle must not hang or find a live
270        // process — the child is genuinely gone, not merely signaled.
271        assert!(
272            tokio::time::timeout(Duration::from_millis(500), child.wait())
273                .await
274                .is_ok(),
275            "pid {pid} should already be reaped after quiesce_srv returned"
276        );
277    }
278
279    #[cfg(not(target_os = "windows"))]
280    #[tokio::test]
281    async fn a_sigterm_ignoring_child_is_force_killed_after_the_timeout() {
282        use std::os::unix::process::ExitStatusExt;
283
284        let mut child = spawn_sigterm_immune().await;
285
286        let (status, outcome) = tokio::time::timeout(
287            Duration::from_secs(10),
288            // Short graceful window so the test doesn't wait the full
289            // production 10s to prove the escalation path.
290            quiesce_srv(&mut child, None, Duration::from_millis(300)),
291        )
292        .await
293        .expect("quiesce_srv hung past its own escalation timeout")
294        .expect("quiesce_srv errored");
295
296        assert_eq!(outcome, QuiesceOutcome::ExitedAfterForceKill);
297        assert_eq!(status.signal(), Some(libc::SIGKILL));
298    }
299
300    /// Closing stdin is the stop signal on every platform (it is the only
301    /// one on Windows): a child that ignores SIGTERM but exits on stdin EOF,
302    /// as srv does, exits gracefully.
303    #[cfg(not(target_os = "windows"))]
304    #[tokio::test]
305    async fn closing_stdin_stops_a_child_that_ignores_sigterm() {
306        let mut child = tokio::process::Command::new("sh")
307            .args(["-c", "trap '' TERM; echo ready; cat >/dev/null"])
308            .stdin(std::process::Stdio::piped())
309            .stdout(std::process::Stdio::piped())
310            .kill_on_drop(true)
311            .spawn()
312            .expect("spawn stdin-reading shell");
313        let stdin = child.stdin.take();
314        let stdout = child.stdout.take().expect("piped stdout");
315        let line = BufReader::new(stdout).lines().next_line().await.expect("read").expect("line");
316        assert_eq!(line.trim(), "ready");
317
318        let (status, outcome) = tokio::time::timeout(
319            Duration::from_secs(10),
320            quiesce_srv(&mut child, stdin, Duration::from_secs(5)),
321        )
322        .await
323        .expect("quiesce_srv hung")
324        .expect("quiesce_srv errored");
325
326        assert_eq!(outcome, QuiesceOutcome::ExitedGracefully);
327        assert!(status.success());
328    }
329
330    #[cfg(not(target_os = "windows"))]
331    #[tokio::test]
332    async fn a_child_that_honors_sigterm_exits_gracefully_not_via_force_kill() {
333        // Plain `sleep` has no signal handler — the default SIGTERM action
334        // is immediate termination, so this should resolve almost
335        // instantly, well inside the graceful window, and never reach the
336        // force-kill branch.
337        let mut child = spawn_long_lived();
338
339        let (_status, outcome) = tokio::time::timeout(
340            Duration::from_secs(5),
341            quiesce_srv(&mut child, None, GRACEFUL_QUIESCE_TIMEOUT),
342        )
343        .await
344        .expect("quiesce_srv hung")
345        .expect("quiesce_srv errored");
346
347        assert_eq!(outcome, QuiesceOutcome::ExitedGracefully);
348    }
349}