Skip to main content

robonix_cli/
process.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Process Module
3//
4// Process management for robonix-cli (start/stop/monitor processes)
5
6use anyhow::{Context, Result};
7use robonix_scribe::{info, warn};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::process::Stdio;
12use std::sync::{Arc, Mutex};
13use tokio::fs::OpenOptions;
14use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
15use tokio::process::Command;
16
17/// Information about a running process for a capability or skill
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ProcessInfo {
20    pub package_name: String,
21    pub std_name: String,
22    pub package_type: String, // "package" today; reserved for future kind tags
23    pub pid: u32,
24    pub log_file: PathBuf,
25    pub hostname: String,
26    #[serde(default)]
27    pub package_path: Option<PathBuf>,
28    #[serde(default)]
29    pub command: String,
30}
31
32/// Process start result with group information
33#[derive(Debug, Clone)]
34pub struct ProcessStartResult {
35    pub pid: u32,
36    pub pgid: Option<u32>,
37    pub pids: Option<Vec<u32>>,
38}
39
40/// Process stop result with group information
41#[derive(Debug, Clone)]
42pub struct ProcessStopResult {
43    pub pid: u32,
44    pub pgid: Option<u32>,
45    pub pids: Option<Vec<u32>>,
46}
47
48/// Process tree node structure
49#[derive(Debug, Clone)]
50pub struct ProcessTreeNode {
51    pub pid: u32,
52    pub cmd: String,
53    pub children: Vec<ProcessTreeNode>,
54}
55
56impl ProcessTreeNode {
57    /// Format tree as string with indentation
58    pub fn format_tree(&self, prefix: &str, is_last: bool) -> String {
59        let connector = if is_last { "└── " } else { "├── " };
60        let mut result = format!("{}{}PID {}: {}\n", prefix, connector, self.pid, self.cmd);
61
62        let new_prefix = if is_last {
63            format!("{}    ", prefix)
64        } else {
65            format!("{}│   ", prefix)
66        };
67
68        let child_count = self.children.len();
69        for (idx, child) in self.children.iter().enumerate() {
70            let is_last_child = idx == child_count - 1;
71            result.push_str(&child.format_tree(&new_prefix, is_last_child));
72        }
73
74        result
75    }
76}
77
78/// Resolve the per-deploy process-state path from the log directory.
79///
80/// Two layouts are recognised:
81///
82///   * `<…>/rbnx-boot/logs/`  →  `<…>/rbnx-boot/processes.json` (sits
83///     next to `rbnx-boot/state.json` written by `rbnx boot`/`shutdown`)
84///   * anything else          →  `<log_dir>/../processes.json`
85///     (covers `rbnx start --standalone` on a package whose default
86///     `rbnx-build/logs/` is the log root)
87///
88/// The result is always inside the per-deploy tree — never `~/.robonix/`,
89/// which is reserved for cross-deploy user state.
90fn derive_state_file_path(log_dir: &Path) -> PathBuf {
91    if let Some(parent) = log_dir.parent()
92        && parent.file_name().and_then(|s| s.to_str()) == Some("rbnx-boot")
93    {
94        return parent.join("processes.json");
95    }
96    match log_dir.parent() {
97        Some(p) => p.join("processes.json"),
98        None => log_dir.join("processes.json"),
99    }
100}
101
102/// Manager for processes running capabilities and skills
103pub struct ProcessManager {
104    processes: Arc<Mutex<HashMap<String, ProcessInfo>>>, // key: "{package_type}::{std_name}"
105    /// Per-package log directory for Scribe structured logs.
106    log_dir: PathBuf,
107    state_file: PathBuf,
108    hostname: String,
109}
110
111impl ProcessManager {
112    pub fn new(log_dir: PathBuf) -> Result<Self> {
113        // Ensure log directory exists
114        std::fs::create_dir_all(&log_dir)
115            .with_context(|| format!("Failed to create log directory: {}", log_dir.display()))?;
116
117        // Get hostname
118        let hostname = hostname::get()
119            .context("Failed to get hostname")?
120            .to_string_lossy()
121            .to_string();
122
123        // State file lives in the per-deploy root, NOT ~/.robonix/.
124        //
125        // ~/.robonix/ is for cross-deploy user state (config.yaml, chat.yaml,
126        // voiceprint enrollments, installed package db). The package runtime
127        // record belongs to ONE deploy and dies with it, so it must live
128        // inside the deploy tree — the same place `rbnx shutdown` reads
129        // `<manifest-dir>/rbnx-boot/state.json` from — and disappear on
130        // shutdown. A `~/.robonix/processes.json` from a half-killed boot
131        // would otherwise leak across deploys and confuse `rbnx start` on
132        // unrelated packages.
133        //
134        // The deploy root is anchored to the log directory (which
135        // `rbnx start` defaults to `<pkg>/rbnx-build/logs`, and
136        // `rbnx boot` overrides to `<manifest-dir>/rbnx-boot/logs`).
137        // State file is the sibling `processes.json` in the same dir —
138        // or, when a deploy root is unambiguous (`<…>/rbnx-boot/logs`),
139        // inside the `rbnx-boot/` parent so it sits next to
140        // `rbnx-boot/state.json` and `rbnx-boot/logs/`.
141        let state_file = derive_state_file_path(&log_dir);
142
143        let mut manager = Self {
144            processes: Arc::new(Mutex::new(HashMap::new())),
145            log_dir,
146            state_file,
147            hostname,
148        };
149
150        // Load existing processes from state file
151        manager.load_state()?;
152
153        Ok(manager)
154    }
155
156    /// Load process state from persistent storage
157    fn load_state(&mut self) -> Result<()> {
158        if !self.state_file.exists() {
159            return Ok(());
160        }
161
162        let content = std::fs::read_to_string(&self.state_file)
163            .with_context(|| format!("Failed to read state file: {}", self.state_file.display()))?;
164
165        if content.trim().is_empty() {
166            warn!(
167                "Process state file {} is empty; resetting stale runtime state",
168                self.state_file.display()
169            );
170            self.save_state_internal(&HashMap::new())?;
171            return Ok(());
172        }
173
174        let processes: Vec<ProcessInfo> = match serde_json::from_str(&content) {
175            Ok(processes) => processes,
176            Err(err) => {
177                warn!(
178                    "Process state file {} is invalid ({err}); resetting stale runtime state",
179                    self.state_file.display()
180                );
181                self.save_state_internal(&HashMap::new())?;
182                return Ok(());
183            }
184        };
185
186        // Verify processes are still running and filter out dead ones
187        let mut valid_processes = HashMap::new();
188        let original_count = processes.len();
189        for process_info in processes {
190            // Check if process is still live. `kill(pid, 0)` alone accepts
191            // zombies and PID reuse; validate procfs too when available.
192            if Self::is_process_record_live(&process_info) {
193                let key = format!("{}::{}", process_info.package_type, process_info.std_name);
194                valid_processes.insert(key, process_info);
195            } else {
196                info!(
197                    "Process {} (PID: {}) is no longer running, removing from state",
198                    process_info.std_name, process_info.pid
199                );
200            }
201        }
202
203        // Update state file with only valid processes
204        if valid_processes.len() != original_count {
205            self.save_state_internal(&valid_processes)?;
206        }
207
208        *self.processes.lock().unwrap() = valid_processes;
209
210        Ok(())
211    }
212
213    /// Check if a persisted process record still points at a live process.
214    fn is_process_record_live(process_info: &ProcessInfo) -> bool {
215        if !Self::is_process_running(process_info.pid) {
216            return false;
217        }
218        #[cfg(unix)]
219        {
220            if Self::is_process_zombie(process_info.pid) {
221                return false;
222            }
223            let Some(cmdline) = Self::process_cmdline(process_info.pid) else {
224                return false;
225            };
226            if cmdline.trim().is_empty() || cmdline.contains("<defunct>") {
227                return false;
228            }
229            // Old state files did not store command/cwd, which made PID reuse
230            // indistinguishable from a real still-running package. Treat them
231            // as stale after this schema upgrade; new records below carry both.
232            if process_info.command.trim().is_empty() || !cmdline.contains(&process_info.command) {
233                return false;
234            }
235            let Some(expected_cwd) = process_info.package_path.as_ref() else {
236                return false;
237            };
238            if let Ok(actual_cwd) = std::fs::read_link(format!("/proc/{}/cwd", process_info.pid)) {
239                if actual_cwd != *expected_cwd {
240                    return false;
241                }
242            } else {
243                return false;
244            }
245        }
246        true
247    }
248
249    #[cfg(unix)]
250    fn is_process_zombie(pid: u32) -> bool {
251        let path = format!("/proc/{pid}/status");
252        let Ok(status) = std::fs::read_to_string(path) else {
253            return true;
254        };
255        status
256            .lines()
257            .find(|line| line.starts_with("State:"))
258            .map(|line| line.contains(" Z") || line.contains(" X"))
259            .unwrap_or(true)
260    }
261
262    #[cfg(unix)]
263    fn process_cmdline(pid: u32) -> Option<String> {
264        let path = format!("/proc/{pid}/cmdline");
265        let raw = std::fs::read(path).ok()?;
266        Some(
267            String::from_utf8_lossy(&raw)
268                .replace('\0', " ")
269                .trim()
270                .to_string(),
271        )
272    }
273
274    /// Check if a process with given PID exists. This is only the first
275    /// liveness probe; callers that use persisted state should prefer
276    /// `is_process_record_live`.
277    fn is_process_running(pid: u32) -> bool {
278        #[cfg(unix)]
279        {
280            use nix::sys::signal::kill;
281            use nix::unistd::Pid;
282            let pid = Pid::from_raw(pid as i32);
283            // Send signal 0 to check if process exists
284            kill(pid, None).is_ok()
285        }
286        #[cfg(not(unix))]
287        {
288            // On Windows, check process existence differently
289            std::process::Command::new("tasklist")
290                .args(&["/FI", &format!("PID eq {}", pid)])
291                .output()
292                .map(|output| {
293                    let stdout = String::from_utf8_lossy(&output.stdout);
294                    stdout.contains(&pid.to_string())
295                })
296                .unwrap_or(false)
297        }
298    }
299
300    /// Save process state to persistent storage
301    fn save_state(&self) -> Result<()> {
302        let processes = self.processes.lock().unwrap();
303        self.save_state_internal(&processes)
304    }
305
306    fn save_state_internal(&self, processes: &HashMap<String, ProcessInfo>) -> Result<()> {
307        let processes_vec: Vec<&ProcessInfo> = processes.values().collect();
308        let content = serde_json::to_string_pretty(&processes_vec)
309            .context("Failed to serialize process state")?;
310
311        std::fs::write(&self.state_file, content).with_context(|| {
312            format!("Failed to write state file: {}", self.state_file.display())
313        })?;
314
315        Ok(())
316    }
317
318    pub fn get_hostname(&self) -> &str {
319        &self.hostname
320    }
321
322    /// Start a process; blocks until it exits.  Stdout / stderr are captured
323    /// line-by-line and written to Scribe structured logs under
324    /// `$SCRIBE_LOG_DIR/{tag}.log` (tag = `std_name`, e.g.
325    /// `"com.robonix.service.mapping"`).  Lines are NOT forwarded to the
326    /// terminal — `rbnx boot` owns the display and raw package output would
327    /// drown out the boot progress lines.  Use `rbnx logs -f` for live
328    /// per-package log tailing.
329    pub async fn start_process(
330        &self,
331        _package_name: &str,
332        std_name: &str,
333        package_type: &str,
334        package_path: &Path,
335        start_script: &str,
336    ) -> Result<ProcessStartResult> {
337        let key = format!("{}::{}", package_type, std_name);
338        {
339            let processes = self.processes.lock().unwrap();
340            if let Some(existing) = processes.get(&key) {
341                warn!("Process for {} already running, skipping", key);
342                #[cfg(unix)]
343                let (pgid, pids) = {
344                    if let Ok(pgid) = Self::get_process_group_id(existing.pid) {
345                        (Some(pgid), Self::get_processes_in_group(pgid).ok())
346                    } else {
347                        (None, None)
348                    }
349                };
350                #[cfg(not(unix))]
351                let (pgid, pids) = (None, None);
352                return Ok(ProcessStartResult {
353                    pid: existing.pid,
354                    pgid,
355                    pids,
356                });
357            }
358        }
359
360        let start_script = start_script.trim();
361        if start_script.is_empty() {
362            anyhow::bail!("start_script is empty");
363        }
364
365        // Use `bash -c` (not `-lc`) so we inherit the parent environment (PATH, conda, etc.).
366        // A login shell can reset PATH via /etc/profile and pick a different `python3` than the caller.
367        #[cfg(unix)]
368        let mut cmd = Command::new("bash");
369        #[cfg(unix)]
370        cmd.arg("-c").arg(start_script);
371        #[cfg(not(unix))]
372        let mut cmd = Command::new("sh");
373        #[cfg(not(unix))]
374        cmd.arg("-c").arg(start_script);
375
376        cmd.current_dir(package_path)
377            .stdout(Stdio::piped())
378            .stderr(Stdio::piped())
379            .env("PYTHONUNBUFFERED", "1")
380            .env("SCRIBE_LOG_DIR", &self.log_dir);
381        #[cfg(unix)]
382        if std::env::var_os("RBNX_DEPLOY_MANAGED").is_none() {
383            // A standalone `rbnx start` owns a group for its package. When
384            // boot spawned this wrapper, preserve boot's PGID so one teardown
385            // reaches the wrapper and its actual package process together.
386            cmd.process_group(0);
387        }
388
389        let mut child = cmd
390            .spawn()
391            .with_context(|| format!("Failed to start: {}", start_script))?;
392        let pid = child
393            .id()
394            .ok_or_else(|| anyhow::anyhow!("Failed to get process ID"))?;
395        info!("Running {} (PID {})", key, pid);
396        let log_file = self.log_dir.join(format!("{std_name}.log"));
397        {
398            let mut processes = self.processes.lock().unwrap();
399            processes.insert(
400                key.clone(),
401                ProcessInfo {
402                    package_name: _package_name.to_string(),
403                    std_name: std_name.to_string(),
404                    package_type: package_type.to_string(),
405                    pid,
406                    log_file: log_file.clone(),
407                    hostname: self.hostname.clone(),
408                    package_path: Some(package_path.to_path_buf()),
409                    command: start_script.to_string(),
410                },
411            );
412        }
413        self.save_state()?;
414
415        // Pipe stdout / stderr through Scribe so structured logs land in
416        // $SCRIBE_LOG_DIR/{tag}.log.  Do NOT forward to the terminal —
417        // `rbnx boot` owns the display and raw package output would
418        // drown out the boot progress lines.
419        let stdout = child.stdout.take().expect("stdout not piped");
420        let stderr = child.stderr.take().expect("stderr not piped");
421        // Scribe tag + log-file stem = `std_name` (the provider_id) verbatim,
422        // matching `deploy::log_path`. No name mangling.
423        let tag = std_name.to_string();
424
425        let stdout_task = tokio::spawn(async move {
426            let reader = tokio::io::BufReader::new(stdout);
427            let mut lines = reader.lines();
428            while let Ok(Some(line)) = lines.next_line().await {
429                robonix_scribe::ingest(&tag, &line);
430            }
431        });
432
433        let tag2 = std_name.to_string();
434        let stderr_task = tokio::spawn(async move {
435            let reader = tokio::io::BufReader::new(stderr);
436            let mut lines = reader.lines();
437            while let Ok(Some(line)) = lines.next_line().await {
438                // Many programs (Python in particular) write INFO and
439                // WARNING messages to stderr — use `info` rather than
440                // `warn` so the level in the file doesn't misrepresent
441                // the actual severity.
442                robonix_scribe::ingest(&tag2, &line);
443            }
444        });
445
446        let status = child
447            .wait()
448            .await
449            .with_context(|| "Failed to wait for process")?;
450
451        // Drain remaining pipe output before returning.
452        let _ = tokio::join!(stdout_task, stderr_task);
453
454        {
455            let mut processes = self.processes.lock().unwrap();
456            processes.remove(&key);
457        }
458        self.save_state()?;
459
460        if !status.success() {
461            anyhow::bail!("{}: process exited with {}", std_name, status);
462        }
463
464        #[cfg(unix)]
465        let (pgid, pids) = {
466            if let Ok(pgid) = Self::get_process_group_id(pid) {
467                (Some(pgid), Self::get_processes_in_group(pgid).ok())
468            } else {
469                (None, None)
470            }
471        };
472        #[cfg(not(unix))]
473        let (pgid, pids) = (None, None);
474
475        Ok(ProcessStartResult { pid, pgid, pids })
476    }
477
478    /// Get all running processes
479    pub fn get_running_processes(&self) -> Vec<ProcessInfo> {
480        let processes = self.processes.lock().unwrap();
481        processes.values().cloned().collect()
482    }
483
484    /// Check if a process is running
485    pub fn is_running(&self, std_name: &str, package_type: &str) -> bool {
486        let key = format!("{}::{}", package_type, std_name);
487
488        // Check in memory first
489        let process_info = {
490            let processes = self.processes.lock().unwrap();
491            processes.get(&key).cloned()
492        };
493
494        if let Some(process_info) = process_info {
495            // Verify the process is actually still running
496            if Self::is_process_record_live(&process_info) {
497                return true;
498            } else {
499                // Process is dead, remove it from state
500                let mut processes = self.processes.lock().unwrap();
501                processes.remove(&key);
502                drop(processes);
503                let _ = self.save_state();
504                return false;
505            }
506        }
507
508        false
509    }
510
511    /// Return whether `start_process` has inserted the in-memory runtime
512    /// record, without re-validating the child's current command line.
513    ///
514    /// This is a narrow spawn-synchronization primitive for callers that run
515    /// lifecycle supervision alongside `start_process`. A valid package start
516    /// body may `exec` into Docker or another launcher immediately, so the
517    /// stricter persisted-record identity check used by [`Self::is_running`]
518    /// is intentionally not appropriate during this short hand-off window.
519    pub fn has_process_record(&self, std_name: &str, package_type: &str) -> bool {
520        let key = format!("{}::{}", package_type, std_name);
521        self.processes.lock().unwrap().contains_key(&key)
522    }
523
524    /// Kill a process group (more efficient than killing individual processes)
525    #[cfg(unix)]
526    fn kill_process_tree(&self, pid: u32) -> Result<()> {
527        use nix::sys::signal::{Signal, kill, killpg};
528        use nix::unistd::Pid;
529        use std::io::{BufRead, BufReader};
530        use std::process::Command as SyncCommand;
531
532        let pid_obj = Pid::from_raw(pid as i32);
533
534        // Get the actual process group ID from the process
535        // If we used setsid, the PGID should equal the PID, but let's verify
536        let pgid = match Self::get_process_group_id(pid) {
537            Ok(gid) => {
538                let pgid_obj = Pid::from_raw(gid as i32);
539                // List all processes in the group before killing
540                if let Ok(pids) = Self::get_processes_in_group(gid) {
541                    info!(
542                        "Stopping process group {} (root PID: {}): found {} processes: {:?}",
543                        gid,
544                        pid,
545                        pids.len(),
546                        pids
547                    );
548                } else {
549                    info!("Stopping process group {} (root PID: {})", gid, pid);
550                }
551                pgid_obj
552            }
553            Err(_) => {
554                // Fallback: assume PGID equals PID (true if we used setsid)
555                warn!(
556                    "Could not get process group ID for PID {}, assuming PGID=PID",
557                    pid
558                );
559                info!("Stopping process (PID: {}, assumed PGID: {})", pid, pid);
560                pid_obj
561            }
562        };
563
564        // First, send SIGTERM to the entire process group
565        if let Err(e) = killpg(pgid, Signal::SIGTERM) {
566            warn!("Failed to send SIGTERM to process group {}: {:?}", pgid, e);
567            // Fallback: try killing the process directly
568            let _ = kill(pid_obj, Signal::SIGTERM);
569        }
570
571        // Wait for processes to terminate gracefully, but check periodically
572        let max_wait = std::time::Duration::from_secs(1);
573        let check_interval = std::time::Duration::from_millis(100);
574        let start_time = std::time::Instant::now();
575
576        while start_time.elapsed() < max_wait {
577            // Check if process group still has any processes alive
578            if let Ok(output) = SyncCommand::new("pgrep")
579                .arg("-g")
580                .arg(pgid.as_raw().to_string())
581                .output()
582                && !output.status.success()
583            {
584                // No processes in group, they've all terminated
585                break;
586            }
587            std::thread::sleep(check_interval);
588        }
589
590        // Check if process group still has any processes alive
591        // Use pgrep to find processes in the group
592        if let Ok(output) = SyncCommand::new("pgrep")
593            .arg("-g")
594            .arg(pgid.as_raw().to_string())
595            .output()
596        {
597            if output.status.success() {
598                let reader = BufReader::new(&*output.stdout);
599                let mut still_alive = Vec::new();
600                for line in reader.lines() {
601                    if let Ok(pid_str) = line
602                        && let Ok(proc_pid) = pid_str.parse::<u32>()
603                    {
604                        still_alive.push(proc_pid);
605                    }
606                }
607
608                if !still_alive.is_empty() {
609                    info!(
610                        "Process group still has {} processes alive, sending SIGKILL",
611                        still_alive.len()
612                    );
613                    // Send SIGKILL to the process group
614                    let _ = killpg(pgid, Signal::SIGKILL);
615                    // Also kill individual processes as fallback
616                    for proc_pid in still_alive {
617                        let proc_pid_obj = Pid::from_raw(proc_pid as i32);
618                        let _ = kill(proc_pid_obj, Signal::SIGKILL);
619                    }
620                }
621            }
622        } else {
623            // Fallback: try to kill the process directly if pgrep fails
624            // Check if process still exists
625            if let Ok(output) = SyncCommand::new("ps")
626                .arg("-p")
627                .arg(pid.to_string())
628                .output()
629                && output.status.success()
630            {
631                let _ = kill(pid_obj, Signal::SIGKILL);
632            }
633        }
634
635        Ok(())
636    }
637
638    /// Get process group ID for a given PID
639    #[cfg(unix)]
640    fn get_process_group_id(pid: u32) -> Result<u32> {
641        use std::process::Command as SyncCommand;
642
643        // Use ps to get the process group ID
644        if let Ok(output) = SyncCommand::new("ps")
645            .arg("-o")
646            .arg("pgid=")
647            .arg("-p")
648            .arg(pid.to_string())
649            .output()
650            && output.status.success()
651        {
652            let pgid_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
653            if let Ok(pgid) = pgid_str.parse::<u32>() {
654                return Ok(pgid);
655            }
656        }
657
658        anyhow::bail!("Failed to get process group ID for PID {}", pid)
659    }
660
661    /// Get all process IDs in a process group
662    #[cfg(unix)]
663    fn get_processes_in_group(pgid: u32) -> Result<Vec<u32>> {
664        use std::io::{BufRead, BufReader};
665        use std::process::Command as SyncCommand;
666        use std::process::Stdio;
667
668        let mut pids = Vec::new();
669
670        // Use pgrep to find all processes in the group
671        if let Ok(output) = SyncCommand::new("pgrep")
672            .arg("-g")
673            .arg(pgid.to_string())
674            .stdout(Stdio::piped())
675            .output()
676            && output.status.success()
677        {
678            let reader = BufReader::new(&*output.stdout);
679            for line in reader.lines() {
680                if let Ok(pid_str) = line
681                    && let Ok(pid) = pid_str.parse::<u32>()
682                {
683                    pids.push(pid);
684                }
685            }
686        }
687
688        Ok(pids)
689    }
690
691    /// Get process tree structure (parent-child relationships)
692    #[cfg(unix)]
693    pub fn get_process_tree(pid: u32) -> Result<ProcessTreeNode> {
694        use std::collections::HashMap;
695        use std::io::{BufRead, BufReader};
696        use std::process::Command as SyncCommand;
697        use std::process::Stdio;
698
699        #[derive(Debug, Clone)]
700        struct ProcessInfo {
701            pid: u32,
702            ppid: u32,
703            cmd: String,
704        }
705
706        // Get all processes with their parent PIDs
707        let mut processes: HashMap<u32, ProcessInfo> = HashMap::new();
708
709        // Use ps to get process tree with full command
710        // Use -ww to get full command line (no width limit)
711        if let Ok(output) = SyncCommand::new("ps")
712            .arg("-eo")
713            .arg("pid,ppid,args")
714            .arg("--no-headers")
715            .stdout(Stdio::piped())
716            .output()
717            && output.status.success()
718        {
719            let reader = BufReader::new(&*output.stdout);
720            for line_str in reader.lines().map_while(Result::ok) {
721                // Parse line: PID PPID COMMAND...
722                // We need to handle spaces in command, so split by first two numbers
723                let trimmed = line_str.trim();
724                if let Some(first_space) = trimmed.find(' ')
725                    && let Ok(proc_pid) = trimmed[..first_space].parse::<u32>()
726                {
727                    let rest = &trimmed[first_space + 1..].trim_start();
728                    if let Some(second_space) = rest.find(' ')
729                        && let Ok(proc_ppid) = rest[..second_space].parse::<u32>()
730                    {
731                        let cmd = rest[second_space + 1..].trim().to_string();
732                        // Truncate long commands for display
733                        let cmd_display = if cmd.len() > 60 {
734                            format!("{}...", &cmd[..57])
735                        } else {
736                            cmd
737                        };
738                        processes.insert(
739                            proc_pid,
740                            ProcessInfo {
741                                pid: proc_pid,
742                                ppid: proc_ppid,
743                                cmd: cmd_display,
744                            },
745                        );
746                    }
747                }
748            }
749        }
750
751        // Check if root process exists
752        if !processes.contains_key(&pid) {
753            anyhow::bail!("Process {} not found in process list", pid);
754        }
755
756        // Build tree starting from root PID
757        fn build_tree(pid: u32, processes: &HashMap<u32, ProcessInfo>) -> ProcessTreeNode {
758            let proc_info = processes.get(&pid);
759            let cmd = proc_info
760                .map(|p| p.cmd.clone())
761                .unwrap_or_else(|| format!("[PID {}]", pid));
762
763            // Find all children
764            let children: Vec<ProcessTreeNode> = processes
765                .values()
766                .filter(|p| p.ppid == pid)
767                .map(|p| build_tree(p.pid, processes))
768                .collect();
769
770            ProcessTreeNode { pid, cmd, children }
771        }
772
773        Ok(build_tree(pid, &processes))
774    }
775
776    #[cfg(not(unix))]
777    pub fn get_process_tree(pid: u32) -> Result<ProcessTreeNode> {
778        // On Windows, return simple structure
779        Ok(ProcessTreeNode {
780            pid,
781            cmd: format!("[PID {}]", pid),
782            children: Vec::new(),
783        })
784    }
785
786    #[cfg(not(unix))]
787    fn kill_process_tree(&self, pid: u32) -> Result<()> {
788        // On Windows, use taskkill with /T flag to kill process tree
789        use std::process::Command as SyncCommand;
790        let _ = SyncCommand::new("taskkill")
791            .args(&["/PID", &pid.to_string(), "/T", "/F"])
792            .output();
793        Ok(())
794    }
795
796    /// Stop a specific process
797    pub async fn stop_process(
798        &self,
799        std_name: &str,
800        package_type: &str,
801    ) -> Result<ProcessStopResult> {
802        let key = format!("{}::{}", package_type, std_name);
803
804        // Get and remove process info, then drop the lock
805        let process_info = {
806            let mut processes = self.processes.lock().unwrap();
807            processes.remove(&key)
808        };
809
810        if let Some(process_info) = process_info {
811            info!("Stopping process: {} (PID: {})", key, process_info.pid);
812
813            // Get process group information before killing
814            #[cfg(unix)]
815            let (pgid, pids) = {
816                if let Ok(pgid) = Self::get_process_group_id(process_info.pid) {
817                    let pids = Self::get_processes_in_group(pgid).ok();
818                    (Some(pgid), pids)
819                } else {
820                    (None, None)
821                }
822            };
823            #[cfg(not(unix))]
824            let (pgid, pids) = (None, None);
825
826            // Kill the process tree (parent + all children)
827            if let Err(e) = self.kill_process_tree(process_info.pid) {
828                warn!(
829                    "Failed to kill process tree for PID {}: {:?}",
830                    process_info.pid, e
831                );
832                // Fallback: try to kill just the main process
833                #[cfg(unix)]
834                {
835                    use nix::sys::signal::{Signal, kill};
836                    use nix::unistd::Pid;
837                    let pid = Pid::from_raw(process_info.pid as i32);
838                    let _ = kill(pid, Signal::SIGTERM);
839                    tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
840                    let _ = kill(pid, Signal::SIGKILL);
841                }
842                #[cfg(not(unix))]
843                {
844                    let _ = Command::new("taskkill")
845                        .args(&["/PID", &process_info.pid.to_string(), "/F"])
846                        .output()
847                        .await;
848                }
849            }
850
851            // Append stop message to log
852            if let Ok(mut file) = OpenOptions::new()
853                .create(true)
854                .append(true)
855                .open(&process_info.log_file)
856                .await
857            {
858                let stop_msg = format!(
859                    "\n=== Stopped {} {} at {} ===\n",
860                    package_type,
861                    std_name,
862                    chrono::Utc::now().to_rfc3339()
863                );
864                let _ = file.write_all(stop_msg.as_bytes()).await;
865            }
866
867            info!("Process stopped: {}", key);
868
869            // Save state to persistent storage (lock is already dropped, so this is safe)
870            self.save_state()?;
871
872            Ok(ProcessStopResult {
873                pid: process_info.pid,
874                pgid,
875                pids,
876            })
877        } else {
878            warn!("Process not found: {}", key);
879            anyhow::bail!("Process not found: {}", key)
880        }
881    }
882
883    /// Stop all processes
884    pub async fn stop_all(&self) -> Result<()> {
885        let processes = {
886            let processes = self.processes.lock().unwrap();
887            let keys: Vec<String> = processes.keys().cloned().collect();
888            keys
889        };
890
891        for key in processes {
892            let parts: Vec<&str> = key.split("::").collect();
893            if parts.len() == 2 {
894                let package_type = parts[0];
895                let std_name = parts[1];
896                let _ = self.stop_process(std_name, package_type).await;
897            }
898        }
899
900        Ok(())
901    }
902
903    /// Stop all processes for a given package name
904    pub async fn stop_by_package(&self, package_name: &str) -> Result<Vec<String>> {
905        let to_stop: Vec<(String, String)> = {
906            let processes = self.processes.lock().unwrap();
907            processes
908                .values()
909                .filter(|p| p.package_name == package_name)
910                .map(|p| (p.std_name.clone(), p.package_type.clone()))
911                .collect()
912        };
913
914        let mut stopped = Vec::new();
915        for (std_name, package_type) in to_stop {
916            if self.stop_process(&std_name, &package_type).await.is_ok() {
917                stopped.push(std_name);
918            }
919        }
920        Ok(stopped)
921    }
922}
923
924// Note: We intentionally do NOT implement Drop for ProcessManager.
925// Processes are started as daemon processes and should continue running
926// even after the CLI exits. They can be stopped explicitly using the
927// unregister command or stop_process/stop_all methods.
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932    use std::fs;
933
934    fn unique_temp_dir(label: &str) -> PathBuf {
935        let dir = std::env::temp_dir().join(format!(
936            "robonix-process-test-{label}-{}-{}",
937            std::process::id(),
938            chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
939        ));
940        fs::create_dir_all(&dir).unwrap();
941        dir
942    }
943
944    #[test]
945    fn empty_process_state_is_reset_instead_of_fatal() {
946        let root = unique_temp_dir("empty");
947        let log_dir = root.join("rbnx-boot").join("logs");
948        fs::create_dir_all(&log_dir).unwrap();
949        let state_file = root.join("rbnx-boot").join("processes.json");
950        fs::write(&state_file, "").unwrap();
951
952        let manager = ProcessManager::new(log_dir).unwrap();
953
954        assert!(manager.processes.lock().unwrap().is_empty());
955        assert_eq!(fs::read_to_string(&state_file).unwrap(), "[]");
956        let _ = fs::remove_dir_all(root);
957    }
958
959    #[test]
960    fn invalid_process_state_is_reset_instead_of_fatal() {
961        let root = unique_temp_dir("invalid");
962        let log_dir = root.join("rbnx-boot").join("logs");
963        fs::create_dir_all(&log_dir).unwrap();
964        let state_file = root.join("rbnx-boot").join("processes.json");
965        fs::write(&state_file, "{").unwrap();
966
967        let manager = ProcessManager::new(log_dir).unwrap();
968
969        assert!(manager.processes.lock().unwrap().is_empty());
970        assert_eq!(fs::read_to_string(&state_file).unwrap(), "[]");
971        let _ = fs::remove_dir_all(root);
972    }
973}