agentmux_launcher/
srv_spawner.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Spawn the agentmux-srv backend sidecar from the LAUNCHER (Phase B.1).
5//
6// Today's flow (pre-Phase-B): launcher spawns host; host spawns srv;
7// host owns a Job Object J1 wrapping srv; renderers inherit launcher's
8// J0 via host. Result: when host crashes, J1 closes and srv dies.
9//
10// Phase B.1 flow: launcher spawns BOTH srv and host as siblings,
11// assigns BOTH to launcher's J0 directly. Host's J1 on srv is
12// deleted (it would actively defeat "srv survives host crash" if
13// kept). Renderers continue to inherit J0 via host as before.
14//
15// The launcher passes srv's endpoints to the host via env vars
16// (AGENTMUX_BACKEND_WS, _WEB, _PID). Host detects them and skips
17// its own spawn_backend path (which is preserved for `task dev`
18// fallback where launcher isn't in the loop).
19//
20// Adapted from `agentmux-cef/src/sidecar.rs::spawn_backend` —
21// kept structurally similar so divergence is auditable. Key
22// differences:
23//   * Tokio process API (not std::process)
24//   * CREATE_SUSPENDED + assign-to-job + ResumeThread (PR #570 race
25//     pattern, applied to srv too)
26//   * No separate Job Object on srv; launcher's J0 covers it
27//   * Auth key generated here, not consumed from a shared AppState
28//   * stderr ESTART parsing returns the result via tokio mpsc
29
30use std::path::{Path, PathBuf};
31use std::process::Stdio;
32
33use tokio::io::{AsyncBufReadExt, BufReader};
34use tokio::process::{Child, Command};
35use tokio::sync::mpsc;
36
37use crate::data_dir::DataPaths;
38
39/// What the launcher learns about srv after it signals ready.
40/// Held by the launcher and used to populate env vars the host reads.
41#[derive(Debug, Clone)]
42pub struct SrvSpawnResult {
43    pub pid: u32,
44    pub ws_endpoint: String,
45    pub web_endpoint: String,
46    pub instance_id: String,
47    pub auth_key: String,
48    /// RFC3339 timestamp captured when ESTART arrived. Carried on the
49    /// result for `--diag` / debug observability; not currently
50    /// propagated into env. F.7 cleanup audit: keep with allow + this
51    /// note rather than delete — a future `--diag srv` printer is the
52    /// natural reader.
53    #[allow(dead_code)]
54    pub started_at: String,
55}
56
57/// Errors during srv spawn — granular enough that the launcher can
58/// log the right diagnostic.
59#[derive(Debug)]
60pub enum SrvSpawnError {
61    BinaryNotFound(String),
62    SpawnFailed(String),
63    JobAssignFailed(String),
64    ResumeFailed(String),
65    EstartTimeout,
66    EstartChannelClosed,
67}
68
69impl std::fmt::Display for SrvSpawnError {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            Self::BinaryNotFound(s) => write!(f, "srv binary not found: {}", s),
73            Self::SpawnFailed(s) => write!(f, "spawn failed: {}", s),
74            Self::JobAssignFailed(s) => write!(f, "AssignProcessToJobObject failed: {}", s),
75            Self::ResumeFailed(s) => write!(f, "ResumeThread failed: {}", s),
76            Self::EstartTimeout => write!(f, "timeout waiting for AGENTMUXSRV-ESTART (30s)"),
77            Self::EstartChannelClosed => {
78                write!(f, "ESTART channel closed before srv signalled ready")
79            }
80        }
81    }
82}
83
84/// Spawn srv as a child of the launcher, assigned to launcher's
85/// Job Object J0 so it dies cleanly with the launcher tree.
86///
87/// `launcher_exe_dir` is used to locate the srv binary alongside the
88/// launcher (or in `runtime/` for portable). `paths` carries the
89/// data + config dirs and is propagated to srv via env vars.
90/// `job_handle` is the launcher's Job Object so srv joins the same
91/// kill-on-job-close contract as the host. Returns once srv prints
92/// `AGENTMUXSRV-ESTART` (or the 30s timeout fires).
93///
94/// Caller keeps the returned `Child` alive — drop closes srv's
95/// stdin and srv's existing PPID death-watcher takes over (already
96/// part of agentmux-srv per `SPEC_BACKEND_LIFECYCLE.md`).
97// Phase E.1b — `srv_pipe_path` is the launcher-computed pipe path
98// (same data-dir hash as launcher's own pipe, different leaf name).
99// Passed via `AGENTMUX_SRV_PIPE_PATH` so srv doesn't have to
100// recompute the hash; launcher is the single source of truth.
101pub async fn spawn_srv(
102    launcher_exe_dir: &Path,
103    paths: &DataPaths,
104    srv_pipe_path: &str,
105    #[cfg(target_os = "windows")] job_handle: windows_sys::Win32::Foundation::HANDLE,
106) -> Result<(SrvSpawnResult, Child), SrvSpawnError> {
107    let backend_path = resolve_srv_binary(launcher_exe_dir)?;
108
109    // Generate a fresh auth_key per run (UUID v4 — same as host did).
110    // This is the launcher's responsibility now; host receives it via
111    // AGENTMUX_AUTH_KEY env so srv + host + frontend agree on the key.
112    let auth_key = uuid::Uuid::new_v4().to_string();
113    let version = env!("CARGO_PKG_VERSION");
114    let instance_id = format!("v{}", version);
115
116    // app_path = launcher's exe dir (used by srv for finding bundled
117    // tooling like jq.exe / rg.exe). In portable mode this is the
118    // top of the portable folder; the runtime/tools/bin/ subdir
119    // lives under runtime/, but srv's app_path lookup is currently
120    // exe_dir-based per the host's code.
121    //
122    // For B.1 we keep parity: pass exe_dir of the LAUNCHER. If srv
123    // tooling lookup breaks, follow up — log it loudly.
124    let app_path_str = launcher_exe_dir.to_string_lossy().to_string();
125
126    let mut cmd = Command::new(&backend_path);
127    cmd.args([
128        "--wavedata",
129        &paths.data_dir.to_string_lossy(),
130        "--instance",
131        &instance_id,
132    ])
133    .env("AGENTMUX_AUTH_KEY", &auth_key)
134    // Canonical AGENTMUX_* env vars (INSTANCE_DIR / DATA_DIR /
135    // CONFIG_DIR / LOG_DIR / CEF_CACHE_DIR / AGENTS_DIR / INSTANCE_
136    // RUNTIME_DIR / SHARED_DIR / RUNTIME_MODE). Replaces the old
137    // AGENTMUX_DATA_HOME / AGENTMUX_DEV / AGENTMUX_CONFIG_HOME /
138    // AGENTMUX_SETTINGS_DIR pre-unification names. srv reads them
139    // via `DataPaths::from_env()` (or the raw var names directly).
140    .envs(paths.common.to_env_vars())
141    .env("AGENTMUX_APP_PATH", &app_path_str)
142    .env("AGENTMUX_SRV_PIPE_PATH", srv_pipe_path)
143    .stdin(Stdio::piped())
144    .stdout(Stdio::piped())
145    .stderr(Stdio::piped())
146    .kill_on_drop(false); // Job Object handles cleanup; tokio's kill-on-drop would force-kill.
147
148    // Windows: spawn suspended so we can assign-to-job before any
149    // child code runs (PR #570 race pattern, now applied to srv).
150    // Without this, srv could open files / sockets before joining
151    // the job — those resources would survive launcher death.
152    #[cfg(target_os = "windows")]
153    {
154        const CREATE_SUSPENDED: u32 = 0x00000004;
155        const CREATE_NO_WINDOW: u32 = 0x08000000;
156        cmd.creation_flags(CREATE_SUSPENDED | CREATE_NO_WINDOW);
157    }
158
159    // Linux process-tree reap (A0): PR_SET_PDEATHSIG → SIGKILL on
160    // launcher death so srv is reaped even if the launcher exits
161    // abnormally (panic/OOM/external SIGKILL). Linux analogue of the
162    // Windows Job Object cleanup the launcher already does. Same
163    // safety contract as the host pre_exec — async-signal-safe call
164    // only. macOS lacks prctl; the macOS launcher reap path is
165    // handled by tokio's child supervision in `run_unix`.
166    #[cfg(target_os = "linux")]
167    {
168        use std::os::unix::process::CommandExt as _;
169        unsafe {
170            cmd.pre_exec(|| {
171                libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL, 0, 0, 0);
172                Ok(())
173            });
174        }
175    }
176
177    let mut child = cmd
178        .spawn()
179        .map_err(|e| SrvSpawnError::SpawnFailed(e.to_string()))?;
180    let pid = child
181        .id()
182        .ok_or_else(|| SrvSpawnError::SpawnFailed("child has no PID".to_string()))?;
183
184    // Windows: assign srv to launcher's job, then resume.
185    // Skip job assignment if launcher's job creation failed (J0 is
186    // null) — that's the degraded mode logged by main.rs. Srv still
187    // runs but won't be reaped on launcher death.
188    //
189    // Both error paths must explicitly start_kill the suspended
190    // child before returning. We set kill_on_drop(false) (J0 normally
191    // handles cleanup), so dropping the Child wouldn't terminate the
192    // suspended srv — it would orphan as a permanent zombie holding
193    // resources and the data dir lockfile, blocking subsequent
194    // launches. (codex P1 @ srv_spawner.rs:161, PR #571 round-3.)
195    #[cfg(target_os = "windows")]
196    {
197        if !job_handle.is_null() {
198            if let Err(e) = assign_pid_to_job(pid, job_handle) {
199                let _ = child.start_kill();
200                return Err(SrvSpawnError::JobAssignFailed(e));
201            }
202        }
203        if let Err(e) = crate::resume_main_thread(pid) {
204            let _ = child.start_kill();
205            return Err(SrvSpawnError::ResumeFailed(e));
206        }
207    }
208
209    let started_at = chrono::Utc::now().to_rfc3339();
210
211    // Forward srv stdout to our log (info level; the launcher's log
212    // file is the same one srv-logs end up in once we wire log
213    // forwarding properly).
214    if let Some(stdout) = child.stdout.take() {
215        let pid_for_log = pid;
216        tokio::spawn(async move {
217            let mut reader = BufReader::new(stdout).lines();
218            while let Ok(Some(line)) = reader.next_line().await {
219                crate::log(&format!("[srv {} stdout] {}", pid_for_log, line));
220            }
221        });
222    }
223
224    // Parse stderr for AGENTMUXSRV-ESTART (the readiness signal). srv
225    // writes other diagnostic lines too (AGENTMUXSRV-EVENT:..., plain
226    // text); for B.1 we just log them. Phase B sub-PR B.2 will
227    // forward AGENTMUXSRV-EVENT messages to subscribers via the IPC
228    // event stream.
229    let stderr = child
230        .stderr
231        .take()
232        .ok_or_else(|| SrvSpawnError::SpawnFailed("no stderr handle".to_string()))?;
233    let (tx, mut rx) = mpsc::channel::<SrvSpawnResult>(1);
234    let auth_key_for_estart = auth_key.clone();
235    let started_at_for_estart = started_at.clone();
236    let pid_for_log = pid;
237    tokio::spawn(async move {
238        let mut reader = BufReader::new(stderr).lines();
239        let mut estart_sent = false;
240        while let Ok(Some(line)) = reader.next_line().await {
241            if !estart_sent && line.starts_with("AGENTMUXSRV-ESTART") {
242                let parsed = parse_estart(&line);
243                let result = SrvSpawnResult {
244                    pid: pid_for_log,
245                    ws_endpoint: parsed.ws_endpoint,
246                    web_endpoint: parsed.web_endpoint,
247                    instance_id: parsed.instance_id,
248                    auth_key: auth_key_for_estart.clone(),
249                    started_at: started_at_for_estart.clone(),
250                };
251                crate::log(&format!(
252                    "srv {} ready: ws={} web={} instance={}",
253                    result.pid, result.ws_endpoint, result.web_endpoint, result.instance_id
254                ));
255                let _ = tx.send(result).await;
256                estart_sent = true;
257            } else if line.starts_with("AGENTMUXSRV-EVENT:") {
258                crate::log(&format!("[srv {} event] {}", pid_for_log, line));
259                // Phase B.2 will forward these to subscribers.
260            } else {
261                crate::log(&format!("[srv {} stderr] {}", pid_for_log, line));
262            }
263        }
264        // EOF on stderr → srv exited (or its stderr closed). Logged
265        // by the wait-task in main; nothing else to do here.
266    });
267
268    // Wait for ESTART. Both error paths must explicitly start_kill
269    // the child before returning — same kill_on_drop(false) leak
270    // class as the assign/resume failures above. Without this, the
271    // 30s timeout in degraded mode (J0 absent) would leak a fully-
272    // running srv that keeps the data dir lockfile, blocking the
273    // next launch. (codex P2 @ srv_spawner.rs:240, PR #571 round-4.)
274    let recv = tokio::time::timeout(std::time::Duration::from_secs(30), rx.recv()).await;
275    match recv {
276        Err(_) => {
277            let _ = child.start_kill();
278            Err(SrvSpawnError::EstartTimeout)
279        }
280        Ok(None) => {
281            let _ = child.start_kill();
282            Err(SrvSpawnError::EstartChannelClosed)
283        }
284        Ok(Some(result)) => Ok((result, child)),
285    }
286}
287
288/// Resolve the agentmux-srv binary path from the LAUNCHER's vantage
289/// point.
290///
291/// Search order, mirroring the host's `resolve_backend_binary`
292/// (sidecar.rs:318-402) but anchored at the launcher's exe dir:
293///   1. `<launcher_dir>/runtime/agentmux-srv-{ver}-{os}.{arch}.exe`
294///      (versioned portable layout)
295///   2. `<launcher_dir>/runtime/agentmux-srv.exe` (dev fallback)
296///   3. `<launcher_dir>/agentmux-srv-{ver}-{os}.{arch}.exe`
297///      (launcher in same dir as srv — should not happen in portable
298///      but covers cargo-built dev mode where launcher + srv both
299///      land in target/release/)
300///   4. `<launcher_dir>/agentmux-srv.exe` (dev fallback)
301fn resolve_srv_binary(launcher_exe_dir: &Path) -> Result<PathBuf, SrvSpawnError> {
302    let backend_name = "agentmux-srv";
303    let exe_suffix = if cfg!(target_os = "windows") { ".exe" } else { "" };
304    let version = env!("CARGO_PKG_VERSION");
305    let (os_name, arch) = if cfg!(target_os = "macos") {
306        ("darwin", if cfg!(target_arch = "aarch64") { "arm64" } else { "x64" })
307    } else if cfg!(target_os = "linux") {
308        ("linux", if cfg!(target_arch = "aarch64") { "arm64" } else { "x64" })
309    } else {
310        ("windows", if cfg!(target_arch = "aarch64") { "arm64" } else { "x64" })
311    };
312
313    let candidates = [
314        // Portable: srv lives in launcher_dir/runtime/
315        launcher_exe_dir
316            .join("runtime")
317            .join(format!("{}-{}-{}.{}{}", backend_name, version, os_name, arch, exe_suffix)),
318        launcher_exe_dir
319            .join("runtime")
320            .join(format!("{}{}", backend_name, exe_suffix)),
321        // Dev: launcher and srv side-by-side in target/release/
322        launcher_exe_dir
323            .join(format!("{}-{}-{}.{}{}", backend_name, version, os_name, arch, exe_suffix)),
324        launcher_exe_dir.join(format!("{}{}", backend_name, exe_suffix)),
325    ];
326
327    for p in &candidates {
328        if p.exists() {
329            return Ok(p.clone());
330        }
331    }
332
333    Err(SrvSpawnError::BinaryNotFound(format!(
334        "{} v{} not found. Searched:\n  {}",
335        backend_name,
336        version,
337        candidates
338            .iter()
339            .map(|p| p.display().to_string())
340            .collect::<Vec<_>>()
341            .join("\n  ")
342    )))
343}
344
345/// Parsed fields out of a `AGENTMUXSRV-ESTART` line. Same shape as the
346/// host's `parse_estart` (sidecar.rs:404-420).
347struct EstartFields {
348    ws_endpoint: String,
349    web_endpoint: String,
350    instance_id: String,
351}
352
353fn parse_estart(line: &str) -> EstartFields {
354    let parts: Vec<&str> = line.split_whitespace().collect();
355    let get = |prefix: &str| -> String {
356        parts
357            .iter()
358            .find_map(|p| p.strip_prefix(prefix))
359            .unwrap_or_default()
360            .to_string()
361    };
362    EstartFields {
363        ws_endpoint: get("ws:"),
364        web_endpoint: get("web:"),
365        instance_id: get("instance:"),
366    }
367}
368
369/// Assign a process to the launcher's Job Object J0. Used by
370/// `spawn_srv` for srv and exported for `main.rs` to use for the
371/// host. Separated from job creation because both children join
372/// the SAME job (only one J0 ever exists per launcher run).
373#[cfg(target_os = "windows")]
374pub fn assign_pid_to_job(
375    pid: u32,
376    job: windows_sys::Win32::Foundation::HANDLE,
377) -> Result<(), String> {
378    use windows_sys::Win32::Foundation::CloseHandle;
379    use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
380    use windows_sys::Win32::System::Threading::{
381        OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
382    };
383    unsafe {
384        let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid);
385        if process.is_null() {
386            return Err(format!("OpenProcess({}) returned null", pid));
387        }
388        let ok = AssignProcessToJobObject(job, process);
389        CloseHandle(process);
390        if ok == 0 {
391            return Err(format!(
392                "AssignProcessToJobObject failed for pid={}",
393                pid
394            ));
395        }
396        Ok(())
397    }
398}