1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ProcessInfo {
20 pub package_name: String,
21 pub std_name: String,
22 pub package_type: String, 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#[derive(Debug, Clone)]
34pub struct ProcessStartResult {
35 pub pid: u32,
36 pub pgid: Option<u32>,
37 pub pids: Option<Vec<u32>>,
38}
39
40#[derive(Debug, Clone)]
42pub struct ProcessStopResult {
43 pub pid: u32,
44 pub pgid: Option<u32>,
45 pub pids: Option<Vec<u32>>,
46}
47
48#[derive(Debug, Clone)]
50pub struct ProcessTreeNode {
51 pub pid: u32,
52 pub cmd: String,
53 pub children: Vec<ProcessTreeNode>,
54}
55
56impl ProcessTreeNode {
57 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
78fn 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
102pub struct ProcessManager {
104 processes: Arc<Mutex<HashMap<String, ProcessInfo>>>, log_dir: PathBuf,
107 state_file: PathBuf,
108 hostname: String,
109}
110
111impl ProcessManager {
112 pub fn new(log_dir: PathBuf) -> Result<Self> {
113 std::fs::create_dir_all(&log_dir)
115 .with_context(|| format!("Failed to create log directory: {}", log_dir.display()))?;
116
117 let hostname = hostname::get()
119 .context("Failed to get hostname")?
120 .to_string_lossy()
121 .to_string();
122
123 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 manager.load_state()?;
152
153 Ok(manager)
154 }
155
156 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 let mut valid_processes = HashMap::new();
188 let original_count = processes.len();
189 for process_info in processes {
190 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 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 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 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 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 kill(pid, None).is_ok()
285 }
286 #[cfg(not(unix))]
287 {
288 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 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 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 #[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 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 let stdout = child.stdout.take().expect("stdout not piped");
420 let stderr = child.stderr.take().expect("stderr not piped");
421 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 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 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 pub fn get_running_processes(&self) -> Vec<ProcessInfo> {
480 let processes = self.processes.lock().unwrap();
481 processes.values().cloned().collect()
482 }
483
484 pub fn is_running(&self, std_name: &str, package_type: &str) -> bool {
486 let key = format!("{}::{}", package_type, std_name);
487
488 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 if Self::is_process_record_live(&process_info) {
497 return true;
498 } else {
499 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 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 #[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 let pgid = match Self::get_process_group_id(pid) {
537 Ok(gid) => {
538 let pgid_obj = Pid::from_raw(gid as i32);
539 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 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 if let Err(e) = killpg(pgid, Signal::SIGTERM) {
566 warn!("Failed to send SIGTERM to process group {}: {:?}", pgid, e);
567 let _ = kill(pid_obj, Signal::SIGTERM);
569 }
570
571 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 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 break;
586 }
587 std::thread::sleep(check_interval);
588 }
589
590 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 let _ = killpg(pgid, Signal::SIGKILL);
615 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 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 #[cfg(unix)]
640 fn get_process_group_id(pid: u32) -> Result<u32> {
641 use std::process::Command as SyncCommand;
642
643 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 #[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 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 #[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 let mut processes: HashMap<u32, ProcessInfo> = HashMap::new();
708
709 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 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 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 if !processes.contains_key(&pid) {
753 anyhow::bail!("Process {} not found in process list", pid);
754 }
755
756 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 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 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 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 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 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 #[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 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 #[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 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 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 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 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#[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}