agentmux_srv\backend/
shell_node.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! ShellNodeRunner — spawns a shell command and streams output to the
5//! frontend as `shell_chunk` WPS events scoped to the agent's block.
6//!
7//! Launched by `handle_shell_create` (server/mod.rs) via `tokio::spawn`.
8//! The runner is fire-and-forget; the HTTP handler returns the `shell_id`
9//! to the MCP caller immediately while output streams asynchronously.
10//!
11//! Phase 2 of SPEC_PERSISTENT_SHELL_NODE_2026_06_11.md. Uses
12//! `tokio::process::Command` with captured pipes (no PTY in this phase;
13//! PTY support is a Phase 3 follow-up that requires portable-pty wiring
14//! similar to the existing ShellController).
15
16use std::collections::HashMap;
17use std::sync::Arc;
18use parking_lot::Mutex;
19use tokio::io::{AsyncBufReadExt, BufReader};
20use tokio::sync::{mpsc, oneshot};
21
22use crate::backend::wps::{Broker, WaveEvent, EVENT_SHELL_CHUNK};
23
24fn now_ms() -> u64 {
25    std::time::SystemTime::now()
26        .duration_since(std::time::UNIX_EPOCH)
27        .unwrap_or_default()
28        .as_millis() as u64
29}
30
31/// Per-shell stop handles. `ShellStop` (MCP tool / UI button) looks up a
32/// `shell_id` and fires the oneshot, which makes the owning `ShellNodeRunner`
33/// tree-kill its child. Mirrors `InstallSessionRegistry`; lives in
34/// `AppState.shell_sessions`. Phase 3 of SPEC_PERSISTENT_SHELL_NODE.
35#[derive(Default)]
36pub struct ShellSessionRegistry {
37    shells: Mutex<HashMap<String, oneshot::Sender<()>>>,
38}
39
40impl ShellSessionRegistry {
41    pub fn new() -> Arc<Self> {
42        Arc::new(Self::default())
43    }
44
45    fn insert(&self, shell_id: String, tx: oneshot::Sender<()>) {
46        self.shells.lock().insert(shell_id, tx);
47    }
48
49    /// Request stop of a running shell. Returns false if the id is unknown
50    /// (never started, or already exited). Removing here also closes the
51    /// window for the runner's own natural-exit `remove`.
52    pub fn stop(&self, shell_id: &str) -> bool {
53        if let Some(tx) = self.shells.lock().remove(shell_id) {
54            let _ = tx.send(());
55            true
56        } else {
57            false
58        }
59    }
60
61    /// Drop a shell's handle without signalling — called by the runner on
62    /// natural exit so its `kill_task` resolves `Err` (= not stopped).
63    fn remove(&self, shell_id: &str) {
64        self.shells.lock().remove(shell_id);
65    }
66
67    /// Stop every running shell. For srv-shutdown cleanup so long-running
68    /// children (`task dev` → `task.exe`/`node`) don't orphan and hold ports.
69    /// Returns the number of shells signalled (so the caller can skip the
70    /// grace-period sleep when there was nothing to stop).
71    pub fn stop_all(&self) -> usize {
72        let drained: Vec<_> = self.shells.lock().drain().map(|(_, tx)| tx).collect();
73        let n = drained.len();
74        for tx in drained {
75            let _ = tx.send(());
76        }
77        n
78    }
79}
80
81/// Kill the entire process tree rooted at `pid`. `Child::kill` / `kill_on_drop`
82/// only reap the wrapper shell (`cmd /C` / `sh -c`); a `task dev` spawns
83/// `task.exe` → `cargo`/`node` grandchildren that survive otherwise.
84///
85/// Scoped strictly to OUR `pid` — never by image name. Killing `task.exe`
86/// by name is the cross-instance hazard that let agent "Mazs" nuke peer
87/// dev servers (see SPEC_PERSISTENT_SHELL_PHASE3_STOP §1).
88fn kill_tree(pid: u32) {
89    #[cfg(windows)]
90    {
91        let _ = std::process::Command::new("taskkill")
92            .args(["/PID", &pid.to_string(), "/T", "/F"])
93            .stdout(std::process::Stdio::null())
94            .stderr(std::process::Stdio::null())
95            .status();
96    }
97    #[cfg(unix)]
98    {
99        // Negative pid targets the process group created via process_group(0).
100        let pgid = pid as i32;
101        unsafe { libc::kill(-pgid, libc::SIGTERM) };
102        std::thread::sleep(std::time::Duration::from_millis(300));
103        unsafe { libc::kill(-pgid, libc::SIGKILL) };
104    }
105}
106
107pub struct ShellNodeRunner {
108    pub shell_id: String,
109    pub block_id: String,
110    pub cmd: String,
111    pub cwd: Option<String>,
112    pub extra_env: HashMap<String, String>,
113    pub broker: Arc<Broker>,
114    /// Stop registry — the runner registers its `shell_id` here so
115    /// `ShellStop` can tree-kill it, and removes itself on natural exit.
116    pub registry: Arc<ShellSessionRegistry>,
117}
118
119impl ShellNodeRunner {
120    pub async fn run(self) {
121        let shell_id = self.shell_id.clone();
122        let block_id = self.block_id.clone();
123        let broker = self.broker.clone();
124
125        let mut child_cmd = if cfg!(windows) {
126            let mut c = tokio::process::Command::new("cmd");
127            c.args(["/C", &self.cmd]);
128            c
129        } else {
130            let mut c = tokio::process::Command::new("sh");
131            c.args(["-c", &self.cmd]);
132            c
133        };
134
135        // Only set the working directory if it actually exists. The cwd is
136        // normalized upstream (handle_shell_create → base::normalize_working_dir),
137        // but a stale or mistyped path would otherwise make the spawn fail hard
138        // with os error 267 (ERROR_DIRECTORY) on Windows. Mirror the agent-CLI
139        // spawn's graceful fallback (subprocess.rs): warn via a system chunk and
140        // run in the server's cwd rather than killing the shell before it starts.
141        if let Some(ref cwd) = self.cwd {
142            if std::path::Path::new(cwd).is_dir() {
143                child_cmd.current_dir(cwd);
144            } else {
145                publish_chunk(
146                    &broker,
147                    &block_id,
148                    &shell_id,
149                    "system",
150                    &format!("[cwd not found: {cwd} — running in the server's working directory]"),
151                    now_ms(),
152                );
153            }
154        }
155        for (k, v) in &self.extra_env {
156            child_cmd.env(k, v);
157        }
158
159        child_cmd.stdout(std::process::Stdio::piped());
160        child_cmd.stderr(std::process::Stdio::piped());
161        // Null stdin (CRITICAL). The shell runs non-interactively and the srv's
162        // own stdin is not a usable TTY. Without this the child inherits the srv
163        // stdin, and tools that probe/read stdin at startup HANG before doing any
164        // work — e.g. `npm run dev` spawned npm-cli.js but it never launched the
165        // vite script (no output, never bound its port). Confirmed: the same
166        // command with `< NUL` starts vite instantly (agentx/fix-shell-dev-server).
167        // (When ShellInput lands — Phase 3b — this becomes a piped stdin.)
168        child_cmd.stdin(std::process::Stdio::null());
169        // Windows: suppress the console window that Windows auto-creates for
170        // CUI-subsystem processes (cmd.exe). stdout/stderr are piped so no
171        // output is lost — the window was decorative noise only.
172        #[cfg(windows)]
173        {
174            use std::os::windows::process::CommandExt as _;
175            const CREATE_NO_WINDOW: u32 = 0x0800_0000;
176            child_cmd.creation_flags(CREATE_NO_WINDOW);
177        }
178        // Backstop: reap the wrapper shell if this runner task is ever dropped.
179        child_cmd.kill_on_drop(true);
180        // Unix: own process group so kill_tree can signal the whole group
181        // (-pgid). Windows uses taskkill /T on the pid instead.
182        #[cfg(unix)]
183        {
184            use std::os::unix::process::CommandExt as _;
185            child_cmd.process_group(0);
186        }
187
188        let mut child = match child_cmd.spawn() {
189            Ok(c) => c,
190            Err(e) => {
191                publish_chunk(&broker, &block_id, &shell_id, "system", &format!("[spawn error: {e}]"), now_ms());
192                publish_exit(&broker, &block_id, &shell_id, 1, false, now_ms());
193                return;
194            }
195        };
196
197        // Register a stop handle. `ShellStop` fires `cancel_rx`, and `kill_task`
198        // tree-kills this child. On natural exit we drop the sender (via
199        // registry.remove) so `kill_task` resolves Err → "not stopped".
200        let pid = child.id();
201        tracing::info!(shell_id = %shell_id, pid = ?pid, "shell.spawn");
202        let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
203        self.registry.insert(shell_id.clone(), cancel_tx);
204        let kill_task = tokio::spawn(async move {
205            match cancel_rx.await {
206                Ok(()) => {
207                    if let Some(pid) = pid {
208                        let _ = tokio::task::spawn_blocking(move || kill_tree(pid)).await;
209                    }
210                    true // stopped by request
211                }
212                Err(_) => false, // sender dropped → natural exit
213            }
214        });
215
216        let stdout = child.stdout.take().expect("stdout piped");
217        let stderr = child.stderr.take().expect("stderr piped");
218
219        // Collect both stdout and stderr into a single ordered channel.
220        // Each task owns its half of the pipe; the channel closes when both
221        // tasks finish, at which point the main loop exits and we wait for exit.
222        type Line = (&'static str, String, u64);
223        let (tx, mut rx) = mpsc::unbounded_channel::<Line>();
224
225        let tx_out = tx.clone();
226        let t_stdout = tokio::spawn(async move {
227            let mut lines = BufReader::new(stdout).lines();
228            while let Ok(Some(line)) = lines.next_line().await {
229                let ts = now_ms();
230                let _ = tx_out.send(("stdout", line, ts));
231            }
232        });
233
234        let tx_err = tx.clone();
235        let t_stderr = tokio::spawn(async move {
236            let mut lines = BufReader::new(stderr).lines();
237            while let Ok(Some(line)) = lines.next_line().await {
238                let ts = now_ms();
239                let _ = tx_err.send(("stderr", line, ts));
240            }
241        });
242
243        // Drop original sender so channel closes once both reader tasks finish.
244        drop(tx);
245
246        let mut line_count: u64 = 0;
247        while let Some((kind, content, ts)) = rx.recv().await {
248            line_count += 1;
249            publish_chunk(&broker, &block_id, &shell_id, kind, &content, ts);
250        }
251
252        let _ = tokio::join!(t_stdout, t_stderr);
253
254        // Drop our stop handle (no-op if ShellStop already removed it) so the
255        // natural-exit path lets `kill_task` resolve.
256        self.registry.remove(&shell_id);
257
258        let exit_code = match child.wait().await {
259            Ok(status) => status.code().unwrap_or(-1),
260            Err(_) => -1,
261        };
262
263        let was_stopped = kill_task.await.unwrap_or(false);
264        tracing::info!(shell_id = %shell_id, exit_code, was_stopped, line_count, "shell.exit");
265        publish_exit(&broker, &block_id, &shell_id, exit_code, was_stopped, now_ms());
266    }
267}
268
269// Every shell_chunk / exit event is published under a SINGLE scope:
270//   - `shell:<shell_id>`  → a per-shell persistence ring buffer (1024 entries).
271//
272// Single-scope delivery (chosen over the fallback of strengthening the reducer's
273// dedup): an earlier design also published every chunk under `block:<block_id>`
274// for "live" delivery, so output produced before the frontend's `shell:<id>`
275// subscription established (the common case — process spawn + first lines beat
276// the WS resub round-trip) arrived LIVE via the block scope AND then again in the
277// replay burst when the broker replayed the whole `shell:<id>` ring on first
278// subscribe. The reducer's last-chunk-only `isDuplicate` let those non-adjacent
279// dups through → doubled output (full duplication on WS reconnect).
280//
281// Now chunks/exit go ONLY to `shell:<shell_id>`. The frontend subscribes to that
282// scope when it sees the (block-scoped, persist:64) `shell_node_create`. Because
283// the broker persists the ring regardless of subscribers (wps.rs persist_event
284// runs inside publish whenever persist>0), any output produced before the
285// subscription establishes is retained in the persist:1024 ring and replayed
286// exactly once on subscribe (guarded by the broker's per-route+event+scope
287// `replayed` set). No chunk is ever delivered via two paths → no duplication.
288//
289// This still preserves the reason the per-shell ring was introduced: each shell
290// has its OWN ring, so a chatty shell can't evict another shell's `exit` event
291// (the bug that left a sibling's row stuck `running` after a remount when all
292// shells shared the block ring).
293fn shell_scopes(shell_id: &str) -> Vec<String> {
294    vec![format!("shell:{shell_id}")]
295}
296
297fn publish_chunk(broker: &Broker, _block_id: &str, shell_id: &str, kind: &str, content: &str, ts: u64) {
298    broker.publish(WaveEvent {
299        event: EVENT_SHELL_CHUNK.to_string(),
300        scopes: shell_scopes(shell_id),
301        sender: String::new(),
302        persist: 1024,
303        data: Some(serde_json::json!({
304            "shell_id": shell_id,
305            "op": "chunk",
306            "kind": kind,
307            "content": content,
308            "timestamp": ts,
309        })),
310    });
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[tokio::test]
318    async fn stop_signals_then_is_idempotent() {
319        let reg = ShellSessionRegistry::new();
320        let (tx, rx) = oneshot::channel::<()>();
321        reg.insert("s1".to_string(), tx);
322        assert!(reg.stop("s1")); // fires the channel
323        assert!(rx.await.is_ok());
324        assert!(!reg.stop("s1")); // second stop: unknown id
325    }
326
327    #[tokio::test]
328    async fn remove_drops_sender_without_signalling() {
329        let reg = ShellSessionRegistry::new();
330        let (tx, rx) = oneshot::channel::<()>();
331        reg.insert("s2".to_string(), tx);
332        reg.remove("s2"); // natural-exit path
333        assert!(rx.await.is_err()); // sender dropped → Err, not Ok
334        assert!(!reg.stop("s2"));
335    }
336
337    #[tokio::test]
338    async fn stop_all_fires_every_handle() {
339        let reg = ShellSessionRegistry::new();
340        let (tx1, rx1) = oneshot::channel::<()>();
341        let (tx2, rx2) = oneshot::channel::<()>();
342        reg.insert("a".to_string(), tx1);
343        reg.insert("b".to_string(), tx2);
344        reg.stop_all();
345        assert!(rx1.await.is_ok());
346        assert!(rx2.await.is_ok());
347    }
348}
349
350fn publish_exit(broker: &Broker, _block_id: &str, shell_id: &str, exit_code: i32, stopped: bool, ts: u64) {
351    broker.publish(WaveEvent {
352        event: EVENT_SHELL_CHUNK.to_string(),
353        scopes: shell_scopes(shell_id),
354        sender: String::new(),
355        persist: 1024,
356        data: Some(serde_json::json!({
357            "shell_id": shell_id,
358            "op": "exit",
359            "exit_code": exit_code,
360            // True when the exit was caused by ShellStop (tree-killed), so the
361            // frontend renders the grey "stopped" status instead of exited-err.
362            "stopped": stopped,
363            "timestamp": ts,
364        })),
365    });
366}