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}