Skip to main content

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}