agentmux_launcher/supervisor/unix.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::host_spawn::{spawn_host_unix, terminate_child_gracefully};
5use crate::logging::log;
6use crate::second_instance::bind_socket_with_recovery;
7use crate::show_fatal_dialog;
8use crate::supervisor::{HOST_RESTART_BUDGET, HOST_RESTART_WINDOW};
9use crate::{
10 data_dir, event_log, hash, host_pipe, ipc, mem_supervisor, other_instances, saga,
11 srv_spawner, startup_events, state, tray,
12};
13
14/// Await the next delivery of a Unix signal, or never resolve if the
15/// signal stream couldn't be installed. Lets `run_unix`'s `select!`
16/// treat an absent handler as "this branch is dormant" rather than
17/// special-casing `Option` at every poll.
18#[cfg(not(target_os = "windows"))]
19async fn next_signal(s: &mut Option<tokio::signal::unix::Signal>) {
20 match s {
21 Some(sig) => {
22 sig.recv().await;
23 }
24 None => std::future::pending::<()>().await,
25 }
26}
27
28/// Unix (macOS/Linux) main flow: resolve paths → bind launcher IPC
29/// socket (single-instance signal) → set up reducer / event log / saga
30/// coordinator / IPC server → spawn srv → spawn host with srv endpoints
31/// AND the launcher socket path in env → supervised wait → cleanup.
32///
33/// As of A1 (SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_2026_06_05.md §4)
34/// the launcher's window/pool/instance reducer + durable saga
35/// coordinator are now live on Linux. The 17 host-side `report_*` IPC
36/// calls reach the reducer; the saga log persists at
37/// `<data-dir>/db/launcher-sagas.db`; second-instance launches are
38/// detected via socket-bind contention.
39///
40/// Differs from `run_windows` only where the OS forces it:
41/// * No Job Object — A0 (`PR_SET_PDEATHSIG` in `spawn_host_unix` and
42/// `srv_spawner`) gives the equivalent kernel-side reap when the
43/// launcher dies abnormally.
44/// * As of SPEC_LAUNCHER_TEARDOWN_BACKSTOP_2026_07_11 unix parity, host
45/// and srv are each spawned into their OWN process group
46/// (`.process_group(0)`) rather than inheriting the launcher's — the
47/// prerequisite for `kill_process_group_forcefully`'s
48/// `kill(-pgid, SIGKILL)` to be scoped to exactly what THIS launcher
49/// spawned, never the launcher's own group. Side effect (reagent P1,
50/// PR #2200): **a terminal Ctrl+C no longer reaches host/srv
51/// directly** — only the launcher (still in the terminal's foreground
52/// group) receives that SIGINT. Host/srv now die exclusively via the
53/// launcher's own explicit `terminate_child_gracefully` SIGTERM in the
54/// cleanup epilogue below (after the launcher's own SIGINT arm has
55/// already won the `select!` and broken the loop), never by an
56/// independent signal race inside the loop. See the (now narrower)
57/// group-shutdown guard comments on the `host_child.wait()` /
58/// `srv_child.wait()` arms for what scenario they still cover.
59/// `kill <launcher-pid>` is unaffected — the launcher's own
60/// SIGINT/SIGTERM handlers still catch it and drive the same cleanup.
61/// * Unix-domain-socket IPC (A1) instead of named pipes; protocol on
62/// the wire is identical (newline-delimited JSON Command/Event).
63/// The launcher-side server uses `tokio::net::UnixListener`; the
64/// host-side client uses `tokio::net::UnixStream`. See
65/// `ipc::server::run_ipc_server` (Unix arm) and
66/// `crates/cef/src/launcher_ipc/mod.rs::connect_to_launcher` (Unix arm).
67/// * srv-side IPC is still skipped on Unix (srv is launched with an
68/// empty `srv_pipe_path`); follow-up PR will bring srv's Unix
69/// socket online too.
70/// * Cleanup is SIGTERM-then-SIGKILL (we own the reap) rather than
71/// KILL_ON_JOB_CLOSE.
72///
73/// srv's stdin write-end is held for the launcher's lifetime — its EOF is
74/// srv's parent-death backstop if the launcher is SIGKILLed (the one case
75/// our signal handlers can't cover).
76#[cfg(not(target_os = "windows"))]
77pub(crate) async fn run_unix(
78 launcher_exe_dir: &std::path::Path,
79 real_exe: &std::path::Path,
80 args: &[String],
81 // macOS/Linux: pre-created sink whose rx was already handed to the
82 // splash thread in main() (both platforms paint the splash before
83 // calling into here). Other Unix / splash disabled: None; a fresh sink
84 // (rx dropped) is created below.
85 startup_sink_opt: Option<startup_events::StartupEventSink>,
86) {
87 use tokio::signal::unix::{signal, SignalKind};
88
89 let version = env!("CARGO_PKG_VERSION");
90
91 // Startup telemetry bus, resolved up front (rather than after the
92 // pre-supervisor work below, where it used to live) so that work itself
93 // can be wrapped in a "prep" stage — previously invisible in the splash
94 // panel on every platform (confirmed via grep: only "migrations"/
95 // "backend"/"host" were ever reported; the selftest fixture's "prep"/
96 // "Launcher setup" fixture data implied this stage already existed for
97 // real, but it never did). See
98 // docs/specs/PLAN_SPLASH_TELEMETRY_OPEN_ITEMS_2026_07_22.md item 4.
99 let startup_sink = startup_sink_opt.unwrap_or_else(|| {
100 let (s, rx) = startup_events::StartupEventSink::new();
101 drop(rx);
102 s
103 });
104 startup_sink.stage_begin("prep", "Launcher setup");
105 let prep_t = std::time::Instant::now();
106
107 // 1. Resolve + create data dirs (same authority as run_windows: srv +
108 // host receive these via env so they can't drift).
109 let paths = match data_dir::resolve_paths(launcher_exe_dir, version) {
110 Ok(p) => p,
111 Err(e) => {
112 log(&format!("FATAL: path resolution failed: {}", e));
113 eprintln!("Failed to resolve AgentMux data directories: {}", e);
114 std::process::exit(1);
115 }
116 };
117 log(&format!(
118 "paths resolved: data={} config={} user_home={} portable={}",
119 paths.data_dir.display(),
120 paths.config_dir.display(),
121 paths.user_home_dir.display(),
122 paths.portable_root.is_some(),
123 ));
124 if let Err(e) = data_dir::ensure_dirs(&paths) {
125 log(&format!("FATAL: failed to create data dirs: {}", e));
126 eprintln!("{}", e);
127 std::process::exit(1);
128 }
129
130 // -----------------------------------------------------------------
131 // A1.1 — IPC server setup on Linux (the wire is up). Mirrors the
132 // Windows path at the equivalent point in `run_windows`. Once this
133 // lands the host's 17 `report_*` IPC calls reach the (already
134 // platform-neutral) reducer, the window-pool / single-instance /
135 // instance-numbering logic activates, sagas get persisted to the
136 // launcher_saga.db SQLite log, and `--diag sagas` reads something
137 // useful.
138 //
139 // Spec: docs/specs/SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_2026_06_05.md §4
140 // -----------------------------------------------------------------
141
142 // Compute the socket path from a data-dir hash + version, identical
143 // scoping rule to the Windows pipe namespace. `pipe_name` is now
144 // pure on both platforms — the side-effecting ensure step lives
145 // in `ensure_ipc_socket_dir` and is invoked separately below so
146 // that any future read-only inspector (e.g. a Linux `--diag`
147 // port) can call `pipe_name` without mutating the filesystem.
148 let pipe_version = option_env!("AGENTMUX_BUILD_LABEL")
149 .unwrap_or(env!("CARGO_PKG_VERSION"));
150 let dir_hash = hash::data_dir_hash16(&paths.data_dir, pipe_version);
151 let socket_path = ipc::pipe_name(&dir_hash);
152 log(&format!(
153 "launcher IPC socket = {} (data_dir={} pipe_version={})",
154 socket_path,
155 paths.data_dir.display(),
156 pipe_version
157 ));
158 // Ensure the socket dir exists with safe ownership/perms BEFORE
159 // any bind attempts. This may call std::process::exit(2) on a
160 // hostile-dir-on-disk scenario (cross-user squatting attack).
161 let _ = ipc::ensure_ipc_socket_dir();
162
163 // Single-instance handshake (A1.6). The bind is the authoritative
164 // signal: a second launcher pointing at the same data dir gets
165 // `EADDRINUSE`. The Windows path enjoys atomic `first_pipe_
166 // instance(true)`; Unix bind isn't atomic with respect to stale-
167 // file recovery, so we serialize that recovery via an `flock(2)`
168 // lockfile (codex P1 + reagent P1 on PR #1288).
169 //
170 // Concurrent-cleanup race the lockfile defends against:
171 // 1. Stale socket file remains from a crashed prior launcher.
172 // 2. Launcher A and B start concurrently. Both `bind` returns
173 // EADDRINUSE because the file exists.
174 // 3. Without the lock: both A and B `connect` → ECONNREFUSED
175 // (no listener on the stale file), both `unlink`, both
176 // `bind` succeeds → two live launchers for one data dir,
177 // and B's `unlink` may have removed A's freshly-bound
178 // socket file before B bound its own.
179 // 4. With the lock: A holds the lock → does probe + unlink +
180 // rebind atomically. B blocks on the lock. When A releases,
181 // B's retry-bind sees A's running listener (file exists) and
182 // B's `connect` succeeds → B exits cleanly as second
183 // instance. No double-recovery.
184 //
185 // The lockfile lives next to the socket as `<socket>.lock`. It is
186 // intentionally NOT removed on clean shutdown — the inode is
187 // tiny, and leaving it lets the next launcher reuse it without a
188 // create-race. (Stale-lock-file scenarios are bounded: flock(2)
189 // is auto-released on process exit even for SIGKILL.)
190 let first_socket = bind_socket_with_recovery(&socket_path, &paths.data_dir, &dir_hash, &paths.common.channel);
191
192 // macOS: we are the first instance (second-instance launches exit inside
193 // bind_socket_with_recovery). Publish this instance's bound-socket identity
194 // to the splash's reopen handler so a Finder double-click / `open` (no -n)
195 // forwards open_new_window to exactly THIS (channel, version) instance —
196 // never a recomputed hash. SPEC_MACOS_REOPEN_NEW_WINDOW_2026_06_22.md.
197 #[cfg(target_os = "macos")]
198 crate::splash_mac::set_reopen_target(paths.data_dir.clone(), dir_hash.clone());
199
200 // Optional tray / menu-bar icon (issue #2977 Workstream 1). Same placement
201 // rationale as the Windows supervisor: this is the first point where we
202 // know we are THE instance (a second launch exits inside
203 // `bind_socket_with_recovery`), so a second icon cannot appear, and
204 // `data_dir`/`dir_hash` — all the actions need to reach the host — are
205 // resolved. Fire-and-forget: `None` when the tray is off,
206 // unsupported here, or failed to start; the action loop is a detached
207 // thread that only ever forwards over the same authenticated channel a
208 // second launch uses.
209 if let Some(tray_rx) = tray::start_if_enabled(paths.data_dir.clone(), dir_hash.clone()) {
210 tray::spawn_action_loop(tray_rx, paths.data_dir.clone(), dir_hash.clone());
211 }
212
213 // Task #35 (SPEC_WIN10_PAGEFILE_OOM_CRASH_2026_06_29.md P1) — read-only
214 // detection of other, OLDER AgentMux instances still running. Mirrors
215 // the Windows call site: we only reach this point once we've WON the
216 // single-instance socket bind above. Detection + logging ONLY — see
217 // `other_instances.rs` doc comment.
218 //
219 // reagent (PR #2117 round 1): synchronous I/O (fs::read_dir, socket
220 // connect probes), no `.await` in the body — spawn_blocking instead of
221 // plain tokio::spawn so it runs on the blocking-task pool and can never
222 // occupy/starve an async worker the IPC accept loop or host supervision
223 // needs, especially given channels/local-* dirs are documented to
224 // accumulate unpruned (more siblings to walk/probe over time).
225 {
226 let channels_root = paths.common.home_dir.join("channels");
227 let own_channel = paths.common.channel.clone();
228 let own_version = version.to_string();
229 tokio::task::spawn_blocking(move || {
230 other_instances::log_older_running_instances(&channels_root, &own_channel, &own_version);
231 });
232 }
233
234 // Broadcast bus for reducer-emitted events. Same capacity (1024)
235 // and rationale as the Windows path.
236 let (events_tx, _) =
237 tokio::sync::broadcast::channel::<agentmux_common::ipc::Event>(1024);
238
239 // Event log: in-memory ring + disk persistence at
240 // <data-dir>/launcher-events.log. Disk writer task spawned next.
241 let log_disk_path = paths.data_dir.join("launcher-events.log");
242 let event_log = std::sync::Arc::new(event_log::EventLog::new(Some(log_disk_path)));
243 let event_log_for_writer = std::sync::Arc::clone(&event_log);
244 let disk_writer_rx = events_tx.subscribe();
245 tokio::spawn(event_log::run_disk_writer(
246 event_log_for_writer,
247 disk_writer_rx,
248 ));
249
250 // Canonical state shared between IPC server + saga coordinator.
251 let state = std::sync::Arc::new(tokio::sync::Mutex::new(state::State::default()));
252
253 // Pillar 1 Step 6 — in-memory saga registry (see the Windows
254 // supervisor's comment for the full rationale). Best-effort cleanup of
255 // the legacy durable files.
256 let saga_log = std::sync::Arc::new(saga::LauncherSagaLog::new());
257 for legacy in [
258 paths.data_dir.join("db").join("launcher-sagas.db"),
259 paths.data_dir.join("launcher-sagas.db"),
260 ] {
261 for suffix in ["", "-wal", "-shm"] {
262 let p = std::path::PathBuf::from(format!("{}{}", legacy.display(), suffix));
263 if p.exists() && std::fs::remove_file(&p).is_ok() {
264 log(&format!("[saga] removed legacy durable saga log file {:?} (Step 6: registry is in-memory)", p));
265 }
266 }
267 }
268
269 // "prep" ends here — everything above (paths, IPC socket, single-instance
270 // handshake, other-instances check, event log, saga registry legacy
271 // cleanup) is done. What follows (saga coordinator + IPC server setup,
272 // srv spawn) has its own stages/overlap already.
273 startup_sink.stage_end(
274 "prep",
275 prep_t.elapsed().as_millis() as u64,
276 startup_events::StartupStatus::Ok,
277 None,
278 );
279
280 // Saga coordinator-setup/IPC-server-startup run concurrently with the
281 // srv boot below (tokio::join!) instead of sequentially before it.
282 // Neither branch depends on the other's output: this setup never reads
283 // srv_result, and srv never connects back to the launcher's IPC socket
284 // during its own startup. See SPEC_MACOS_LAUNCH_SPEED_AND_SPLASH_
285 // TELEMETRY_2026_07_02.md §A.4 item 5.
286 let launcher_setup = async {
287 // Host pipe wrapper for saga-issued Commands → host. The IPC
288 // server's per-connection handler installs the host's writer once
289 // the host registers (see ipc/server.rs handle_connection's
290 // ClientKind::Host branch).
291 let host_pipe = std::sync::Arc::new(host_pipe::HostPipe::new(
292 events_tx.clone(),
293 std::sync::Arc::clone(&state),
294 ));
295
296 // Saga coordinator. Same construction + error handling as the
297 // Windows path; the coordinator itself is platform-neutral.
298 let saga_coord_inner =
299 saga::SagaCoordinator::new(events_tx.clone(), std::sync::Arc::clone(&state))
300 .with_log(std::sync::Arc::clone(&saga_log))
301 .unwrap_or_else(|e| {
302 log(&format!(
303 "[main] FATAL: failed to seed saga_id allocator: {}",
304 e
305 ));
306 std::process::exit(1);
307 })
308 .with_host_pipe(std::sync::Arc::clone(&host_pipe));
309 let saga_coord = std::sync::Arc::new(saga_coord_inner);
310 let saga_rx = events_tx.subscribe();
311 tokio::spawn(saga::run_coordinator(
312 std::sync::Arc::clone(&saga_coord),
313 saga_rx,
314 ));
315
316 let ipc_handle = ipc::run_ipc_server(
317 socket_path.clone(),
318 first_socket,
319 ipc::server::ServerCtx {
320 launcher_pid: std::process::id(),
321 launcher_version: env!("CARGO_PKG_VERSION").to_string(),
322 state: std::sync::Arc::clone(&state),
323 events_tx,
324 event_log,
325 host_pipe: std::sync::Arc::clone(&host_pipe),
326 startup_sink: Some(startup_sink.clone()),
327 },
328 );
329 log(&format!("IPC server started on {}", socket_path));
330
331 (saga_coord, ipc_handle, host_pipe)
332 };
333
334 // 2b. Spawn srv. The srv pipe path is the launcher-owned socket
335 // path scope (srv will gain its own Unix-socket bind in a
336 // follow-up; for now we still pass an empty string so srv's
337 // Windows-only IPC code stays disabled).
338 let srv_pipe_path = String::new();
339 // This launcher owns the data dir now (it won the single-instance socket
340 // above), so an srv still holding the dir's lock was left behind by an
341 // earlier launcher; stop it rather than fail to start.
342 crate::stale_srv::reclaim_stale_srv_lock(&paths.data_dir);
343 let (setup_result, srv_spawn_result) = tokio::join!(
344 launcher_setup,
345 srv_spawner::spawn_srv(launcher_exe_dir, &paths, &srv_pipe_path, &startup_sink)
346 );
347 let (saga_coord, _ipc_handle, host_pipe) = setup_result;
348 let (srv_result, mut srv_child) = match srv_spawn_result {
349 Ok(pair) => pair,
350 Err(e) => {
351 log(&format!("FATAL: srv spawn failed: {}", e));
352 eprintln!("Failed to start backend: {}", e);
353 if let srv_spawner::SrvSpawnError::MigrationFailed(reason) = &e {
354 // On Unix `show_fatal_dialog` is itself an eprintln (no native
355 // dialog yet — see main.rs), so today this repeats the line
356 // above. Kept so both supervisors call the same helper with the
357 // same words: when a native macOS/Linux dialog lands in
358 // show_fatal_dialog, this path gets it for free.
359 crate::show_fatal_dialog(
360 srv_spawner::MIGRATION_FAILED_DIALOG_TITLE,
361 &srv_spawner::migration_failed_dialog_body(reason),
362 );
363 }
364 std::process::exit(1);
365 }
366 };
367
368 // OS notification presenter (Linux: freedesktop notifications over D-Bus).
369 // macOS has no toast backend yet (SPEC_OS_NOTIFICATIONS_SYSTEM_2026_09_24
370 // §6.2) and gets the no-op presenter, but still needs the srv session: it
371 // carries `app:startatlogin` to the menu-bar item and the LaunchAgent
372 // (SPEC_START_WITH_OS_2026_09_25 §3.9).
373 crate::notify::start(&srv_result.ws_endpoint, &srv_result.auth_key, paths.data_dir.clone(), dir_hash.clone(), state.clone());
374
375 // CRITICAL (same rationale as run_windows): take srv's stdin out of
376 // the Child so tokio's wait() can't close it and trip srv's
377 // parent-watch EOF. Held until launcher exit.
378 let _srv_stdin_keepalive = srv_child.stdin.take();
379
380 // 3. Spawn the host with srv endpoints in env.
381 // dir_hash was computed once above for socket_path; reuse it here
382 // instead of re-hashing the same paths.data_dir + version.
383 let mut host_env = paths.common.to_env_vars();
384 host_env.push(("AGENTMUX_IPC_HASH", std::ffi::OsString::from(&dir_hash)));
385 // A1.3 — IPC env handshake. Tell the host where to find the
386 // launcher socket. The env var name `AGENTMUX_LAUNCHER_PIPE` is
387 // reused from the Windows side even though the underlying resource
388 // is a Unix-domain socket — keeps the `report_*` functions in
389 // `crates/cef/src/launcher_ipc/reporters.rs` unchanged and avoids
390 // touching the host's connect-on-startup code in `crates/cef/src/lib.rs`.
391 host_env.push((
392 "AGENTMUX_LAUNCHER_PIPE",
393 std::ffi::OsString::from(&socket_path),
394 ));
395 // AGENTMUX_HOME = the host's runtime directory (siblings of the
396 // host binary — libcef.so, paks, locales, etc). Mirrors the
397 // Windows path so the host's asset-resolution code finds its
398 // co-located data without searching $PATH-like fallbacks.
399 if let Some(host_runtime) = real_exe.parent() {
400 host_env.push((
401 "AGENTMUX_HOME",
402 std::ffi::OsString::from(host_runtime),
403 ));
404 }
405 // "host" stage currently covers process-spawn latency only (begin →
406 // spawn_host_unix returning a live Child), not full first-paint — the
407 // signal for "host painted its first frame" (AGENTMUX_SPLASH_READY_FILE)
408 // is written by the host process itself and consumed exclusively by the
409 // splash's own poll loop (splash_mac.rs / splash_linux); having the
410 // supervisor also poll/remove that file risks a race with the splash's
411 // consumer. Extending this stage to span to first-paint is a follow-up
412 // once a race-safe signal exists (see SPEC_MACOS_LAUNCH_SPEED_AND_
413 // SPLASH_TELEMETRY_2026_07_02.md §B.7). Spawn latency is still a real,
414 // independently useful number.
415 startup_sink.stage_begin("host", "Host startup");
416 let host_spawn_t = std::time::Instant::now();
417 let mut host_child = match spawn_host_unix(real_exe, args, &srv_result, &host_env, false) {
418 Some(c) => {
419 startup_sink.stage_end(
420 "host",
421 host_spawn_t.elapsed().as_millis() as u64,
422 startup_events::StartupStatus::Ok,
423 None,
424 );
425 c
426 }
427 None => {
428 startup_sink.stage_end(
429 "host",
430 host_spawn_t.elapsed().as_millis() as u64,
431 startup_events::StartupStatus::Error,
432 Some("spawn failed".to_string()),
433 );
434 log("FATAL: could not start CEF host — terminating");
435 eprintln!("Failed to launch AgentMux.");
436 terminate_child_gracefully(&srv_child);
437 let _ = srv_child.start_kill();
438 std::process::exit(1);
439 }
440 };
441
442 // 4. Signal handlers for `kill <launcher-pid>` (a terminal Ctrl+C
443 // already signals the whole foreground group; these cover the
444 // launcher-only case and make Ctrl+C deterministic too).
445 let mut sigint = signal(SignalKind::interrupt()).ok();
446 let mut sigterm = signal(SignalKind::terminate()).ok();
447 if sigint.is_none() || sigterm.is_none() {
448 log("WARN: failed to install one or more signal handlers — \
449 relying on default termination + srv stdin-EOF backstop");
450 }
451
452 // 5. Supervised wait loop — host crash budget mirrors run_windows.
453 log("entering supervised host + srv wait (unix)");
454 let mut host_restarts: Vec<std::time::Instant> = Vec::new();
455 // Separate budget for system-OOM host exits (memory-aware relaunch); see
456 // mem_supervisor + SPEC_MEMORY_PRESSURE_SUPERVISION_2026_06_16.
457 let mut oom_restarts: Vec<std::time::Instant> = Vec::new();
458 let mut last_abnormal_code: Option<i32> = None;
459 let mut host_degraded = false;
460 // SPEC_LAUNCHER_TEARDOWN_BACKSTOP_2026_07_11 unix parity — same shape as
461 // `run_windows`'s Phase 1/Phase 2 pair. Low-rate UI-thread liveness
462 // prober (60s, first tick delayed a full interval so a booting host
463 // isn't logged as a false miss) plus a low-rate poll (5s) of the armed
464 // teardown state machine (armed by the IPC server's post-reducer event
465 // hook on PoolDrained/OrphanInstance — see `ipc/server.rs`, already
466 // platform-neutral and unconsumed on unix until this — disarmed on
467 // WindowOpened and on every host exit below).
468 let mut ui_probe_interval = tokio::time::interval_at(
469 tokio::time::Instant::now() + std::time::Duration::from_secs(60),
470 std::time::Duration::from_secs(60),
471 );
472 ui_probe_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
473 let mut ui_probe_nonce: u64 = 0;
474 let mut teardown_check_interval =
475 tokio::time::interval(std::time::Duration::from_secs(5));
476 teardown_check_interval
477 .set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
478 // Same distinct exit code as Windows so wedged-host teardown is
479 // unambiguous in log/exit-code triage across platforms.
480 const TEARDOWN_BACKSTOP_EXIT_CODE: i32 = 86;
481 let exit_code = loop {
482 tokio::select! {
483 _ = teardown_check_interval.tick() => {
484 let misses = crate::ui_liveness::consecutive_misses();
485 if crate::teardown_backstop::should_teardown(misses) {
486 log(&format!(
487 "[teardown-backstop] host wedged with zero user windows — terminating process \
488 groups (armed > {}s grace, {} consecutive unanswered UI-thread probes)",
489 crate::teardown_backstop::TEARDOWN_GRACE.as_secs(),
490 misses,
491 ));
492 // I2/I3 hold by construction: each process group (host,
493 // srv) was created explicitly at spawn time via
494 // `.process_group(0)` — the blast radius is exactly the
495 // processes THIS launcher spawned and their descendants,
496 // never the launcher's own group. Mirrors the Windows J0
497 // TerminateJobObject backstop; the one deliberate
498 // exception to "never kill what a saga can reconcile" —
499 // zero user windows remain and the UI thread is provably
500 // dead, so there is nothing to reconcile.
501 //
502 // The host's group: SIGTERM first, brief grace, then the
503 // hard SIGKILL. srv is NOT presumed wedged (only the host
504 // lost track of its windows), so it is stopped by the
505 // cleanup below like on any quit: `quiesce_srv` lets it
506 // close every agent gracefully, and only then kills its
507 // group if it had to be forced.
508 crate::host_spawn::kill_process_group_gracefully(&host_child);
509 tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
510 crate::host_spawn::kill_process_group_forcefully(&host_child);
511 break TEARDOWN_BACKSTOP_EXIT_CODE;
512 }
513 }
514 _ = ui_probe_interval.tick() => {
515 ui_probe_nonce += 1;
516 if let Some((missed, sent_at)) = crate::ui_liveness::record_probe_sent(ui_probe_nonce) {
517 log(&format!(
518 "[ui-liveness] probe nonce={} unanswered after {}s — UI thread did not pump in that window",
519 missed,
520 sent_at.elapsed().as_secs()
521 ));
522 }
523 // Fail-fast send: try_send_command_no_buffer turns a
524 // disconnected pipe into an immediate error instead of
525 // buffering the probe through a crash-restart gap, where it
526 // could age into a false "did not pump" miss (see
527 // run_windows's identical comment for the full rationale).
528 if let Err(e) = host_pipe
529 .try_send_command_no_buffer(&agentmux_common::ipc::Command::ProbeUiThread {
530 nonce: ui_probe_nonce,
531 })
532 .await
533 {
534 crate::ui_liveness::retract_probe(ui_probe_nonce);
535 log(&format!(
536 "[ui-liveness] probe send failed (transport, not liveness): {:?}",
537 e
538 ));
539 }
540 }
541 host_status = host_child.wait() => {
542 // SPEC_LAUNCHER_TEARDOWN_BACKSTOP Phase 2 — the host exiting
543 // (cleanly, crashing, or relaunched) always stands the armed
544 // teardown down: an Armed machine's whole premise is "the
545 // host is alive but wedged", and this is the crash-restart-
546 // gap false-positive guard — the machine stays suspended
547 // across the restart and can only re-arm from a fresh drain
548 // report by the NEW host.
549 if crate::teardown_backstop::disarm() {
550 log("[teardown-backstop] disarmed — host exited (supervised-exit path owns it now)");
551 }
552 use std::os::unix::process::ExitStatusExt;
553 let status = match host_status {
554 Ok(s) => s,
555 Err(e) => {
556 log(&format!("FATAL: host wait failed: {}", e));
557 break 1;
558 }
559 };
560 // Host killed by a signal. Originally written for the case
561 // where a terminal Ctrl+C reached the host DIRECTLY (it used
562 // to share our foreground process group) and host_child.wait()
563 // could win the select! race against our own SIGINT arm.
564 // Since PR #2200 gave host its own process group (prerequisite
565 // for the teardown backstop's group-kill), a terminal Ctrl+C
566 // no longer reaches host directly — only an EXTERNAL signal
567 // sent straight to host's own pid (e.g. `kill <host-pid>
568 // SIGTERM`, a container/systemd stop signal targeting it
569 // specifically) can still land here mid-loop. Kept for that
570 // case: without this guard such a signal-death has no exit
571 // code → unwrap_or(1) → looks like a crash and we'd relaunch a
572 // replacement host with nothing left to supervise it. Treat
573 // the group-shutdown signals (SIGINT/SIGTERM/SIGHUP) as a
574 // clean shutdown; real crash signals (SIGSEGV, SIGABRT, …)
575 // still fall through to the crash-budget relaunch below.
576 if let Some(sig) = status.signal() {
577 if sig == libc::SIGINT || sig == libc::SIGTERM || sig == libc::SIGHUP {
578 log(&format!("CEF host terminated by signal {} (group shutdown) — shutting down", sig));
579 break 0;
580 }
581 log(&format!("CEF host killed by signal {} (crash) — entering crash-budget relaunch", sig));
582 }
583 let code = status.code().unwrap_or(1);
584 if code == 0 {
585 log("CEF host exited cleanly (code 0) — shutting down");
586 break 0;
587 }
588 // Classify system-OOM (wait it out) vs a genuine host fault
589 // (existing fast budget), mirroring run_windows. SPEC_MEMORY_
590 // PRESSURE_SUPERVISION_2026_06_16 §5.B. On Linux a kernel-OOM-kill
591 // arrives as a SIGKILL (code 1 here) and is caught by the low-
592 // commit reading (SPEC §9.4).
593 let commit_free = mem_supervisor::commit_free_mb();
594 match mem_supervisor::classify_host_exit(code, commit_free) {
595 mem_supervisor::HostExitClass::SystemOom => {
596 let now = std::time::Instant::now();
597 if mem_supervisor::budget_exhausted(
598 &mut oom_restarts,
599 now,
600 mem_supervisor::OOM_RESTART_WINDOW,
601 mem_supervisor::OOM_RESTART_BUDGET,
602 ) {
603 log(&format!(
604 "CEF host hit system OOM (code {}, {} MB commit-free); OOM restart \
605 budget exhausted ({} in {}s) — giving up",
606 code,
607 commit_free,
608 mem_supervisor::OOM_RESTART_BUDGET,
609 mem_supervisor::OOM_RESTART_WINDOW.as_secs()
610 ));
611 show_fatal_dialog(
612 mem_supervisor::OOM_GIVEUP_TITLE,
613 mem_supervisor::OOM_GIVEUP_BODY,
614 );
615 break code;
616 }
617 log(&format!(
618 "CEF host hit system OOM (code {}, {} MB commit-free) — waiting for \
619 memory to recover before relaunch",
620 code, commit_free
621 ));
622 // Race the commit-recovery wait against shutdown + srv
623 // death so the supervisor stays responsive during the
624 // (up to OOM_RELAUNCH_DEADLINE) wait — without this the
625 // SIGINT/SIGTERM + srv arms are starved for the whole
626 // wait (reagent P2). Mirrors the outer select! arms.
627 let recovered = tokio::select! {
628 r = mem_supervisor::await_commit_recovery("host", log) => r,
629 srv_status = srv_child.wait() => {
630 use std::os::unix::process::ExitStatusExt;
631 match srv_status {
632 Ok(s) => {
633 let group_shutdown = matches!(
634 s.signal(),
635 Some(sig) if sig == libc::SIGINT || sig == libc::SIGTERM || sig == libc::SIGHUP
636 );
637 if s.success() || group_shutdown {
638 log("srv exited as part of shutdown (during OOM wait) — shutting down");
639 break 0;
640 }
641 log(&format!(
642 "srv exited UNEXPECTEDLY during OOM wait with code {} — terminating launcher",
643 s.code().unwrap_or(1)
644 ));
645 }
646 Err(e) => log(&format!("FATAL: srv wait failed during OOM wait: {}", e)),
647 }
648 break 1;
649 }
650 _ = next_signal(&mut sigint) => {
651 log("received SIGINT during OOM wait — shutting down");
652 break 0;
653 }
654 _ = next_signal(&mut sigterm) => {
655 log("received SIGTERM during OOM wait — shutting down");
656 break 0;
657 }
658 };
659 if !recovered {
660 show_fatal_dialog(
661 mem_supervisor::OOM_GIVEUP_TITLE,
662 mem_supervisor::OOM_GIVEUP_BODY,
663 );
664 break code;
665 }
666 match spawn_host_unix(real_exe, args, &srv_result, &host_env, true) {
667 Some(c) => host_child = c,
668 None => {
669 log("host relaunch failed to spawn — giving up");
670 break code;
671 }
672 }
673 }
674 mem_supervisor::HostExitClass::Abnormal => {
675 let now = std::time::Instant::now();
676 host_restarts.retain(|t| now.duration_since(*t) < HOST_RESTART_WINDOW);
677 if host_restarts.len() >= HOST_RESTART_BUDGET {
678 log(&format!(
679 "CEF host exited abnormally (code {}); restart budget exhausted \
680 ({} in {}s) — giving up",
681 code,
682 host_restarts.len(),
683 HOST_RESTART_WINDOW.as_secs()
684 ));
685 break code;
686 }
687 host_restarts.push(now);
688 if last_abnormal_code == Some(code) {
689 host_degraded = true;
690 }
691 last_abnormal_code = Some(code);
692 log(&format!(
693 "CEF host exited abnormally (code {}) — relaunching (restart {}/{}{})",
694 code,
695 host_restarts.len(),
696 HOST_RESTART_BUDGET,
697 if host_degraded { ", degraded: --disable-gpu" } else { "" }
698 ));
699 match spawn_host_unix(real_exe, args, &srv_result, &host_env, host_degraded) {
700 Some(c) => host_child = c,
701 None => {
702 log("host relaunch failed to spawn — giving up");
703 break code;
704 }
705 }
706 }
707 }
708 }
709 srv_status = srv_child.wait() => {
710 use std::os::unix::process::ExitStatusExt;
711 match srv_status {
712 Ok(s) => {
713 // Mirror the host arm's group-shutdown guard (see its
714 // comment for the PR #2200 process-group caveat: a
715 // terminal Ctrl+C no longer reaches srv directly,
716 // since it now has its own process group). srv's own
717 // signal handler (crates/srv/src/main.rs —
718 // SIGINT/SIGTERM → cancel token → clean exit) still
719 // turns an EXPLICIT SIGTERM — e.g. from our own
720 // `terminate_child_gracefully` in the cleanup
721 // epilogue, or a signal sent directly to srv's own
722 // pid — into a clean `s.success()` exit; the
723 // signal-death branch below is the narrower backstop
724 // for a raw uncaught signal landing mid-loop. Either
725 // way, a clean or signal-killed exit here is a
726 // teardown, NOT an unexpected srv death — don't log a
727 // scary message and break 1 (reagent #1193 P2).
728 let group_shutdown = matches!(
729 s.signal(),
730 Some(sig) if sig == libc::SIGINT || sig == libc::SIGTERM || sig == libc::SIGHUP
731 );
732 if s.success() || group_shutdown {
733 log("srv exited as part of shutdown — shutting down");
734 break 0;
735 }
736 log(&format!(
737 "srv exited UNEXPECTEDLY (host still running) with code {} — terminating launcher",
738 s.code().unwrap_or(1)
739 ));
740 }
741 Err(e) => log(&format!("FATAL: srv wait failed: {}", e)),
742 }
743 break 1;
744 }
745 _ = next_signal(&mut sigint) => {
746 log("received SIGINT — shutting down");
747 break 0;
748 }
749 _ = next_signal(&mut sigterm) => {
750 log("received SIGTERM — shutting down");
751 break 0;
752 }
753 }
754 };
755
756 #[cfg(target_os = "linux")]
757 crate::notify::shutdown();
758
759 // Close any open saga brackets before tearing down children so the
760 // durable log doesn't carry dangling SagaStarted entries into the
761 // next startup's LSD-3 compensation pass.
762 saga_coord.cancel_all_in_flight("launcher shutting down").await;
763
764 // 6. Cleanup, both children at once. The host: SIGTERM so it reaps its
765 // render subprocesses, a short grace, then SIGKILL. srv: the one stop
766 // path (`quiesce_srv`), which lets it close every agent gracefully
767 // first (`agent_teardown::app_exit`) before any force-kill.
768 log("stopping children (host: SIGTERM → grace → SIGKILL; srv: quiesce)");
769 let srv_pgid = srv_child.id();
770 let stop_host = async {
771 terminate_child_gracefully(&host_child);
772 let _ = tokio::time::timeout(std::time::Duration::from_millis(1500), host_child.wait()).await;
773 let _ = host_child.start_kill();
774 };
775 let stop_srv = crate::upgrade::quiesce_srv(
776 &mut srv_child,
777 _srv_stdin_keepalive,
778 agentmux_common::process::SRV_EXIT_WAIT,
779 );
780 let ((), srv_stopped) = tokio::join!(stop_host, stop_srv);
781 match srv_stopped {
782 Ok((_, crate::upgrade::QuiesceOutcome::ExitedAfterForceKill)) => {
783 // Killed mid-teardown: its agent CLIs share its process group
784 // and would outlive it.
785 log("WARN: srv didn't exit in time — killed it and its process group");
786 if let Some(pgid) = srv_pgid {
787 crate::host_spawn::kill_group_forcefully(pgid);
788 }
789 }
790 Ok((_, outcome)) => log(&format!("srv stopped ({outcome:?})")),
791 Err(e) => log(&format!("WARN: stopping srv failed: {e}")),
792 }
793 log(&format!("launcher exiting with code {}", exit_code));
794 std::process::exit(exit_code);
795}