agentmux_launcher/main.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4// AgentMux Launcher — Sets DLL search path then spawns srv + the CEF host.
5//
6// Phase B.1: launcher now spawns srv directly (sibling of host) so srv
7// survives host crashes. Both children are assigned to the launcher's
8// Job Object J0 with KILL_ON_JOB_CLOSE; killing the launcher reaps
9// the entire tree atomically via the OS.
10//
11// This was previously a tiny sync wrapper that just SetDllDirectoryW'd
12// runtime/ then spawned the CEF host. Phase B grew it into the
13// privileged owner per
14// specs/SPEC_WINDOW_PROCESS_STATE_MACHINE_2026_04_27.md.
15//
16// Process tree after B.1:
17// launcher (J0)
18// ├── srv (assigned to J0; survives host crash)
19// └── host (assigned to J0; CEF render workers inherit J0)
20
21#![cfg_attr(
22 all(not(debug_assertions), target_os = "windows"),
23 windows_subsystem = "windows"
24)]
25
26mod config;
27mod data_dir;
28mod diag;
29mod event_log;
30mod hash;
31mod host_pipe;
32mod ipc;
33mod mem_supervisor;
34mod reducer;
35mod saga;
36#[cfg(target_os = "windows")]
37mod splash;
38#[cfg(target_os = "macos")]
39mod splash_mac;
40#[cfg(target_os = "linux")]
41mod splash_linux;
42// Splash footer support. The baked font + software text blitter are only used by
43// the software-buffer backends (Linux, Windows); macOS renders native text.
44#[cfg(any(target_os = "linux", target_os = "windows"))]
45mod splash_font;
46#[cfg(any(target_os = "linux", target_os = "windows"))]
47mod splash_text;
48mod splash_config;
49mod splash_info;
50mod srv_spawner;
51mod state;
52mod wrr;
53
54/// Suppress the Windows "Application Error" / WER crash dialog so an unhandled
55/// fault terminates the process immediately instead of wedging it behind a
56/// modal. No-op off Windows. Spec:
57/// docs/specs/SPEC_SERVICE_SUPERVISION_AND_RECOVERY_2026_05_20.md.
58#[cfg(target_os = "windows")]
59fn suppress_os_crash_dialogs() {
60 use windows_sys::Win32::System::Diagnostics::Debug::{SetErrorMode, SEM_FAILCRITICALERRORS};
61 use windows_sys::Win32::System::ErrorReporting::{WerSetFlags, WER_FAULT_REPORTING_NO_UI};
62 unsafe {
63 // Suppress the WER crash-dialog UI WITHOUT disabling WER itself —
64 // SEM_NOGPFAULTERRORBOX would also kill WER/LocalDumps crash-dump
65 // collection, the postmortem diagnostics this stability work needs.
66 // WER_FAULT_REPORTING_NO_UI is the documented "no UI, keep
67 // reports" path.
68 let _ = WerSetFlags(WER_FAULT_REPORTING_NO_UI);
69 // SEM_FAILCRITICALERRORS suppresses the critical-error handler
70 // (e.g. "no disk in drive" popups) — unrelated to crash reporting.
71 SetErrorMode(SEM_FAILCRITICALERRORS);
72 }
73}
74
75#[cfg(not(target_os = "windows"))]
76fn suppress_os_crash_dialogs() {}
77
78/// Process entry point. `suppress_os_crash_dialogs()` runs FIRST — before the
79/// Tokio runtime is built. The runtime is built explicitly here (rather than
80/// via `#[tokio::main]`, whose generated wrapper would construct it before any
81/// of our code runs) so a fault during runtime construction can't surface the
82/// Windows crash modal either. Spec:
83/// docs/specs/SPEC_SERVICE_SUPERVISION_AND_RECOVERY_2026_05_20.md.
84fn main() {
85 suppress_os_crash_dialogs();
86
87 // Dev/demo affordance: `--splash-selftest` shows the splash in isolation
88 // (no srv/host), holds it briefly, then exits — for eyeballing the footer +
89 // layout. See SPEC_SPLASH_USERINFO_AND_DISABLE_2026_06_21.md.
90 if std::env::args().any(|a| a == "--splash-selftest") {
91 splash_selftest();
92 return;
93 }
94
95 // macOS: paint the splash FIRST, on the main thread, before any heavy work
96 // — this is the whole reason the splash lives in the small fast launcher
97 // rather than the slow CEF host. AppKit must own the main thread, so the
98 // srv+host supervisor (`launcher_main`) runs on a worker thread with its
99 // own Tokio runtime; the splash pumps a CoreFoundation runloop on main
100 // until the host signals first paint. See `splash_mac`.
101 #[cfg(target_os = "macos")]
102 {
103 // Splash disabled → no AppKit splash; run the supervisor directly on the
104 // main thread (there's no runloop to pump without a splash window).
105 if splash_config::splash_disabled() {
106 tokio::runtime::Runtime::new()
107 .expect("failed to build Tokio runtime")
108 .block_on(launcher_main());
109 return;
110 }
111 let splash = splash_mac::Splash::show();
112 std::thread::Builder::new()
113 .name("launcher-supervisor".into())
114 .spawn(|| {
115 // Catch panics so a supervisor crash always exits the process
116 // rather than leaving the main-thread AppKit runloop spinning
117 // as an invisible orphan.
118 let result = std::panic::catch_unwind(|| {
119 tokio::runtime::Runtime::new()
120 .expect("failed to build Tokio runtime")
121 .block_on(launcher_main());
122 });
123 if result.is_err() {
124 eprintln!("AgentMux launcher supervisor panicked — exiting");
125 std::process::exit(1);
126 }
127 // Supervisor finished cleanly (host exited / fatal).
128 std::process::exit(0);
129 })
130 .expect("failed to spawn launcher supervisor thread");
131 splash.run_until_dismissed(); // pumps the runloop, then parks forever
132 return;
133 }
134
135 #[cfg(not(target_os = "macos"))]
136 {
137 // Linux: paint the splash before any heavy work (mirrors the macOS path,
138 // but on its own thread since neither X11 nor Wayland needs the main
139 // thread). spawn() sets AGENTMUX_SPLASH_READY_FILE so the host — spawned
140 // later inside launcher_main — inherits it and signals first paint.
141 // Windows keeps spawning its splash inside launcher_main (event-name
142 // model). See splash_linux/.
143 #[cfg(target_os = "linux")]
144 if !splash_config::splash_disabled() {
145 splash_linux::spawn();
146 }
147
148 tokio::runtime::Runtime::new()
149 .expect("failed to build Tokio runtime")
150 .block_on(launcher_main());
151 }
152}
153
154/// `--splash-selftest`: show the splash with no srv/host behind it, hold it for a
155/// few seconds (or `AGENTMUX_SPLASH_HOLD_MS`), then exit. A demo/dev affordance
156/// for eyeballing the footer + centering without launching the whole app.
157fn splash_selftest() {
158 let hold = std::env::var("AGENTMUX_SPLASH_HOLD_MS")
159 .ok()
160 .and_then(|s| s.parse::<u64>().ok())
161 .map(|ms| std::time::Duration::from_millis(ms.max(3000)))
162 .unwrap_or_else(|| std::time::Duration::from_secs(6));
163
164 #[cfg(target_os = "linux")]
165 {
166 splash_linux::spawn();
167 std::thread::sleep(hold);
168 }
169 #[cfg(target_os = "macos")]
170 {
171 let splash = splash_mac::Splash::show();
172 if let Ok(p) = std::env::var("AGENTMUX_SPLASH_DUMP_PNG") {
173 splash.dump_png(&p);
174 }
175 let _ = splash; // run_until_dismissed parks; selftest just holds then exits
176 std::thread::sleep(hold);
177 }
178 #[cfg(target_os = "windows")]
179 {
180 let _ = splash::spawn_splash("selftest");
181 std::thread::sleep(hold);
182 }
183}
184
185async fn launcher_main() {
186 let exe_path = std::env::current_exe().expect("cannot resolve exe path");
187 let exe_dir = exe_path.parent().expect("exe has no parent directory");
188 // Production + Windows dev use a `runtime/` subdir (launcher at root,
189 // host + libs + srv under runtime/). The macOS/Linux `task dev` flat
190 // layout (Phase 1, SPEC_LAUNCHER_MACOS_DEV_INTEGRATION_2026_05_30)
191 // drops the launcher next to the host in dist/cef-dev/ so the host's
192 // `../Frameworks` resolution and asset anchoring are byte-identical to
193 // the legacy direct-invoke path — no `runtime/` to descend into. Fall
194 // back to exe_dir when there's no runtime/ subdir. Windows always has
195 // one, so its behavior is unchanged.
196 let runtime_dir = {
197 let rt = exe_dir.join("runtime");
198 if rt.is_dir() {
199 rt
200 } else {
201 exe_dir.to_path_buf()
202 }
203 };
204
205 log(&format!(
206 "starting — exe={} runtime={}",
207 exe_path.display(),
208 runtime_dir.display()
209 ));
210
211 // Set DLL search path so libcef.dll (in runtime/) is found by the
212 // CEF host's load-time linker. SetDllDirectoryW is process-local
213 // and inherited by child processes — both srv (which doesn't
214 // need libcef but harmless) and host (which absolutely does).
215 #[cfg(target_os = "windows")]
216 {
217 use std::os::windows::ffi::OsStrExt;
218 let wide: Vec<u16> = runtime_dir
219 .as_os_str()
220 .encode_wide()
221 .chain(Some(0))
222 .collect();
223 unsafe {
224 windows_sys::Win32::System::LibraryLoader::SetDllDirectoryW(wide.as_ptr());
225 }
226 }
227 log("SetDllDirectoryW done");
228
229 let args: Vec<String> = std::env::args().skip(1).collect();
230
231 // LSD-3 — `agentmux.exe --diag sagas` is OFFLINE: it reads the
232 // launcher saga SQLite log directly, with no IPC and no running
233 // launcher. So it MUST run BEFORE the CEF runtime existence
234 // check below — the offline-diagnostic value is most needed
235 // exactly when the launcher won't start (e.g. corrupt runtime
236 // folder). (codex P1 + reagent P1 PR #647 round 3.)
237 if matches!(
238 (args.first().map(String::as_str), args.get(1).map(String::as_str)),
239 (Some("--diag"), Some("sagas"))
240 ) {
241 match diag::run_sagas_diag(exe_dir).await {
242 Ok(()) => std::process::exit(0),
243 Err(msg) => {
244 eprintln!("--diag sagas failed: {}", msg);
245 std::process::exit(1);
246 }
247 }
248 }
249
250 let real_exe = find_cef_binary(&runtime_dir);
251 log(&format!("resolved CEF binary: {}", real_exe.display()));
252 // Self-spawn guard: if host resolution ever points back at the
253 // launcher's own binary (the flat dev layout's failure mode —
254 // launcher + host co-located), spawning it would recurse into an
255 // unbounded launcher fork bomb. find_cef_binary excludes
256 // `agentmux-launcher` by name; this is the loud backstop in case a
257 // future binary slips past that filter. Compare canonicalized paths
258 // so symlink/`./` differences don't defeat the check.
259 if let (Ok(a), Ok(b)) = (
260 std::fs::canonicalize(&real_exe),
261 std::fs::canonicalize(&exe_path),
262 ) {
263 if a == b {
264 log(&format!(
265 "FATAL: host resolved to the launcher's own binary ({}) — refusing to self-spawn",
266 a.display()
267 ));
268 eprintln!("AgentMux runtime is misconfigured (host == launcher). Aborting.");
269 std::process::exit(1);
270 }
271 }
272 if !real_exe.exists() {
273 log(&format!(
274 "FATAL: CEF binary not found at {}",
275 real_exe.display()
276 ));
277 eprintln!(
278 "AgentMux runtime not found in: {}\nMake sure the runtime/ folder is intact.",
279 runtime_dir.display()
280 );
281 std::process::exit(1);
282 }
283
284 log(&format!("forwarding {} CLI args to host", args.len()));
285
286 // Phase B.8 — `agentmux.exe --diag wrr` and `--diag srv` Tool
287 // clients. Connect to the running launcher (or srv) over IPC,
288 // capture events for a short window, print summary, exit.
289 // (Note: --diag sagas is handled above, before the CEF runtime
290 // check, since it doesn't need IPC.)
291 if matches!(args.first().map(String::as_str), Some("--diag")) {
292 let topic = args.get(1).map(String::as_str).unwrap_or("");
293 match topic {
294 "wrr" => match diag::run_wrr_diag(exe_dir).await {
295 Ok(()) => std::process::exit(0),
296 Err(msg) => {
297 eprintln!("--diag wrr failed: {}", msg);
298 std::process::exit(1);
299 }
300 },
301 // Phase E.7 — operator visibility into the srv reducer's
302 // canonical state (workspaces / tabs / blocks / sagas) +
303 // recent activity. Same `Tool` IPC pattern as `--diag wrr`,
304 // talks to the srv pipe instead of the launcher pipe.
305 "srv" => match diag::run_srv_diag(exe_dir).await {
306 Ok(()) => std::process::exit(0),
307 Err(msg) => {
308 eprintln!("--diag srv failed: {}", msg);
309 std::process::exit(1);
310 }
311 },
312 // sagas is handled above, before the runtime check.
313 "sagas" => {
314 // Should never reach here — `sagas` is matched + handled
315 // above the CEF runtime check. Kept for completeness.
316 unreachable!("--diag sagas is handled before runtime check");
317 }
318 "" => {
319 eprintln!("usage: agentmux.exe --diag <topic>\nknown topics: wrr, srv, sagas");
320 std::process::exit(2);
321 }
322 other => {
323 eprintln!("unknown --diag topic: {} (known: wrr, srv, sagas)", other);
324 std::process::exit(2);
325 }
326 }
327 }
328
329 #[cfg(target_os = "windows")]
330 {
331 run_windows(exe_dir, &real_exe, &args).await;
332 }
333
334 #[cfg(not(target_os = "windows"))]
335 {
336 // Phase 1 (SPEC_LAUNCHER_MACOS_DEV_INTEGRATION_2026_05_30):
337 // the launcher now owns srv + host on macOS/Linux too — it
338 // spawns the backend, hands the host its endpoints via env,
339 // and supervises both with the same crash budget Windows uses.
340 // The legacy exec-into-host escape hatch lives in
341 // `task dev:standalone` (host invoked directly, no launcher).
342 run_unix(exe_dir, &real_exe, &args).await;
343 }
344}
345
346/// Phase 1 host supervision (spec
347/// `docs/specs/SPEC_SERVICE_SUPERVISION_AND_RECOVERY_2026_05_20.md`): on an
348/// abnormal host exit the launcher relaunches the host, but at most
349/// `HOST_RESTART_BUDGET` times within `HOST_RESTART_WINDOW` — a crash budget
350/// so a deterministic crash cannot spin forever (spec §10-A). Shared by
351/// the Windows (`run_windows`) and Unix (`run_unix`) supervisors.
352const HOST_RESTART_BUDGET: usize = 3;
353const HOST_RESTART_WINDOW: std::time::Duration = std::time::Duration::from_secs(60);
354
355/// Spawn the CEF host suspended, assign it to the launcher's Job Object, and
356/// resume it. Returns the running child, or `None` if any step failed — the
357/// caller decides (fatal on first launch, give-up on a restart). `splash_event`
358/// is passed on every launch — including restarts — so a relaunched host can
359/// still dismiss a splash left pending by a host that crashed pre-first-frame.
360/// `disable_gpu` is the retry ladder's rung-2 degraded mode (spec §7): when set
361/// the host is launched with `--disable-gpu` (software rendering).
362#[cfg(target_os = "windows")]
363fn spawn_host_supervised(
364 real_exe: &std::path::Path,
365 args: &[String],
366 srv: &srv_spawner::SrvSpawnResult,
367 host_env: &[(&'static str, std::ffi::OsString)],
368 pipe_path: &str,
369 job_present: bool,
370 job_handle: windows_sys::Win32::Foundation::HANDLE,
371 splash_event: Option<&str>,
372 disable_gpu: bool,
373) -> Option<tokio::process::Child> {
374 use windows_sys::Win32::System::Threading::CREATE_SUSPENDED;
375
376 // The launcher's resolved `real_exe` lives in `runtime/`; pass its
377 // parent dir as AGENTMUX_HOME so the host can anchor asset lookups
378 // (frontend/index.html, etc.) on something stable rather than
379 // `std::env::current_exe()`. Windows's GetModuleFileName keeps
380 // returning the original load-time path even after the parent dir
381 // is renamed or unlinked out from under it — the 2026-05-28
382 // incident pattern. AGENTMUX_HOME is whatever path *we* (the
383 // launcher) successfully resolved real_exe through, so it always
384 // points at the runtime dir that actually contains the binaries.
385 // See docs/retro/retro-portable-rm-running-install-2026-05-28.md.
386 let host_runtime_dir = real_exe.parent().map(|p| p.to_path_buf());
387
388 let mut host_cmd = tokio::process::Command::new(real_exe);
389 host_cmd
390 .args(args)
391 .env("AGENTMUX_BACKEND_WS", &srv.ws_endpoint)
392 .env("AGENTMUX_BACKEND_WEB", &srv.web_endpoint)
393 .env("AGENTMUX_BACKEND_PID", srv.pid.to_string())
394 .env("AGENTMUX_AUTH_KEY", &srv.auth_key)
395 .env("AGENTMUX_INSTANCE_ID", &srv.instance_id)
396 .envs(host_env.iter().cloned())
397 .env("AGENTMUX_LAUNCHER_PIPE", pipe_path)
398 .creation_flags(CREATE_SUSPENDED)
399 .kill_on_drop(false); // J0 handles cleanup.
400 if let Some(dir) = host_runtime_dir {
401 host_cmd.env("AGENTMUX_HOME", dir);
402 }
403 if let Some(name) = splash_event {
404 host_cmd.env("AGENTMUX_SPLASH_EVENT", name);
405 }
406 // Retry-ladder rung 2 (spec §7): software rendering — no GPU process to
407 // crash. A Chromium switch the host forwards to CEF.
408 if disable_gpu {
409 host_cmd.arg("--disable-gpu");
410 }
411
412 let mut host_child = match host_cmd.spawn() {
413 Ok(c) => c,
414 Err(e) => {
415 log(&format!("failed to spawn CEF host: {}", e));
416 return None;
417 }
418 };
419 let host_pid = host_child.id().unwrap_or(0);
420 log(&format!("spawned CEF host pid={} (suspended)", host_pid));
421
422 // Assign to J0 BEFORE resuming so CEF render children inherit the job.
423 if job_present && host_pid != 0 {
424 match srv_spawner::assign_pid_to_job(host_pid, job_handle) {
425 Ok(()) => log(&format!(
426 "Job Object assigned to host pid={}, KILL_ON_JOB_CLOSE active",
427 host_pid
428 )),
429 Err(e) => log(&format!(
430 "WARN: AssignProcessToJobObject(host) failed: {} — host children may escape job",
431 e
432 )),
433 }
434 }
435
436 // Resume the suspended main thread.
437 if let Err(e) = resume_main_thread(host_pid) {
438 log(&format!("failed to resume host pid={}: {}", host_pid, e));
439 let _ = host_child.start_kill();
440 return None;
441 }
442 Some(host_child)
443}
444
445/// Spawn the CEF host on Unix with the srv endpoints + canonical
446/// data-dir env handed off, mirroring `spawn_host_supervised` minus the
447/// Windows-only machinery (Job Object assignment, CREATE_SUSPENDED /
448/// resume, named-pipe handle, splash event). `disable_gpu` is the retry
449/// ladder's degraded rung (software rendering). Returns the running
450/// child or `None` if spawn failed.
451///
452/// AGENTMUX_HOME is intentionally NOT set: on the flat dev layout the
453/// host's `current_exe().parent()` fallback resolves to the same dir the
454/// launcher would export, so omitting it keeps asset + framework lookup
455/// byte-identical to the legacy direct-invoke (`task dev:standalone`)
456/// path. Phase 2 will set it once the production runtime/ layout lands
457/// on macOS.
458#[cfg(not(target_os = "windows"))]
459fn spawn_host_unix(
460 real_exe: &std::path::Path,
461 args: &[String],
462 srv: &srv_spawner::SrvSpawnResult,
463 host_env: &[(&'static str, std::ffi::OsString)],
464 disable_gpu: bool,
465) -> Option<tokio::process::Child> {
466 let mut host_cmd = tokio::process::Command::new(real_exe);
467 host_cmd
468 .args(args)
469 .env("AGENTMUX_BACKEND_WS", &srv.ws_endpoint)
470 .env("AGENTMUX_BACKEND_WEB", &srv.web_endpoint)
471 .env("AGENTMUX_BACKEND_PID", srv.pid.to_string())
472 .env("AGENTMUX_AUTH_KEY", &srv.auth_key)
473 .env("AGENTMUX_INSTANCE_ID", &srv.instance_id)
474 // Parent-identity stamp: our pid == the host's getppid (we spawn it
475 // directly). A dev-build host normally ignores AGENTMUX_BACKEND_WS
476 // (it could be a stale value inherited from a parent agentmux pane);
477 // this lets the host verify the hand-off is genuinely ours THIS run
478 // and adopt our launcher-owned srv instead of double-spawning. See
479 // agentmux-cef/src/main.rs::launcher_is_genuine_parent.
480 .env("AGENTMUX_LAUNCHER_PID", std::process::id().to_string())
481 .envs(host_env.iter().cloned())
482 // We reap children ourselves on shutdown (SIGTERM, then SIGKILL
483 // backstop) — kill_on_drop would SIGKILL the host the moment the
484 // Child is dropped, robbing CEF of the chance to reap its render
485 // subprocesses cleanly.
486 .kill_on_drop(false);
487 if disable_gpu {
488 host_cmd.arg("--disable-gpu");
489 }
490 // Linux process-tree reap: PR_SET_PDEATHSIG asks the kernel to SIGKILL
491 // this child the moment its parent (the launcher) dies, even abnormally
492 // (e.g. launcher panic, OOM, SIGKILL from outside). This is the Linux
493 // analogue of Windows' Job Object KILL_ON_JOB_CLOSE that A0 wires up
494 // (SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_2026_06_05 §3 step 3 and
495 // §A1.4). Without it, a launcher crash orphans the CEF host onto PID 1
496 // and the user is left with a zombie tree until manual cleanup.
497 //
498 // The closure runs between fork() and execve() in the child; per
499 // POSIX it MUST NOT allocate, take locks, or call async-signal-unsafe
500 // functions. `prctl(2)` is async-signal-safe. macOS lacks prctl, so
501 // this is gated to Linux; macOS uses NSTask's auto-reap behavior.
502 #[cfg(target_os = "linux")]
503 {
504 use std::os::unix::process::CommandExt as _;
505 // Safety: prctl is async-signal-safe. No allocation, no syscalls
506 // beyond prctl itself. Errors from prctl are non-fatal — the host
507 // still spawns; we just lose the auto-reap guarantee.
508 unsafe {
509 host_cmd.pre_exec(|| {
510 libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL, 0, 0, 0);
511 Ok(())
512 });
513 }
514 }
515 match host_cmd.spawn() {
516 Ok(c) => {
517 let pid = c.id().unwrap_or(0);
518 log(&format!("spawned CEF host pid={} (unix)", pid));
519 Some(c)
520 }
521 Err(e) => {
522 log(&format!("failed to spawn CEF host: {}", e));
523 None
524 }
525 }
526}
527
528/// Send SIGTERM to a child so it can shut down gracefully — for the CEF
529/// host that means reaping its render subprocesses (a tokio `start_kill`
530/// SIGKILL would orphan them); for srv it's a clean shutdown. No-op if the
531/// child has already exited (its pid may have been reaped). Best-effort:
532/// the SIGKILL grace-window backstop in `run_unix` catches anything that
533/// ignores SIGTERM.
534#[cfg(not(target_os = "windows"))]
535fn terminate_child_gracefully(child: &tokio::process::Child) {
536 if let Some(pid) = child.id() {
537 // SAFETY: kill(2) with a process-scoped pid + a constant signal —
538 // no memory is touched. A stale pid just returns ESRCH (ignored).
539 unsafe {
540 libc::kill(pid as libc::pid_t, libc::SIGTERM);
541 }
542 }
543}
544
545/// Await the next delivery of a Unix signal, or never resolve if the
546/// signal stream couldn't be installed. Lets `run_unix`'s `select!`
547/// treat an absent handler as "this branch is dormant" rather than
548/// special-casing `Option` at every poll.
549#[cfg(not(target_os = "windows"))]
550async fn next_signal(s: &mut Option<tokio::signal::unix::Signal>) {
551 match s {
552 Some(sig) => {
553 sig.recv().await;
554 }
555 None => std::future::pending::<()>().await,
556 }
557}
558
559/// Bind the launcher's IPC socket with single-instance enforcement +
560/// crash-safe stale-socket recovery, serialized across concurrent
561/// launchers via `flock(2)`.
562///
563/// Returns a bound `UnixListener` on success. Calls `std::process::exit`
564/// on:
565/// * second-instance detection (exit code 0)
566/// * a hard bind failure that isn't `EADDRINUSE` (exit code 2)
567/// * unable to acquire the recovery lock (exit code 2)
568///
569/// Why the lockfile (codex P1 + reagent P1 on PR #1288): see the call-
570/// site comment. Two-launcher concurrent stale-cleanup would otherwise
571/// produce two live launchers for one data dir.
572#[cfg(not(target_os = "windows"))]
573fn bind_socket_with_recovery(
574 socket_path: &str,
575 data_dir: &std::path::Path,
576 dir_hash: &str,
577) -> tokio::net::UnixListener {
578 use std::os::unix::io::AsRawFd as _;
579
580 // Fast path: bind without contention.
581 match ipc::server::bind_first_unix_socket(socket_path) {
582 Ok(l) => return l,
583 Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { /* slow path below */ }
584 Err(e) => {
585 log(&format!("FATAL: bind {} failed: {}", socket_path, e));
586 eprintln!(
587 "AgentMux failed to start: could not bind IPC socket.\n\nSocket: {}\nError: {}",
588 socket_path, e
589 );
590 std::process::exit(2);
591 }
592 }
593
594 // Slow path: contention. Acquire the recovery lock so only one
595 // launcher at a time does the connect-probe + unlink + rebind.
596 let lock_path = format!("{}.lock", socket_path);
597 let lock_file = match std::fs::OpenOptions::new()
598 .create(true)
599 .read(true)
600 .write(true)
601 .truncate(false)
602 .open(&lock_path)
603 {
604 Ok(f) => f,
605 Err(e) => {
606 log(&format!(
607 "FATAL: could not open recovery lockfile {}: {}",
608 lock_path, e
609 ));
610 eprintln!(
611 "AgentMux failed to start: could not open IPC recovery lockfile.\n\nLockfile: {}\nError: {}",
612 lock_path, e
613 );
614 std::process::exit(2);
615 }
616 };
617 // Block until we have the lock — another launcher's recovery
618 // window is bounded by a single bind + a single connect-probe;
619 // we won't wait long. flock(2) is auto-released on close (when
620 // the OS reaps the launcher process), so a SIGKILL'd holder
621 // doesn't leak the lock.
622 if unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) } != 0 {
623 let errno = std::io::Error::last_os_error();
624 log(&format!(
625 "FATAL: flock({}, LOCK_EX) failed: {}",
626 lock_path, errno
627 ));
628 eprintln!(
629 "AgentMux failed to start: could not acquire IPC recovery lock.\n\nLockfile: {}\nError: {}",
630 lock_path, errno
631 );
632 std::process::exit(2);
633 }
634
635 // Retry the bind under the lock — another launcher may have
636 // already cleaned up the stale file while we were waiting on
637 // flock, leaving us free to bind directly.
638 match ipc::server::bind_first_unix_socket(socket_path) {
639 Ok(l) => return l,
640 Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { /* probe below */ }
641 Err(e) => {
642 log(&format!(
643 "FATAL: post-lock bind {} failed: {}",
644 socket_path, e
645 ));
646 eprintln!(
647 "AgentMux failed to start: post-lock IPC bind failed.\n\nSocket: {}\nError: {}",
648 socket_path, e
649 );
650 std::process::exit(2);
651 }
652 }
653
654 // Disambiguate: is the existing socket a real running launcher,
655 // or a stale file?
656 match std::os::unix::net::UnixStream::connect(socket_path) {
657 Ok(_) => {
658 // Real second-instance. Forward an `open_new_window` request to the
659 // already-running launcher's host (Windows-parity — main.rs:1292),
660 // then exit cleanly. SPEC_MACOS_LAUNCH_COHERENCE_2026_06_18.md.
661 eprintln!(
662 "AgentMux is already running for this data directory.\n\nSocket: {}",
663 socket_path
664 );
665 forward_open_new_window_or_log(data_dir, dir_hash);
666 log(&format!(
667 "[ipc] second-instance detected — existing launcher owns {}",
668 socket_path
669 ));
670 std::process::exit(0);
671 }
672 Err(connect_err)
673 if connect_err.kind() == std::io::ErrorKind::ConnectionRefused
674 || connect_err.raw_os_error() == Some(libc::ENOENT) =>
675 {
676 // Stale socket file from a crashed launcher. Unlink and
677 // rebind. The lock serializes us against other launchers
678 // ALSO doing recovery, but it does NOT block a fresh
679 // launcher taking the fast-path bind() above — that
680 // launcher is lock-free and can win the socket in the
681 // microsecond window between our `remove_file` and our
682 // `bind`. If that happens, AddrInUse means a real
683 // launcher just claimed the socket and WE are now the
684 // losing second instance, not a failed start.
685 // (Reagent P2 on PR #1288.)
686 log(&format!(
687 "[ipc] stale socket file at {} — unlinking and rebinding (under recovery lock)",
688 socket_path
689 ));
690 let _ = std::fs::remove_file(socket_path);
691 match ipc::server::bind_first_unix_socket(socket_path) {
692 Ok(l) => l,
693 Err(retry_e) if retry_e.kind() == std::io::ErrorKind::AddrInUse => {
694 eprintln!(
695 "AgentMux is already running for this data directory.\n\nSocket: {}",
696 socket_path
697 );
698 forward_open_new_window_or_log(data_dir, dir_hash);
699 log(&format!(
700 "[ipc] post-recovery bind lost the race to a fresh launcher on {} — exiting as second instance",
701 socket_path
702 ));
703 std::process::exit(0);
704 }
705 Err(retry_e) => {
706 log(&format!(
707 "FATAL: bind retry after stale-socket unlink failed: {}",
708 retry_e
709 ));
710 eprintln!(
711 "AgentMux failed to start: IPC rebind after stale cleanup failed.\n\nSocket: {}\nError: {}",
712 socket_path, retry_e
713 );
714 std::process::exit(2);
715 }
716 }
717 }
718 Err(other) => {
719 log(&format!(
720 "[ipc] AddrInUse but connect probe failed in an unexpected way: {} — \
721 treating as second instance and exiting cleanly",
722 other
723 ));
724 std::process::exit(0);
725 }
726 }
727 // `lock_file` drops here; flock auto-released on close.
728}
729
730/// Unix (macOS/Linux) main flow: resolve paths → bind launcher IPC
731/// socket (single-instance signal) → set up reducer / event log / saga
732/// coordinator / IPC server → spawn srv → spawn host with srv endpoints
733/// AND the launcher socket path in env → supervised wait → cleanup.
734///
735/// As of A1 (SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_2026_06_05.md §4)
736/// the launcher's window/pool/instance reducer + durable saga
737/// coordinator are now live on Linux. The 17 host-side `report_*` IPC
738/// calls reach the reducer; the saga log persists at
739/// `<data-dir>/db/launcher-sagas.db`; second-instance launches are
740/// detected via socket-bind contention.
741///
742/// Differs from `run_windows` only where the OS forces it:
743/// * No Job Object — A0 (`PR_SET_PDEATHSIG` in `spawn_host_unix` and
744/// `srv_spawner`) gives the equivalent kernel-side reap when the
745/// launcher dies abnormally. Terminal Ctrl+C reaches the process
746/// group; explicit SIGINT/SIGTERM handlers cover the
747/// `kill <launcher-pid>` case.
748/// * Unix-domain-socket IPC (A1) instead of named pipes; protocol on
749/// the wire is identical (newline-delimited JSON Command/Event).
750/// The launcher-side server uses `tokio::net::UnixListener`; the
751/// host-side client uses `tokio::net::UnixStream`. See
752/// `ipc::server::run_ipc_server` (Unix arm) and
753/// `agentmux-cef/src/launcher_ipc.rs::connect_to_launcher` (Unix arm).
754/// * srv-side IPC is still skipped on Linux (srv is launched with an
755/// empty `srv_pipe_path`); follow-up PR will bring srv's Unix
756/// socket online too.
757/// * Cleanup is SIGTERM-then-SIGKILL (we own the reap) rather than
758/// KILL_ON_JOB_CLOSE.
759///
760/// srv's stdin write-end is held for the launcher's lifetime — its EOF is
761/// srv's parent-death backstop if the launcher is SIGKILLed (the one case
762/// our signal handlers can't cover).
763#[cfg(not(target_os = "windows"))]
764async fn run_unix(
765 launcher_exe_dir: &std::path::Path,
766 real_exe: &std::path::Path,
767 args: &[String],
768) {
769 use tokio::signal::unix::{signal, SignalKind};
770
771 let version = env!("CARGO_PKG_VERSION");
772
773 // 1. Resolve + create data dirs (same authority as run_windows: srv +
774 // host receive these via env so they can't drift).
775 let paths = match data_dir::resolve_paths(launcher_exe_dir, version) {
776 Ok(p) => p,
777 Err(e) => {
778 log(&format!("FATAL: path resolution failed: {}", e));
779 eprintln!("Failed to resolve AgentMux data directories: {}", e);
780 std::process::exit(1);
781 }
782 };
783 log(&format!(
784 "paths resolved: data={} config={} user_home={} portable={}",
785 paths.data_dir.display(),
786 paths.config_dir.display(),
787 paths.user_home_dir.display(),
788 paths.portable_root.is_some(),
789 ));
790 if let Err(e) = data_dir::ensure_dirs(&paths) {
791 log(&format!("FATAL: failed to create data dirs: {}", e));
792 eprintln!("{}", e);
793 std::process::exit(1);
794 }
795
796 // -----------------------------------------------------------------
797 // A1.1 — IPC server setup on Linux (the wire is up). Mirrors the
798 // Windows path at the equivalent point in `run_windows`. Once this
799 // lands the host's 17 `report_*` IPC calls reach the (already
800 // platform-neutral) reducer, the window-pool / single-instance /
801 // instance-numbering logic activates, sagas get persisted to the
802 // launcher_saga.db SQLite log, and `--diag sagas` reads something
803 // useful.
804 //
805 // Spec: docs/specs/SPEC_LAUNCHER_LINUX_PACKAGED_AND_SPLASH_2026_06_05.md §4
806 // -----------------------------------------------------------------
807
808 // Compute the socket path from a data-dir hash + version, identical
809 // scoping rule to the Windows pipe namespace. `pipe_name` is now
810 // pure on both platforms — the side-effecting ensure step lives
811 // in `ensure_ipc_socket_dir` and is invoked separately below so
812 // that any future read-only inspector (e.g. a Linux `--diag`
813 // port) can call `pipe_name` without mutating the filesystem.
814 let pipe_version = option_env!("AGENTMUX_BUILD_LABEL")
815 .unwrap_or(env!("CARGO_PKG_VERSION"));
816 let dir_hash = hash::data_dir_hash16(&paths.data_dir, pipe_version);
817 let socket_path = ipc::pipe_name(&dir_hash);
818 log(&format!(
819 "launcher IPC socket = {} (data_dir={} pipe_version={})",
820 socket_path,
821 paths.data_dir.display(),
822 pipe_version
823 ));
824 // Ensure the socket dir exists with safe ownership/perms BEFORE
825 // any bind attempts. This may call std::process::exit(2) on a
826 // hostile-dir-on-disk scenario (cross-user squatting attack).
827 let _ = ipc::ensure_ipc_socket_dir();
828
829 // Single-instance handshake (A1.6). The bind is the authoritative
830 // signal: a second launcher pointing at the same data dir gets
831 // `EADDRINUSE`. The Windows path enjoys atomic `first_pipe_
832 // instance(true)`; Unix bind isn't atomic with respect to stale-
833 // file recovery, so we serialize that recovery via an `flock(2)`
834 // lockfile (codex P1 + reagent P1 on PR #1288).
835 //
836 // Concurrent-cleanup race the lockfile defends against:
837 // 1. Stale socket file remains from a crashed prior launcher.
838 // 2. Launcher A and B start concurrently. Both `bind` returns
839 // EADDRINUSE because the file exists.
840 // 3. Without the lock: both A and B `connect` → ECONNREFUSED
841 // (no listener on the stale file), both `unlink`, both
842 // `bind` succeeds → two live launchers for one data dir,
843 // and B's `unlink` may have removed A's freshly-bound
844 // socket file before B bound its own.
845 // 4. With the lock: A holds the lock → does probe + unlink +
846 // rebind atomically. B blocks on the lock. When A releases,
847 // B's retry-bind sees A's running listener (file exists) and
848 // B's `connect` succeeds → B exits cleanly as second
849 // instance. No double-recovery.
850 //
851 // The lockfile lives next to the socket as `<socket>.lock`. It is
852 // intentionally NOT removed on clean shutdown — the inode is
853 // tiny, and leaving it lets the next launcher reuse it without a
854 // create-race. (Stale-lock-file scenarios are bounded: flock(2)
855 // is auto-released on process exit even for SIGKILL.)
856 let first_socket = bind_socket_with_recovery(&socket_path, &paths.data_dir, &dir_hash);
857
858 // macOS: we are the first instance (second-instance launches exit inside
859 // bind_socket_with_recovery). Publish this instance's bound-socket identity
860 // to the splash's reopen handler so a Finder double-click / `open` (no -n)
861 // forwards open_new_window to exactly THIS (channel, version) instance —
862 // never a recomputed hash. SPEC_MACOS_REOPEN_NEW_WINDOW_2026_06_22.md.
863 #[cfg(target_os = "macos")]
864 splash_mac::set_reopen_target(paths.data_dir.clone(), dir_hash.clone());
865
866 // Broadcast bus for reducer-emitted events. Same capacity (1024)
867 // and rationale as the Windows path.
868 let (events_tx, _) =
869 tokio::sync::broadcast::channel::<agentmux_common::ipc::Event>(1024);
870
871 // Event log: in-memory ring + disk persistence at
872 // <data-dir>/launcher-events.log. Disk writer task spawned next.
873 let log_disk_path = paths.data_dir.join("launcher-events.log");
874 let event_log = std::sync::Arc::new(event_log::EventLog::new(Some(log_disk_path)));
875 let event_log_for_writer = std::sync::Arc::clone(&event_log);
876 let disk_writer_rx = events_tx.subscribe();
877 tokio::spawn(event_log::run_disk_writer(
878 event_log_for_writer,
879 disk_writer_rx,
880 ));
881
882 // Canonical state shared between IPC server + saga coordinator.
883 let state = std::sync::Arc::new(tokio::sync::Mutex::new(state::State::default()));
884
885 // Durable saga log at <data-dir>/db/launcher-sagas.db.
886 let saga_log_path = data_dir::launcher_saga_log_path(&paths.data_dir);
887 let saga_log = match saga::LauncherSagaLog::open(&saga_log_path) {
888 Ok(l) => std::sync::Arc::new(l),
889 Err(e) => {
890 log(&format!(
891 "FATAL: failed to open launcher saga log at {:?}: {}",
892 saga_log_path, e
893 ));
894 std::process::exit(2);
895 }
896 };
897
898 // Startup recovery walker: mark any saga left running from a prior
899 // crashed run as failed_compensation. Must run BEFORE coordinator
900 // spawn (LSD-3).
901 if let Err(e) = saga::compensate_unresolved_launcher_sagas(&saga_log).await {
902 log(&format!(
903 "[saga-recovery] WARN: walker failed: {} — coordinator will still spawn",
904 e
905 ));
906 }
907
908 // Startup retention vacuum (LSD-4).
909 let retention_days = config::load_saga_retention_days(&paths.user_home_dir, |w| log(w));
910 let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days);
911 match saga_log.vacuum_older_than(cutoff) {
912 Ok(removed) => log(&format!(
913 "[saga-log] vacuumed {} sagas older than {} (retention {} days)",
914 removed, cutoff, retention_days
915 )),
916 Err(e) => log(&format!("[saga-log] WARN: vacuum failed: {}", e)),
917 }
918
919 // Host pipe wrapper for saga-issued Commands → host. The IPC
920 // server's per-connection handler installs the host's writer once
921 // the host registers (see ipc/server.rs handle_connection's
922 // ClientKind::Host branch).
923 let host_pipe = std::sync::Arc::new(host_pipe::HostPipe::new(
924 events_tx.clone(),
925 std::sync::Arc::clone(&state),
926 ));
927
928 // Saga coordinator. Same construction + error handling as the
929 // Windows path; the coordinator itself is platform-neutral.
930 let saga_coord_inner =
931 saga::SagaCoordinator::new(events_tx.clone(), std::sync::Arc::clone(&state))
932 .with_log(std::sync::Arc::clone(&saga_log))
933 .unwrap_or_else(|e| {
934 log(&format!(
935 "[main] FATAL: failed to seed saga_id allocator: {}",
936 e
937 ));
938 std::process::exit(1);
939 })
940 .with_host_pipe(std::sync::Arc::clone(&host_pipe));
941 let saga_coord = std::sync::Arc::new(saga_coord_inner);
942 let saga_rx = events_tx.subscribe();
943 tokio::spawn(saga::run_coordinator(
944 std::sync::Arc::clone(&saga_coord),
945 saga_rx,
946 ));
947
948 let _ipc_handle = ipc::run_ipc_server(
949 socket_path.clone(),
950 first_socket,
951 ipc::server::ServerCtx {
952 launcher_pid: std::process::id(),
953 launcher_version: env!("CARGO_PKG_VERSION").to_string(),
954 state,
955 events_tx,
956 event_log,
957 host_pipe: std::sync::Arc::clone(&host_pipe),
958 },
959 );
960 log(&format!("IPC server started on {}", socket_path));
961
962 // 2. Spawn srv. The srv pipe path is the launcher-owned socket
963 // path scope (srv will gain its own Unix-socket bind in a
964 // follow-up; for now we still pass an empty string so srv's
965 // Windows-only IPC code stays disabled).
966 let srv_pipe_path = String::new();
967 let (srv_result, mut srv_child) =
968 match srv_spawner::spawn_srv(launcher_exe_dir, &paths, &srv_pipe_path).await {
969 Ok(pair) => pair,
970 Err(e) => {
971 log(&format!("FATAL: srv spawn failed: {}", e));
972 eprintln!("Failed to start backend: {}", e);
973 std::process::exit(1);
974 }
975 };
976
977 // CRITICAL (same rationale as run_windows): take srv's stdin out of
978 // the Child so tokio's wait() can't close it and trip srv's
979 // parent-watch EOF. Held until launcher exit.
980 let _srv_stdin_keepalive = srv_child.stdin.take();
981
982 // 3. Spawn the host with srv endpoints in env.
983 // dir_hash was computed once above for socket_path; reuse it here
984 // instead of re-hashing the same paths.data_dir + version.
985 let mut host_env = paths.common.to_env_vars();
986 host_env.push(("AGENTMUX_IPC_HASH", std::ffi::OsString::from(&dir_hash)));
987 // A1.3 — IPC env handshake. Tell the host where to find the
988 // launcher socket. The env var name `AGENTMUX_LAUNCHER_PIPE` is
989 // reused from the Windows side even though the underlying resource
990 // is a Unix-domain socket — keeps the 17 `report_*` call sites in
991 // `agentmux-cef/src/launcher_ipc.rs` unchanged and avoids touching
992 // the host's connect-on-startup code in `agentmux-cef/src/app.rs`.
993 host_env.push((
994 "AGENTMUX_LAUNCHER_PIPE",
995 std::ffi::OsString::from(&socket_path),
996 ));
997 // AGENTMUX_HOME = the host's runtime directory (siblings of the
998 // host binary — libcef.so, paks, locales, etc). Mirrors the
999 // Windows path so the host's asset-resolution code finds its
1000 // co-located data without searching $PATH-like fallbacks.
1001 if let Some(host_runtime) = real_exe.parent() {
1002 host_env.push((
1003 "AGENTMUX_HOME",
1004 std::ffi::OsString::from(host_runtime),
1005 ));
1006 }
1007 let mut host_child = match spawn_host_unix(real_exe, args, &srv_result, &host_env, false) {
1008 Some(c) => c,
1009 None => {
1010 log("FATAL: could not start CEF host — terminating");
1011 eprintln!("Failed to launch AgentMux.");
1012 terminate_child_gracefully(&srv_child);
1013 let _ = srv_child.start_kill();
1014 std::process::exit(1);
1015 }
1016 };
1017
1018 // 4. Signal handlers for `kill <launcher-pid>` (a terminal Ctrl+C
1019 // already signals the whole foreground group; these cover the
1020 // launcher-only case and make Ctrl+C deterministic too).
1021 let mut sigint = signal(SignalKind::interrupt()).ok();
1022 let mut sigterm = signal(SignalKind::terminate()).ok();
1023 if sigint.is_none() || sigterm.is_none() {
1024 log("WARN: failed to install one or more signal handlers — \
1025 relying on default termination + srv stdin-EOF backstop");
1026 }
1027
1028 // 5. Supervised wait loop — host crash budget mirrors run_windows.
1029 log("entering supervised host + srv wait (unix)");
1030 let mut host_restarts: Vec<std::time::Instant> = Vec::new();
1031 // Separate budget for system-OOM host exits (memory-aware relaunch); see
1032 // mem_supervisor + SPEC_MEMORY_PRESSURE_SUPERVISION_2026_06_16.
1033 let mut oom_restarts: Vec<std::time::Instant> = Vec::new();
1034 let mut last_abnormal_code: Option<i32> = None;
1035 let mut host_degraded = false;
1036 let exit_code = loop {
1037 tokio::select! {
1038 host_status = host_child.wait() => {
1039 use std::os::unix::process::ExitStatusExt;
1040 let status = match host_status {
1041 Ok(s) => s,
1042 Err(e) => {
1043 log(&format!("FATAL: host wait failed: {}", e));
1044 break 1;
1045 }
1046 };
1047 // Host killed by a signal. On a terminal Ctrl+C the host gets
1048 // SIGINT DIRECTLY (it shares our foreground process group), so
1049 // host_child.wait() can win the select! race against our own
1050 // SIGINT arm. Without this guard the host's signal-death has no
1051 // exit code → unwrap_or(1) → it looks like a crash and we'd
1052 // relaunch a replacement host that the SIGINT arm then has to
1053 // kill (reagent #1193 P2). Treat the group-shutdown signals
1054 // (SIGINT/SIGTERM/SIGHUP) as a clean shutdown; real crash
1055 // signals (SIGSEGV, SIGABRT, …) still fall through to the
1056 // crash-budget relaunch below.
1057 if let Some(sig) = status.signal() {
1058 if sig == libc::SIGINT || sig == libc::SIGTERM || sig == libc::SIGHUP {
1059 log(&format!("CEF host terminated by signal {} (group shutdown) — shutting down", sig));
1060 break 0;
1061 }
1062 log(&format!("CEF host killed by signal {} (crash) — entering crash-budget relaunch", sig));
1063 }
1064 let code = status.code().unwrap_or(1);
1065 if code == 0 {
1066 log("CEF host exited cleanly (code 0) — shutting down");
1067 break 0;
1068 }
1069 // Classify system-OOM (wait it out) vs a genuine host fault
1070 // (existing fast budget), mirroring run_windows. SPEC_MEMORY_
1071 // PRESSURE_SUPERVISION_2026_06_16 §5.B. On Linux a kernel-OOM-kill
1072 // arrives as a SIGKILL (code 1 here) and is caught by the low-
1073 // commit reading (SPEC §9.4).
1074 let commit_free = mem_supervisor::commit_free_mb();
1075 match mem_supervisor::classify_host_exit(code, commit_free) {
1076 mem_supervisor::HostExitClass::SystemOom => {
1077 let now = std::time::Instant::now();
1078 if mem_supervisor::budget_exhausted(
1079 &mut oom_restarts,
1080 now,
1081 mem_supervisor::OOM_RESTART_WINDOW,
1082 mem_supervisor::OOM_RESTART_BUDGET,
1083 ) {
1084 log(&format!(
1085 "CEF host hit system OOM (code {}, {} MB commit-free); OOM restart \
1086 budget exhausted ({} in {}s) — giving up",
1087 code,
1088 commit_free,
1089 mem_supervisor::OOM_RESTART_BUDGET,
1090 mem_supervisor::OOM_RESTART_WINDOW.as_secs()
1091 ));
1092 show_fatal_dialog(
1093 mem_supervisor::OOM_GIVEUP_TITLE,
1094 mem_supervisor::OOM_GIVEUP_BODY,
1095 );
1096 break code;
1097 }
1098 log(&format!(
1099 "CEF host hit system OOM (code {}, {} MB commit-free) — waiting for \
1100 memory to recover before relaunch",
1101 code, commit_free
1102 ));
1103 // Race the commit-recovery wait against shutdown + srv
1104 // death so the supervisor stays responsive during the
1105 // (up to OOM_RELAUNCH_DEADLINE) wait — without this the
1106 // SIGINT/SIGTERM + srv arms are starved for the whole
1107 // wait (reagent P2). Mirrors the outer select! arms.
1108 let recovered = tokio::select! {
1109 r = mem_supervisor::await_commit_recovery(log) => r,
1110 srv_status = srv_child.wait() => {
1111 use std::os::unix::process::ExitStatusExt;
1112 match srv_status {
1113 Ok(s) => {
1114 let group_shutdown = matches!(
1115 s.signal(),
1116 Some(sig) if sig == libc::SIGINT || sig == libc::SIGTERM || sig == libc::SIGHUP
1117 );
1118 if s.success() || group_shutdown {
1119 log("srv exited as part of shutdown (during OOM wait) — shutting down");
1120 break 0;
1121 }
1122 log(&format!(
1123 "srv exited UNEXPECTEDLY during OOM wait with code {} — terminating launcher",
1124 s.code().unwrap_or(1)
1125 ));
1126 }
1127 Err(e) => log(&format!("FATAL: srv wait failed during OOM wait: {}", e)),
1128 }
1129 break 1;
1130 }
1131 _ = next_signal(&mut sigint) => {
1132 log("received SIGINT during OOM wait — shutting down");
1133 break 0;
1134 }
1135 _ = next_signal(&mut sigterm) => {
1136 log("received SIGTERM during OOM wait — shutting down");
1137 break 0;
1138 }
1139 };
1140 if !recovered {
1141 show_fatal_dialog(
1142 mem_supervisor::OOM_GIVEUP_TITLE,
1143 mem_supervisor::OOM_GIVEUP_BODY,
1144 );
1145 break code;
1146 }
1147 match spawn_host_unix(real_exe, args, &srv_result, &host_env, true) {
1148 Some(c) => host_child = c,
1149 None => {
1150 log("host relaunch failed to spawn — giving up");
1151 break code;
1152 }
1153 }
1154 }
1155 mem_supervisor::HostExitClass::Abnormal => {
1156 let now = std::time::Instant::now();
1157 host_restarts.retain(|t| now.duration_since(*t) < HOST_RESTART_WINDOW);
1158 if host_restarts.len() >= HOST_RESTART_BUDGET {
1159 log(&format!(
1160 "CEF host exited abnormally (code {}); restart budget exhausted \
1161 ({} in {}s) — giving up",
1162 code,
1163 host_restarts.len(),
1164 HOST_RESTART_WINDOW.as_secs()
1165 ));
1166 break code;
1167 }
1168 host_restarts.push(now);
1169 if last_abnormal_code == Some(code) {
1170 host_degraded = true;
1171 }
1172 last_abnormal_code = Some(code);
1173 log(&format!(
1174 "CEF host exited abnormally (code {}) — relaunching (restart {}/{}{})",
1175 code,
1176 host_restarts.len(),
1177 HOST_RESTART_BUDGET,
1178 if host_degraded { ", degraded: --disable-gpu" } else { "" }
1179 ));
1180 match spawn_host_unix(real_exe, args, &srv_result, &host_env, host_degraded) {
1181 Some(c) => host_child = c,
1182 None => {
1183 log("host relaunch failed to spawn — giving up");
1184 break code;
1185 }
1186 }
1187 }
1188 }
1189 }
1190 srv_status = srv_child.wait() => {
1191 use std::os::unix::process::ExitStatusExt;
1192 match srv_status {
1193 Ok(s) => {
1194 // Mirror the host arm's group-shutdown guard. On a
1195 // terminal Ctrl+C srv gets SIGINT DIRECTLY (it shares
1196 // our foreground process group); its own signal handler
1197 // shuts it down gracefully and it exits with code 0
1198 // (agentmux-srv/src/main.rs — SIGINT/SIGTERM → cancel
1199 // token → clean exit). srv_child.wait() can win this
1200 // select! race against our own SIGINT arm, so a clean
1201 // (code 0) or signal-killed exit is a group teardown,
1202 // NOT an unexpected srv death — don't log a scary
1203 // message and break 1 (reagent #1193 P2).
1204 let group_shutdown = matches!(
1205 s.signal(),
1206 Some(sig) if sig == libc::SIGINT || sig == libc::SIGTERM || sig == libc::SIGHUP
1207 );
1208 if s.success() || group_shutdown {
1209 log("srv exited as part of shutdown — shutting down");
1210 break 0;
1211 }
1212 log(&format!(
1213 "srv exited UNEXPECTEDLY (host still running) with code {} — terminating launcher",
1214 s.code().unwrap_or(1)
1215 ));
1216 }
1217 Err(e) => log(&format!("FATAL: srv wait failed: {}", e)),
1218 }
1219 break 1;
1220 }
1221 _ = next_signal(&mut sigint) => {
1222 log("received SIGINT — shutting down");
1223 break 0;
1224 }
1225 _ = next_signal(&mut sigterm) => {
1226 log("received SIGTERM — shutting down");
1227 break 0;
1228 }
1229 }
1230 };
1231
1232 // 6. Cleanup. SIGTERM both children so the host reaps its render
1233 // subprocesses (and srv shuts down cleanly), wait a short grace
1234 // window, then SIGKILL any survivor. Dropping the stdin keepalive
1235 // is srv's secondary shutdown trigger (parent-watch EOF).
1236 log("terminating children (SIGTERM → grace → SIGKILL)");
1237 terminate_child_gracefully(&host_child);
1238 terminate_child_gracefully(&srv_child);
1239 drop(_srv_stdin_keepalive);
1240 let _ = tokio::time::timeout(
1241 std::time::Duration::from_millis(1500),
1242 async {
1243 let _ = host_child.wait().await;
1244 let _ = srv_child.wait().await;
1245 },
1246 )
1247 .await;
1248 let _ = host_child.start_kill();
1249 let _ = srv_child.start_kill();
1250 log(&format!("launcher exiting with code {}", exit_code));
1251 std::process::exit(exit_code);
1252}
1253
1254/// Windows main flow: resolve paths → create J0 → spawn srv → spawn
1255/// host with srv endpoints in env → supervised wait → cleanup.
1256#[cfg(target_os = "windows")]
1257async fn run_windows(
1258 launcher_exe_dir: &std::path::Path,
1259 real_exe: &std::path::Path,
1260 args: &[String],
1261) {
1262
1263 let version = env!("CARGO_PKG_VERSION");
1264
1265 // 1. Resolve data_dir / config_dir / user_home_dir. Both srv and
1266 // host receive these via env so they don't recompute (and so they
1267 // can't drift). Host's existing data_dir computation in sidecar.rs
1268 // still runs as a fallback for `task dev` mode where the launcher
1269 // is not in the loop.
1270 let paths = match data_dir::resolve_paths(launcher_exe_dir, version) {
1271 Ok(p) => p,
1272 Err(e) => {
1273 log(&format!("FATAL: path resolution failed: {}", e));
1274 eprintln!("Failed to resolve AgentMux data directories: {}", e);
1275 std::process::exit(1);
1276 }
1277 };
1278 log(&format!(
1279 "paths resolved: data={} config={} user_home={} portable={}",
1280 paths.data_dir.display(),
1281 paths.config_dir.display(),
1282 paths.user_home_dir.display(),
1283 paths.portable_root.is_some(),
1284 ));
1285 if let Err(e) = data_dir::ensure_dirs(&paths) {
1286 log(&format!("FATAL: failed to create data dirs: {}", e));
1287 eprintln!("{}", e);
1288 std::process::exit(1);
1289 }
1290
1291 // 2. Create the launcher's Job Object J0 BEFORE any spawn. Both
1292 // srv and host will be assigned to it (so they're siblings under
1293 // a single OS-enforced cleanup contract). Failure here drops us
1294 // into "degraded mode" — children spawn but won't be reaped on
1295 // launcher death.
1296 let job: Option<JobHandle> = match create_job_object() {
1297 Ok(handle) => {
1298 log("Job Object created (KILL_ON_JOB_CLOSE active)");
1299 Some(JobHandle(handle))
1300 }
1301 Err(e) => {
1302 log(&format!(
1303 "WARN: Job Object setup failed: {} (process-tree cleanup degraded)",
1304 e
1305 ));
1306 None
1307 }
1308 };
1309 let job_handle: windows_sys::Win32::Foundation::HANDLE =
1310 job.as_ref().map(|j| j.0).unwrap_or(std::ptr::null_mut());
1311
1312 // Phase B.2: start the named-pipe IPC server BEFORE spawning
1313 // any children. Host connects to this pipe at startup using the
1314 // AGENTMUX_LAUNCHER_PIPE env var the launcher passes below.
1315 //
1316 // The server runs in its own Tokio task; the JoinHandle is held
1317 // for the rest of run_windows so the task isn't cancelled mid-
1318 // accept. Server owns the namespace `\\.\pipe\agentmux-{hash}\
1319 // command` per data dir, so multi-instance launchers (different
1320 // data dirs) get distinct pipes.
1321 //
1322 // Phase B.6: the bind itself is the single-instance signal.
1323 // `bind_first_pipe_instance` synchronously reserves the pipe;
1324 // a second launcher pointing at the same data dir gets
1325 // ERROR_ACCESS_DENIED. We surface that to the user as
1326 // "AgentMux is already running for this data directory" and
1327 // exit cleanly BEFORE spawning srv/host (otherwise the second
1328 // host would briefly contend on the CEF cache lockfile).
1329 // For release builds, CARGO_PKG_VERSION (semver) is the isolation key —
1330 // two different versions on the same channel get distinct pipes.
1331 // For local builds, package.sh bakes AGENTMUX_BUILD_LABEL (which includes
1332 // a per-build timestamp stamp), so each successive `task package` run gets
1333 // its own single-instance domain and can start a fresh window even while a
1334 // previous local build is running. Session data is still shared (data_dir
1335 // is keyed on channel+semver, not the label), so agents/auth carry over.
1336 let pipe_version = option_env!("AGENTMUX_BUILD_LABEL")
1337 .unwrap_or(env!("CARGO_PKG_VERSION"));
1338 let dir_hash = hash::data_dir_hash16(&paths.data_dir, pipe_version);
1339 let pipe_path = ipc::pipe_name(&dir_hash);
1340 // Isolation telemetry: record exactly which keyed resources this instance
1341 // claims, so a cross-instance collision is diagnosable from the log alone
1342 // (two live PIDs claiming the same dir_hash) instead of inferred after a
1343 // vanished window. The launcher's job object is unnamed, so there is no
1344 // shared lifecycle handle to log. See
1345 // docs/specs/SPEC_MULTI_INSTANCE_ISOLATION_HARDENING_2026_06_03.md.
1346 log(&format!(
1347 "instance_claim pid={} version={} data_dir={} dir_hash={} pipe={}",
1348 std::process::id(),
1349 pipe_version,
1350 paths.data_dir.display(),
1351 dir_hash,
1352 pipe_path
1353 ));
1354 let first_pipe = match ipc::server::bind_first_pipe_instance(&pipe_path) {
1355 Ok(p) => p,
1356 Err(e) => {
1357 // ERROR_ACCESS_DENIED (5) means another launcher already
1358 // owns this pipe — i.e., another AgentMux is running for
1359 // this data dir. The user-facing behavior matches the
1360 // status-bar version popup's "new window": forward an
1361 // `open_new_window` IPC POST to the existing host and
1362 // exit 0. The named-pipe bind is the AUTHORITATIVE
1363 // single-instance signal; this HTTP call is just the
1364 // forwarding hint. Other errors (namespace misconfig,
1365 // security descriptor failure) genuinely fail — show
1366 // the dialog and exit 2.
1367 const ERROR_ACCESS_DENIED: i32 = 5;
1368 let already_running = e.raw_os_error() == Some(ERROR_ACCESS_DENIED);
1369 log(&format!(
1370 "pipe bind failed (already_running={}): {} pipe={}",
1371 already_running, e, pipe_path
1372 ));
1373 if already_running {
1374 match forward_open_new_window(&paths.data_dir, &dir_hash) {
1375 Ok(()) => {
1376 log("forwarded open_new_window to existing instance — exiting 0");
1377 std::process::exit(0);
1378 }
1379 Err(ForwardError::Transient(reason)) => {
1380 // Transient race: the host is alive (pipe is
1381 // held by the first launcher) but its
1382 // forwarding hint isn't readable yet —
1383 // typically because the host is mid-CEF-init
1384 // and hasn't written `<data-dir>/ipc-port`
1385 // yet. Silent exit so the user isn't punished
1386 // for double-clicking quickly.
1387 log(&format!("forward transient: {} — exiting 0 silently", reason));
1388 std::process::exit(0);
1389 }
1390 Err(ForwardError::Fatal(reason)) => {
1391 // Fatal forward failure: the port file IS
1392 // readable, so the host got far enough to
1393 // publish it, but the HTTP path is dead
1394 // (connect refused, write failed). Could be
1395 // a hung host, a port collision, or
1396 // ERROR_ACCESS_DENIED that wasn't really
1397 // "another instance" (namespace conflict).
1398 // Surface the dialog so the user sees that
1399 // something is genuinely broken rather than
1400 // a silent no-op. (codex P2 PR #598.)
1401 log(&format!("forward fatal: {} — surfacing dialog", reason));
1402 show_fatal_dialog(
1403 "AgentMux",
1404 &format!(
1405 "AgentMux appears to already be running but isn't responding.\n\nData dir: {}\nReason: {}\n\nClose any leftover AgentMux processes and try again. If the problem persists, check the launcher log.",
1406 paths.data_dir.display(),
1407 reason
1408 ),
1409 );
1410 std::process::exit(2);
1411 }
1412 }
1413 }
1414 // Genuine bind failure (not "already running"). Surface
1415 // it loudly because it indicates a system-level problem.
1416 show_fatal_dialog(
1417 "AgentMux",
1418 &format!(
1419 "AgentMux failed to start: could not bind IPC pipe.\n\nPipe: {}\nError: {}\n\nIf the problem persists, check the launcher log.",
1420 pipe_path, e
1421 ),
1422 );
1423 std::process::exit(2);
1424 }
1425 };
1426 // Spawn the native pre-splash immediately after claiming the
1427 // single-instance pipe — before srv spawn and CEF init.
1428 // The event name is forwarded to the CEF host as
1429 // AGENTMUX_SPLASH_EVENT so it can signal dismiss from on_load_end.
1430 #[cfg(target_os = "windows")]
1431 let splash_event_name = if splash_config::splash_disabled() {
1432 None // splash:disabled / AGENTMUX_SPLASH=0 — no event, no window (SPEC §6)
1433 } else {
1434 splash::spawn_splash(&dir_hash)
1435 };
1436 #[cfg(not(target_os = "windows"))]
1437 let splash_event_name: Option<String> = None;
1438
1439 // Phase B.8 — broadcast bus for reducer-emitted events. Capacity
1440 // 1024 is comfortable headroom for the launcher's event volume
1441 // (~10–50 events per user action × handful of subscribers); a
1442 // lagging client gets `RecvError::Lagged` and reconnects.
1443 let (events_tx, _) = tokio::sync::broadcast::channel::<agentmux_common::ipc::Event>(1024);
1444
1445 // Phase D.2 — event log: in-memory ring (replay source for D.3's
1446 // GetEvents) + optional disk persistence at
1447 // `<data-dir>/launcher-events.log` for crash forensics.
1448 let log_disk_path = paths.data_dir.join("launcher-events.log");
1449 let event_log = std::sync::Arc::new(event_log::EventLog::new(Some(log_disk_path)));
1450 let event_log_for_writer = std::sync::Arc::clone(&event_log);
1451 let disk_writer_rx = events_tx.subscribe();
1452 tokio::spawn(event_log::run_disk_writer(event_log_for_writer, disk_writer_rx));
1453
1454 // Phase E.1a — canonical state shared between IPC server + saga
1455 // coordinator (and, in E.5, individual sagas). Single Mutex
1456 // owner, multiple readers via Arc.
1457 let state = std::sync::Arc::new(tokio::sync::Mutex::new(state::State::default()));
1458
1459 // LSD-2 — open the durable launcher saga log at
1460 // `<data-dir>/db/launcher-sagas.db` (separate file from
1461 // `launcher-events.log`; the saga log is structured SQLite, the
1462 // event log is append-only JSONL). Failure to open is a launcher
1463 // startup error — without the log, sagas have no crash-recovery
1464 // story (LSD-3 walks `unresolved_sagas` to mark interrupted
1465 // sagas `failed_compensation`). Spec
1466 // `docs/specs/SPEC_LAUNCHER_SAGA_DURABILITY_2026-05-01.md` §3.1.
1467 //
1468 // `launcher_saga_log_path` performs the back-compat move from
1469 // the pre-AUDIT_SQLITE_SYSTEMS_2026_05_19.md location
1470 // (`<data-dir>/launcher-sagas.db` — outside `db/`) into the
1471 // canonical `db/` subdir alongside srv's SQLite files.
1472 let saga_log_path = data_dir::launcher_saga_log_path(&paths.data_dir);
1473 let saga_log = match saga::LauncherSagaLog::open(&saga_log_path) {
1474 Ok(l) => std::sync::Arc::new(l),
1475 Err(e) => {
1476 log(&format!(
1477 "FATAL: failed to open launcher saga log at {:?}: {}",
1478 saga_log_path, e
1479 ));
1480 std::process::exit(2);
1481 }
1482 };
1483
1484 // LSD-3 — startup recovery walker. Walks the durable saga log,
1485 // marks any saga still in `running` / `compensating` / `failed`
1486 // (left over from a crashed prior run) as `failed_compensation`
1487 // so operators see them in `--diag sagas` and the next coordinator
1488 // run can't accidentally double-act on partially-applied effects.
1489 // MUST run BEFORE `tokio::spawn(saga::run_coordinator(..))` below
1490 // (LSD spec §5 risk #5: don't spawn while recovery is in progress).
1491 // Runs BEFORE LSD-4 vacuum so just-recovered sagas land in their
1492 // failed_compensation state for the operator to see — vacuum
1493 // honors the 7-day retention window and won't immediately purge.
1494 // Spec `docs/specs/SPEC_LAUNCHER_SAGA_DURABILITY_2026-05-01.md` §3.5.
1495 if let Err(e) = saga::compensate_unresolved_launcher_sagas(&saga_log).await {
1496 log(&format!(
1497 "[saga-recovery] WARN: walker failed: {} — coordinator will still spawn; prior crashed sagas remain unresolved until next restart",
1498 e
1499 ));
1500 }
1501
1502 // LSD-4 — startup retention vacuum. Runs once per launcher boot,
1503 // before the coordinator subscribes, so any rows it deletes are
1504 // already terminal and can't possibly belong to an in-flight saga
1505 // the coordinator is about to drive (see `vacuum_older_than` SQL —
1506 // `running` and `compensating` rows are unreachable by the DELETE
1507 // regardless of timing). Failure is non-fatal.
1508 // Spec §3.6.
1509 let retention_days =
1510 config::load_saga_retention_days(&paths.user_home_dir, |w| log(w));
1511 let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days);
1512 match saga_log.vacuum_older_than(cutoff) {
1513 Ok(removed) => log(&format!(
1514 "[saga-log] vacuumed {} sagas older than {} (retention {} days)",
1515 removed, cutoff, retention_days
1516 )),
1517 Err(e) => log(&format!("[saga-log] WARN: vacuum failed: {}", e)),
1518 }
1519
1520 // CPD-2 — launcher → host pipe wrapper. Owns the writer half of
1521 // the host's IPC connection (installed by the per-connection
1522 // handler in `ipc::server` once the host registers) and exposes
1523 // `send_command` / `send_event` to the rest of the launcher.
1524 // CPD-2 wires the wrapper + refactors event fanout for the host
1525 // connection to flow through here. CPD-3 wires this into the
1526 // saga coordinator's `apply_action` so `IssueCmd::Host` actions
1527 // dispatch live (no longer log-only).
1528 let host_pipe = std::sync::Arc::new(host_pipe::HostPipe::new(
1529 events_tx.clone(),
1530 std::sync::Arc::clone(&state),
1531 ));
1532
1533 // Phase E.1a — saga coordinator task. Subscribes to the broadcast
1534 // bus, drives in-flight sagas. E.1a registry is empty — framework
1535 // only. E.5 adds the first concrete saga consumer (tear-off).
1536 // LSD-2 — durable saga log is now installed; every lifecycle
1537 // transition is persisted.
1538 // CPD-3 — install `host_pipe` so saga `IssueCmd::Host` actions
1539 // dispatch through the launcher → host wire instead of being
1540 // log-only.
1541 //
1542 // Subscribe BEFORE spawning so the race window between construction
1543 // and first `recv()` doesn't drop early events. (reagent P2 PR #609.)
1544 // Same pattern as the disk writer above.
1545 // with_log() can fail if max_saga_id() fails (e.g. corrupted SQLite
1546 // file). Treat as fatal — continuing with a default next_saga_id=1
1547 // while the log is attached would let the coordinator silently
1548 // mutate prior saga history on restart. Better to crash loudly so
1549 // operators see + investigate. (codex P1 PR #645 round 2.)
1550 let saga_coord_inner = saga::SagaCoordinator::new(events_tx.clone(), std::sync::Arc::clone(&state))
1551 .with_log(std::sync::Arc::clone(&saga_log))
1552 .unwrap_or_else(|e| {
1553 log(&format!(
1554 "[main] FATAL: failed to seed saga_id allocator from launcher_saga.max(saga_id): {} — refusing to start with degraded coordinator",
1555 e
1556 ));
1557 std::process::exit(1);
1558 })
1559 .with_host_pipe(std::sync::Arc::clone(&host_pipe));
1560 let saga_coord = std::sync::Arc::new(saga_coord_inner);
1561 let saga_rx = events_tx.subscribe();
1562 tokio::spawn(saga::run_coordinator(
1563 std::sync::Arc::clone(&saga_coord),
1564 saga_rx,
1565 ));
1566
1567 let _ipc_handle = ipc::run_ipc_server(
1568 pipe_path.clone(),
1569 first_pipe,
1570 ipc::server::ServerCtx {
1571 launcher_pid: std::process::id(),
1572 launcher_version: env!("CARGO_PKG_VERSION").to_string(),
1573 state,
1574 events_tx,
1575 event_log,
1576 host_pipe: std::sync::Arc::clone(&host_pipe),
1577 },
1578 );
1579 log(&format!("IPC server started on {}", pipe_path));
1580
1581 // 3. Spawn srv first. Host needs srv's endpoints to skip its own
1582 // spawn_backend path. Srv signals readiness via AGENTMUXSRV-ESTART on
1583 // stderr; the spawner returns once we see that line (or after a
1584 // 30s timeout).
1585 // Phase E.1b — pre-compute srv's pipe path (same data-dir hash
1586 // as launcher's pipe) and pass via env so srv binds it on
1587 // startup. Launcher is the sole authority for the data-dir hash.
1588 let srv_pipe_path = ipc::srv_pipe_name(&dir_hash);
1589 log(&format!("[ipc] srv pipe path = {}", srv_pipe_path));
1590
1591 let (srv_result, mut srv_child) = match srv_spawner::spawn_srv(
1592 launcher_exe_dir,
1593 &paths,
1594 &srv_pipe_path,
1595 job_handle,
1596 )
1597 .await
1598 {
1599 Ok(pair) => pair,
1600 Err(e) => {
1601 log(&format!("FATAL: srv spawn failed: {}", e));
1602 eprintln!("Failed to start backend: {}", e);
1603 drop(job);
1604 std::process::exit(1);
1605 }
1606 };
1607
1608 // CRITICAL: tokio::process::Child::wait() proactively drops
1609 // self.stdin before waiting (tokio source comment: "Ensure stdin
1610 // is closed so the child can't read from it any more"). agentmux-
1611 // srv has a parent-watch loop on its own stdin — when stdin reads
1612 // 0 bytes (EOF from a closed write end), it interprets that as
1613 // "parent died" and shuts itself down. tokio's wait() would
1614 // trigger that within milliseconds, causing srv to exit before
1615 // the host even mounts its first browser. Move srv's stdin out
1616 // of the Child into a launcher-scope binding so tokio can't see
1617 // it (its take() returns None) and the pipe stays open for the
1618 // launcher's lifetime. (Smoke test on v0.33.447 caught this.)
1619 let _srv_stdin_keepalive = srv_child.stdin.take();
1620
1621 // 4-6. Spawn the host (suspended) → assign to J0 → resume, via
1622 // spawn_host_supervised(). The splash event is passed on every launch
1623 // (including restarts) so a relaunched host still dismisses a pending
1624 // splash if the first host crashed before its first frame.
1625 let mut host_env = paths.common.to_env_vars();
1626 // Pass the version-scoped IPC hash to the host so it writes the
1627 // port file to `ipc-port-{hash}` rather than the shared `ipc-port`.
1628 // Prevents two running releases from overwriting each other's port
1629 // file (codex P1 on #1227).
1630 host_env.push(("AGENTMUX_IPC_HASH", std::ffi::OsString::from(&dir_hash)));
1631 let mut host_child = match spawn_host_supervised(
1632 real_exe,
1633 args,
1634 &srv_result,
1635 &host_env,
1636 &pipe_path,
1637 job.is_some(),
1638 job_handle,
1639 splash_event_name.as_deref(),
1640 false,
1641 ) {
1642 Some(c) => c,
1643 None => {
1644 // First-launch failure is fatal. Happy path: drop(job) →
1645 // KILL_ON_JOB_CLOSE reaps srv. Degraded path (J0 absent):
1646 // kill srv explicitly or it orphans (kill_on_drop is false).
1647 log("FATAL: could not start CEF host — terminating");
1648 eprintln!("Failed to launch AgentMux.");
1649 if job.is_none() {
1650 let _ = srv_child.start_kill();
1651 }
1652 drop(job);
1653 std::process::exit(1);
1654 }
1655 };
1656
1657 // 7. Supervised wait loop (Phase 1 — host supervision). The host is
1658 // auto-restarted on abnormal exit, bounded by a crash budget so a
1659 // deterministic crash can't spin forever (spec §10-A). A clean host
1660 // exit (code 0) ends the loop. srv is NOT yet supervised — an srv
1661 // exit still terminates the launcher; srv supervision is Phase 2.
1662 //
1663 // We don't manually kill the surviving child in the happy path:
1664 // dropping `job` below triggers KILL_ON_JOB_CLOSE which reaps the
1665 // entire J0 membership. The explicit start_kill is the backstop for
1666 // degraded mode (job == None) only.
1667 log("entering supervised host + srv wait");
1668 let mut host_restarts: Vec<std::time::Instant> = Vec::new();
1669 // Separate budget for system-OOM host exits (memory-aware relaunch); see
1670 // mem_supervisor + SPEC_MEMORY_PRESSURE_SUPERVISION_2026_06_16.
1671 let mut oom_restarts: Vec<std::time::Instant> = Vec::new();
1672 let mut last_abnormal_code: Option<i32> = None;
1673 let mut host_degraded = false;
1674 let exit_code = loop {
1675 tokio::select! {
1676 host_status = host_child.wait() => {
1677 let code = match host_status {
1678 Ok(s) => s.code().unwrap_or(1),
1679 Err(e) => {
1680 log(&format!("FATAL: host wait failed: {}", e));
1681 break 1;
1682 }
1683 };
1684 if code == 0 {
1685 log("CEF host exited cleanly (code 0) — shutting down");
1686 break 0;
1687 }
1688 // Classify: a *system-OOM* exit (the OS ran out of commit) is
1689 // transient and must be WAITED OUT, not hammered into the same
1690 // wall on the fast wedged-host budget — that just re-OOMs and
1691 // burns the budget into a silent give-up
1692 // (docs/retro/retro-oom-crash-2026-06-16.md,
1693 // SPEC_MEMORY_PRESSURE_SUPERVISION_2026_06_16 §5.B). A genuine
1694 // host fault still takes the existing path below, unchanged.
1695 let commit_free = mem_supervisor::commit_free_mb();
1696 match mem_supervisor::classify_host_exit(code, commit_free) {
1697 mem_supervisor::HostExitClass::SystemOom => {
1698 let now = std::time::Instant::now();
1699 if mem_supervisor::budget_exhausted(
1700 &mut oom_restarts,
1701 now,
1702 mem_supervisor::OOM_RESTART_WINDOW,
1703 mem_supervisor::OOM_RESTART_BUDGET,
1704 ) {
1705 log(&format!(
1706 "CEF host hit system OOM (code {}, {} MB commit-free); OOM restart \
1707 budget exhausted ({} in {}s) — giving up",
1708 code,
1709 commit_free,
1710 mem_supervisor::OOM_RESTART_BUDGET,
1711 mem_supervisor::OOM_RESTART_WINDOW.as_secs()
1712 ));
1713 show_fatal_dialog(
1714 mem_supervisor::OOM_GIVEUP_TITLE,
1715 mem_supervisor::OOM_GIVEUP_BODY,
1716 );
1717 break code;
1718 }
1719 log(&format!(
1720 "CEF host hit system OOM (code {}, {} MB commit-free) — waiting for \
1721 memory to recover before relaunch",
1722 code, commit_free
1723 ));
1724 // Commit-gated, backed-off wait. Relaunching into a
1725 // starved system just re-OOMs; waiting is the only lever.
1726 // Race it against srv death so the supervisor isn't blind
1727 // to a concurrent srv exit during the wait (reagent P2).
1728 // run_windows has no signal arms (shutdown flows via the
1729 // host/srv), so srv is the only concurrent event here.
1730 let recovered = tokio::select! {
1731 r = mem_supervisor::await_commit_recovery(log) => r,
1732 srv_status = srv_child.wait() => {
1733 match srv_status {
1734 Ok(s) => log(&format!(
1735 "srv exited UNEXPECTEDLY during OOM wait with code {} — terminating launcher",
1736 s.code().unwrap_or(1)
1737 )),
1738 Err(e) => log(&format!("FATAL: srv wait failed during OOM wait: {}", e)),
1739 }
1740 break 1;
1741 }
1742 };
1743 if !recovered {
1744 show_fatal_dialog(
1745 mem_supervisor::OOM_GIVEUP_TITLE,
1746 mem_supervisor::OOM_GIVEUP_BODY,
1747 );
1748 break code;
1749 }
1750 // Relaunch degraded: the GPU process is a large commit
1751 // consumer, so skip straight to software rendering for an
1752 // OOM relaunch (SPEC §5.B.4).
1753 match spawn_host_supervised(
1754 real_exe,
1755 args,
1756 &srv_result,
1757 &host_env,
1758 &pipe_path,
1759 job.is_some(),
1760 job_handle,
1761 splash_event_name.as_deref(),
1762 true, // disable_gpu
1763 ) {
1764 Some(c) => host_child = c,
1765 None => {
1766 log("host relaunch failed to spawn — giving up");
1767 break code;
1768 }
1769 }
1770 }
1771 mem_supervisor::HostExitClass::Abnormal => {
1772 // Abnormal exit — relaunch within the crash budget.
1773 let now = std::time::Instant::now();
1774 host_restarts.retain(|t| now.duration_since(*t) < HOST_RESTART_WINDOW);
1775 if host_restarts.len() >= HOST_RESTART_BUDGET {
1776 log(&format!(
1777 "CEF host exited abnormally (code {}); restart budget exhausted \
1778 ({} in {}s) — giving up",
1779 code,
1780 host_restarts.len(),
1781 HOST_RESTART_WINDOW.as_secs()
1782 ));
1783 break code;
1784 }
1785 host_restarts.push(now);
1786 // Crash classification + retry ladder (spec §7): a crash that
1787 // reproduces the previous abnormal exit code is deterministic —
1788 // step down to a degraded (--disable-gpu) relaunch so the retry
1789 // isn't "the same thing again". Degraded is sticky; the ladder
1790 // only steps down.
1791 if last_abnormal_code == Some(code) {
1792 host_degraded = true;
1793 }
1794 last_abnormal_code = Some(code);
1795 log(&format!(
1796 "CEF host exited abnormally (code {}) — relaunching (restart {}/{}{})",
1797 code,
1798 host_restarts.len(),
1799 HOST_RESTART_BUDGET,
1800 if host_degraded { ", degraded: --disable-gpu" } else { "" }
1801 ));
1802 match spawn_host_supervised(
1803 real_exe,
1804 args,
1805 &srv_result,
1806 &host_env,
1807 &pipe_path,
1808 job.is_some(),
1809 job_handle,
1810 splash_event_name.as_deref(),
1811 host_degraded,
1812 ) {
1813 Some(c) => host_child = c,
1814 None => {
1815 log("host relaunch failed to spawn — giving up");
1816 break code;
1817 }
1818 }
1819 }
1820 }
1821 }
1822 srv_status = srv_child.wait() => {
1823 match srv_status {
1824 Ok(s) => log(&format!(
1825 "srv exited UNEXPECTEDLY (host still running) with code {} — terminating launcher",
1826 s.code().unwrap_or(1)
1827 )),
1828 Err(e) => log(&format!("FATAL: srv wait failed: {}", e)),
1829 }
1830 break 1;
1831 }
1832 }
1833 };
1834
1835 // 8. Cleanup. Happy path: drop(job) → KILL_ON_JOB_CLOSE reaps
1836 // the surviving child + CEF renderers. Degraded path (job is
1837 // None): explicit start_kill on both — neither will be reaped
1838 // by the OS, so we have to terminate them ourselves to avoid
1839 // orphans. (gemini PR #570 round-1 MEDIUM L105 / round-2 P1
1840 // backstop pattern.)
1841 if job.is_none() {
1842 log("WARN: J0 absent — explicitly killing surviving children");
1843 let _ = host_child.start_kill();
1844 let _ = srv_child.start_kill();
1845 }
1846 drop(job);
1847 log(&format!("launcher exiting with code {}", exit_code));
1848 std::process::exit(exit_code);
1849}
1850
1851/// Append a timestamped line to ~/.agentmux/logs/agentmux-launcher.log.
1852/// Best-effort — silently no-ops if the log dir doesn't exist yet.
1853pub(crate) fn log(msg: &str) {
1854 let log_dir = dirs_fallback_home().join(".agentmux").join("logs");
1855 let _ = std::fs::create_dir_all(&log_dir);
1856 let path = log_dir.join("agentmux-launcher.log");
1857 if let Ok(mut f) = std::fs::OpenOptions::new()
1858 .create(true)
1859 .append(true)
1860 .open(&path)
1861 {
1862 use std::io::Write;
1863 let secs = std::time::SystemTime::now()
1864 .duration_since(std::time::UNIX_EPOCH)
1865 .map(|d| d.as_secs())
1866 .unwrap_or(0);
1867 let _ = writeln!(f, "[{}] v{} {}", secs, env!("CARGO_PKG_VERSION"), msg);
1868 }
1869}
1870
1871/// Home dir without depending on `dirs` for THIS specific lookup.
1872/// Kept to avoid a dirs dep cycle from log() — log() is called from
1873/// data_dir::resolve_paths via failure paths, and we want it to work
1874/// even if `dirs` itself is mid-failure.
1875fn dirs_fallback_home() -> std::path::PathBuf {
1876 std::env::var("USERPROFILE")
1877 .or_else(|_| std::env::var("HOME"))
1878 .map(std::path::PathBuf::from)
1879 .unwrap_or_else(|_| std::path::PathBuf::from("."))
1880}
1881
1882/// Owns a Windows Job Object handle. CloseHandle on drop. The job's
1883/// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` flag means closing the last handle
1884/// terminates every assigned process — which is what we want as a backstop
1885/// if this launcher dies abruptly.
1886#[cfg(target_os = "windows")]
1887struct JobHandle(windows_sys::Win32::Foundation::HANDLE);
1888
1889#[cfg(target_os = "windows")]
1890unsafe impl Send for JobHandle {}
1891
1892#[cfg(target_os = "windows")]
1893impl Drop for JobHandle {
1894 fn drop(&mut self) {
1895 if !self.0.is_null() {
1896 unsafe {
1897 windows_sys::Win32::Foundation::CloseHandle(self.0);
1898 }
1899 }
1900 }
1901}
1902
1903/// Create a Job Object J0 with `KILL_ON_JOB_CLOSE`. Caller assigns
1904/// processes to it via `srv_spawner::assign_pid_to_job(pid, job)`.
1905#[cfg(target_os = "windows")]
1906fn create_job_object() -> Result<windows_sys::Win32::Foundation::HANDLE, String> {
1907 use windows_sys::Win32::Foundation::CloseHandle;
1908 use windows_sys::Win32::System::JobObjects::*;
1909
1910 unsafe {
1911 let job = CreateJobObjectW(std::ptr::null(), std::ptr::null());
1912 if job.is_null() {
1913 return Err("CreateJobObjectW returned null".into());
1914 }
1915 let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
1916 info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
1917 let ok = SetInformationJobObject(
1918 job,
1919 JobObjectExtendedLimitInformation,
1920 &info as *const _ as *const std::ffi::c_void,
1921 std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
1922 );
1923 if ok == 0 {
1924 CloseHandle(job);
1925 return Err("SetInformationJobObject failed".into());
1926 }
1927 Ok(job)
1928 }
1929}
1930
1931/// Resume the (single) main thread of a CREATE_SUSPENDED process.
1932///
1933/// Walks a Toolhelp32 thread snapshot to find the one thread belonging
1934/// to `pid` (a freshly-spawned suspended process has only its main
1935/// thread), opens it with THREAD_SUSPEND_RESUME, and ResumeThread's it.
1936///
1937/// Errors come from snapshot creation, OpenThread, or ResumeThread
1938/// returning `(DWORD)-1`. A `ResumeThread` return of 0 means the thread
1939/// was already running (impossible if the process was just created
1940/// suspended) — treated as success.
1941#[cfg(target_os = "windows")]
1942pub(crate) fn resume_main_thread(pid: u32) -> Result<(), String> {
1943 use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
1944 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
1945 CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD,
1946 THREADENTRY32,
1947 };
1948 use windows_sys::Win32::System::Threading::{
1949 OpenThread, ResumeThread, THREAD_SUSPEND_RESUME,
1950 };
1951
1952 unsafe {
1953 let snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
1954 if snap == INVALID_HANDLE_VALUE {
1955 return Err("CreateToolhelp32Snapshot failed".into());
1956 }
1957
1958 let mut entry: THREADENTRY32 = std::mem::zeroed();
1959 entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
1960
1961 let mut found = false;
1962 if Thread32First(snap, &mut entry) != 0 {
1963 loop {
1964 if entry.th32OwnerProcessID == pid {
1965 let thread = OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID);
1966 if !thread.is_null() {
1967 let prev = ResumeThread(thread);
1968 CloseHandle(thread);
1969 if prev == u32::MAX {
1970 CloseHandle(snap);
1971 return Err(format!(
1972 "ResumeThread failed for tid={}",
1973 entry.th32ThreadID
1974 ));
1975 }
1976 found = true;
1977 break;
1978 }
1979 }
1980 entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
1981 if Thread32Next(snap, &mut entry) == 0 {
1982 break;
1983 }
1984 }
1985 }
1986
1987 CloseHandle(snap);
1988 if !found {
1989 return Err(format!("no thread found for pid={}", pid));
1990 }
1991 Ok(())
1992 }
1993}
1994
1995/// Phase B.6 (post-fix) — forward an `open_new_window` request to
1996/// the already-running host and let this launcher exit 0.
1997///
1998/// The host writes `<data-dir>/ipc-port` after CEF init as
1999/// `port:token`. We open a TCP connection to 127.0.0.1:port, send a
2000/// minimal HTTP/1.1 POST to /ipc with the bearer token and a JSON
2001/// body, and bail. We deliberately do NOT pull in reqwest: the
2002/// launcher binary should stay tiny (~325 KB) and the protocol is
2003/// fixed, so a hand-rolled request is the right tool.
2004///
2005/// Failure classification (codex P2 PR #598):
2006/// - `Transient` — port file missing / unreadable / malformed.
2007/// The host is alive (pipe held) but mid-startup; caller exits
2008/// 0 silently so the user isn't punished for double-clicking
2009/// quickly.
2010/// - `Fatal` — port file is readable, but the HTTP path failed
2011/// (connect refused, write failed, timeout). Either a hung
2012/// host or a non-running-instance source of
2013/// `ERROR_ACCESS_DENIED` (namespace conflict, security
2014/// descriptor failure). Caller surfaces the dialog so the user
2015/// sees a real problem rather than a silent no-op.
2016enum ForwardError {
2017 Transient(String),
2018 Fatal(String),
2019}
2020
2021fn forward_open_new_window(data_dir: &std::path::Path, dir_hash: &str) -> Result<(), ForwardError> {
2022 // Read the version-scoped port file so we reach THIS version's host,
2023 // not a concurrent release's host that may have overwritten "ipc-port".
2024 let port_file_name = format!("ipc-port-{}", dir_hash);
2025 let port_file = data_dir.join(&port_file_name);
2026 let contents = std::fs::read_to_string(&port_file).map_err(|e| {
2027 ForwardError::Transient(format!("read {}: {}", port_file.display(), e))
2028 })?;
2029 let trimmed = contents.trim();
2030 let (port_str, token) = trimmed.split_once(':').ok_or_else(|| {
2031 ForwardError::Transient(format!(
2032 "malformed port file (expected port:token): {}",
2033 trimmed
2034 ))
2035 })?;
2036 let port: u16 = port_str
2037 .parse()
2038 .map_err(|e| ForwardError::Transient(format!("invalid port {:?}: {}", port_str, e)))?;
2039
2040 // From here on the file was readable: any failure is a fatal
2041 // forward (the host got far enough to publish but isn't
2042 // serving the IPC port).
2043 let addr: std::net::SocketAddr = ([127, 0, 0, 1], port).into();
2044 let mut stream = std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_secs(2))
2045 .map_err(|e| ForwardError::Fatal(format!("connect 127.0.0.1:{}: {}", port, e)))?;
2046 stream
2047 .set_write_timeout(Some(std::time::Duration::from_secs(2)))
2048 .ok();
2049
2050 let body = r#"{"cmd":"open_new_window"}"#;
2051 let req = format!(
2052 "POST /ipc HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nAuthorization: Bearer {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2053 token,
2054 body.len(),
2055 body
2056 );
2057 use std::io::{Read, Write};
2058 stream
2059 .write_all(req.as_bytes())
2060 .map_err(|e| ForwardError::Fatal(format!("write request: {}", e)))?;
2061 // CRITICAL: read at least the status line. The host's axum
2062 // handler is async — if the launcher closes the TCP socket
2063 // before axum has finished parsing + dispatching to
2064 // `open_new_window`, the request can be dropped (smoke caught
2065 // exactly this on v0.33.481: the launcher logged "forwarded"
2066 // but no second window appeared because the process exited
2067 // before axum ran the handler). We don't care about the body
2068 // — `Connection: close` lets the server drop the socket once
2069 // the response is written, so a single short read is enough
2070 // to keep the connection alive past handler dispatch.
2071 stream
2072 .set_read_timeout(Some(std::time::Duration::from_secs(2)))
2073 .ok();
2074 let mut sink = [0u8; 64];
2075 let _ = stream.read(&mut sink);
2076 Ok(())
2077}
2078
2079/// Best-effort `open_new_window` forward for the unix second-instance path.
2080/// Unlike the Windows path (which pops a dialog on a fatal forward), unix just
2081/// logs and lets the caller exit 0: the existing instance is alive (its socket
2082/// answered our connect probe), so a transient/fatal forward failure shouldn't
2083/// block — at worst the relaunch is a silent no-op instead of a new window.
2084/// SPEC_MACOS_LAUNCH_COHERENCE_2026_06_18.md.
2085#[cfg(not(target_os = "windows"))]
2086fn forward_open_new_window_or_log(data_dir: &std::path::Path, dir_hash: &str) {
2087 match forward_open_new_window(data_dir, dir_hash) {
2088 Ok(()) => log("forwarded open_new_window to existing instance"),
2089 Err(ForwardError::Transient(reason)) => {
2090 log(&format!("open_new_window forward transient (host mid-startup?): {}", reason))
2091 }
2092 Err(ForwardError::Fatal(reason)) => {
2093 log(&format!("open_new_window forward failed: {}", reason))
2094 }
2095 }
2096}
2097
2098/// Show a modal error dialog before the launcher exits. Used for
2099/// genuine bind failures (NOT the "already running" path — that
2100/// silently forwards via `forward_open_new_window`). Without this,
2101/// the launcher exit is silent (it has the `windows` subsystem in
2102/// release, so eprintln! goes nowhere).
2103#[cfg(target_os = "windows")]
2104fn show_fatal_dialog(title: &str, body: &str) {
2105 use std::os::windows::ffi::OsStrExt;
2106 use windows_sys::Win32::UI::WindowsAndMessaging::{
2107 MessageBoxW, MB_ICONWARNING, MB_OK, MB_SETFOREGROUND, MB_TOPMOST,
2108 };
2109 let title_w: Vec<u16> = std::ffi::OsStr::new(title)
2110 .encode_wide()
2111 .chain(Some(0))
2112 .collect();
2113 let body_w: Vec<u16> = std::ffi::OsStr::new(body)
2114 .encode_wide()
2115 .chain(Some(0))
2116 .collect();
2117 unsafe {
2118 MessageBoxW(
2119 std::ptr::null_mut(),
2120 body_w.as_ptr(),
2121 title_w.as_ptr(),
2122 MB_OK | MB_ICONWARNING | MB_SETFOREGROUND | MB_TOPMOST,
2123 );
2124 }
2125}
2126
2127#[cfg(not(target_os = "windows"))]
2128fn show_fatal_dialog(_title: &str, body: &str) {
2129 eprintln!("{}", body);
2130}
2131
2132/// Find the CEF host binary in the runtime directory.
2133/// Tries versioned name first (agentmux-X.Y.Z.exe), then the old
2134/// agentmux-cef-X.Y.Z.exe pattern for backwards compat, then plain
2135/// agentmux-cef.exe (dev mode).
2136fn find_cef_binary(runtime_dir: &std::path::Path) -> std::path::PathBuf {
2137 let ext = if cfg!(target_os = "windows") { ".exe" } else { "" };
2138
2139 let versioned = format!("agentmux-{}{}", env!("CARGO_PKG_VERSION"), ext);
2140 let versioned_path = runtime_dir.join(&versioned);
2141 if versioned_path.exists() {
2142 return versioned_path;
2143 }
2144
2145 if let Ok(entries) = std::fs::read_dir(runtime_dir) {
2146 let prefix = "agentmux-";
2147 let cef_prefix = "agentmux-cef";
2148 for entry in entries.flatten() {
2149 let name = entry.file_name();
2150 let name = name.to_string_lossy();
2151 if name.starts_with(prefix)
2152 && !name.starts_with(cef_prefix)
2153 && !name.starts_with("agentmux-srv")
2154 // CRITICAL for the flat dev layout (macOS/Linux Phase 1):
2155 // launcher + host + srv share one dir, so the launcher
2156 // binary itself matches `agentmux-*`. Without this guard
2157 // the launcher resolves ITSELF as the host and spawns a
2158 // recursive launcher fork bomb. On Windows the launcher
2159 // lives at the root (not in runtime/), so this is a no-op.
2160 && !name.starts_with("agentmux-launcher")
2161 && name.ends_with(ext)
2162 {
2163 return entry.path();
2164 }
2165 }
2166 }
2167
2168 let versioned_old = format!("agentmux-cef-{}{}", env!("CARGO_PKG_VERSION"), ext);
2169 let versioned_old_path = runtime_dir.join(&versioned_old);
2170 if versioned_old_path.exists() {
2171 return versioned_old_path;
2172 }
2173
2174 runtime_dir.join(format!("agentmux-cef{}", ext))
2175}