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}