1use crate::daemon::log_writer::{self, OutputLine};
2use crate::daemon::port_allocator::PortAllocator;
3use crate::paths;
4use crate::protocol::{
5 ErrorCode, ProcessInfo, ProcessState, Response, RestartMode, RestartPolicy,
6 Stream as ProtoStream, WatchConfig, process_url,
7};
8use crate::session::IdCounter;
9use std::collections::HashMap;
10use std::process::Stdio;
11use std::sync::Arc;
12use std::sync::atomic::AtomicU64;
13use std::time::{Duration, Instant};
14use tokio::process::{Child, Command};
15use tokio::sync::broadcast;
16
17const DEFAULT_MAX_LOG_BYTES: u64 = 50 * 1024 * 1024; #[must_use]
22pub fn is_valid_dns_label(name: &str) -> bool {
23 if name.is_empty() || name.len() > 63 {
24 return false;
25 }
26 if name.starts_with('-') || name.ends_with('-') {
27 return false;
28 }
29 name.chars()
30 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
31}
32
33pub struct ManagedProcess {
34 pub name: String,
35 pub id: String,
36 pub command: String,
37 pub cwd: Option<String>,
38 pub env: HashMap<String, String>,
39 pub child: Option<Child>,
40 pub pid: u32,
41 pub started_at: Instant,
42 pub exit_code: Option<i32>,
43 pub port: Option<u16>,
44 pub restart_policy: Option<RestartPolicy>,
46 pub watch_config: Option<WatchConfig>,
47 pub restart_count: u32,
48 pub manually_stopped: bool,
49 pub restart_pending: bool,
50 pub failed: bool,
51 pub supervisor_tx: Option<tokio::sync::mpsc::Sender<String>>,
52 pub capture_handles: Vec<tokio::task::JoinHandle<()>>,
53 pub watch_handle: Option<crate::daemon::watcher::WatchHandle>,
54}
55
56pub struct ProcessManager {
57 processes: HashMap<String, ManagedProcess>,
58 id_counter: IdCounter,
59 session: String,
60 pub output_tx: broadcast::Sender<OutputLine>,
61 port_allocator: PortAllocator,
62}
63
64impl ProcessManager {
65 pub fn new(session: &str) -> Self {
66 let (output_tx, _) = broadcast::channel(1024);
67 Self {
68 processes: HashMap::new(),
69 id_counter: IdCounter::new(),
70 session: session.to_string(),
71 output_tx,
72 port_allocator: PortAllocator::new(),
73 }
74 }
75
76 #[allow(unknown_lints)]
79 #[allow(unsafe_code, clippy::unused_async, clippy::unused_async_trait_impl)]
80 pub async fn spawn_process(
81 &mut self,
82 command: &str,
83 name: Option<String>,
84 cwd: Option<&str>,
85 env: Option<&HashMap<String, String>>,
86 port: Option<u16>,
87 ) -> Response {
88 let id = self.id_counter.next_id();
89 let name = name.unwrap_or_else(|| id.clone());
90
91 if name.contains('/') || name.contains('\\') || name.contains("..") || name.contains('\0') {
93 return Response::Error {
94 code: ErrorCode::General,
95 message: format!("invalid process name: {}", name),
96 };
97 }
98
99 if self.port_allocator.is_proxy_enabled() && !is_valid_dns_label(&name) {
101 return Response::Error {
102 code: ErrorCode::General,
103 message: format!(
104 "invalid process name for proxy: '{}' (must be lowercase alphanumeric/hyphens, max 63 chars)",
105 name
106 ),
107 };
108 }
109
110 let resolved_port = if let Some(p) = port {
112 Some(p)
113 } else if self.port_allocator.is_proxy_enabled() {
114 let assigned: std::collections::HashSet<u16> = self
115 .processes
116 .values()
117 .filter(|p| p.child.is_some())
118 .filter_map(|p| p.port)
119 .collect();
120 match self.port_allocator.auto_assign_port(&assigned) {
121 Ok(p) => Some(p),
122 Err(e) => {
123 return Response::Error {
124 code: ErrorCode::General,
125 message: e.to_string(),
126 };
127 }
128 }
129 } else {
130 None
131 };
132
133 if self.processes.contains_key(&name) {
134 return Response::Error {
135 code: ErrorCode::General,
136 message: format!("process already exists: {}", name),
137 };
138 }
139
140 let log_dir = paths::log_dir(&self.session);
141 let _ = std::fs::create_dir_all(&log_dir);
142
143 let mut cmd = Command::new("sh");
144 cmd.arg("-c")
145 .arg(command)
146 .stdout(Stdio::piped())
147 .stderr(Stdio::piped());
148 if let Some(dir) = cwd {
149 cmd.current_dir(dir);
150 }
151 if let Some(p) = resolved_port {
152 let mut merged_env: HashMap<String, String> = HashMap::new();
154 merged_env.insert("PORT".to_string(), p.to_string());
155 merged_env.insert("HOST".to_string(), "127.0.0.1".to_string());
156 if let Some(env_vars) = env {
157 for (k, v) in env_vars {
158 merged_env.insert(k.clone(), v.clone());
159 }
160 }
161 cmd.envs(&merged_env);
162 } else if let Some(env_vars) = env {
163 cmd.envs(env_vars);
164 }
165 unsafe {
169 cmd.pre_exec(|| {
170 nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
171 .map_err(std::io::Error::other)?;
172 Ok(())
173 });
174 }
175
176 let mut child = match cmd.spawn() {
177 Ok(c) => c,
178 Err(e) => {
179 return Response::Error {
180 code: ErrorCode::General,
181 message: format!("failed to spawn: {}", e),
182 };
183 }
184 };
185
186 let pid = child.id().unwrap_or(0);
187
188 let seq_counter = Arc::new(AtomicU64::new(0));
190
191 let (sup_tx, sup_rx_stdout) = tokio::sync::mpsc::channel::<String>(16);
194 let (stderr_sup_sender, sup_rx_stderr) = tokio::sync::mpsc::channel::<String>(16);
195 drop(stderr_sup_sender);
196
197 let mut capture_handles = Vec::new();
198
199 if let Some(stdout) = child.stdout.take() {
200 let tx = self.output_tx.clone();
201 let pname = name.clone();
202 let path = log_dir.join(format!("{}.stdout", name));
203 let seq = Arc::clone(&seq_counter);
204 let handle = tokio::spawn(async move {
205 log_writer::capture_output(
206 stdout,
207 &path,
208 &pname,
209 ProtoStream::Stdout,
210 tx,
211 DEFAULT_MAX_LOG_BYTES,
212 log_writer::DEFAULT_MAX_ROTATED_FILES,
213 seq,
214 sup_rx_stdout,
215 )
216 .await;
217 });
218 capture_handles.push(handle);
219 }
220 if let Some(stderr) = child.stderr.take() {
221 let tx = self.output_tx.clone();
222 let pname = name.clone();
223 let path = log_dir.join(format!("{}.stderr", name));
224 let seq = Arc::clone(&seq_counter);
225 let handle = tokio::spawn(async move {
226 log_writer::capture_output(
227 stderr,
228 &path,
229 &pname,
230 ProtoStream::Stderr,
231 tx,
232 DEFAULT_MAX_LOG_BYTES,
233 log_writer::DEFAULT_MAX_ROTATED_FILES,
234 seq,
235 sup_rx_stderr,
236 )
237 .await;
238 });
239 capture_handles.push(handle);
240 }
241
242 self.processes.insert(
243 name.clone(),
244 ManagedProcess {
245 name: name.clone(),
246 id: id.clone(),
247 command: command.to_string(),
248 cwd: cwd.map(std::string::ToString::to_string),
249 env: env.cloned().unwrap_or_default(),
250 child: Some(child),
251 pid,
252 started_at: Instant::now(),
253 exit_code: None,
254 port: resolved_port,
255 restart_policy: None,
256 watch_config: None,
257 restart_count: 0,
258 manually_stopped: false,
259 restart_pending: false,
260 failed: false,
261 supervisor_tx: Some(sup_tx),
262 capture_handles,
263 watch_handle: None,
264 },
265 );
266
267 let url = resolved_port.map(|p| process_url(&name, p, None));
268 Response::RunOk {
269 name,
270 id,
271 pid,
272 port: resolved_port,
273 url,
274 }
275 }
276
277 pub async fn stop_process(&mut self, target: &str) -> Response {
278 let proc = match self.find_mut(target) {
279 Some(p) => p,
280 None => {
281 return Response::Error {
282 code: ErrorCode::NotFound,
283 message: format!("process not found: {}", target),
284 };
285 }
286 };
287
288 proc.manually_stopped = true;
289
290 if let Some(ref child) = proc.child {
291 let raw_pid = child.id().unwrap_or(0) as i32;
292 if raw_pid > 0 {
293 let pgid = nix::unistd::Pid::from_raw(raw_pid);
295 let _ = nix::sys::signal::killpg(pgid, nix::sys::signal::Signal::SIGTERM);
296 }
297 }
298
299 if let Some(ref mut child) = proc.child {
301 let wait_result = tokio::time::timeout(Duration::from_secs(10), child.wait()).await;
302
303 match wait_result {
304 Ok(Ok(status)) => {
305 proc.exit_code = status.code();
306 }
307 _ => {
308 let raw_pid = proc.pid as i32;
310 if raw_pid > 0 {
311 let pgid = nix::unistd::Pid::from_raw(raw_pid);
312 let _ = nix::sys::signal::killpg(pgid, nix::sys::signal::Signal::SIGKILL);
313 }
314 let _ = child.wait().await;
315 proc.exit_code = Some(-9);
316 }
317 }
318 proc.child = None;
319 }
320
321 Response::Ok {
322 message: format!("stopped {}", target),
323 }
324 }
325
326 pub async fn stop_all(&mut self) -> Response {
327 let names: Vec<String> = self.processes.keys().cloned().collect();
328 for name in names {
329 let _ = self.stop_process(&name).await;
330 }
331 self.processes.clear();
332 Response::Ok {
333 message: "all processes stopped".into(),
334 }
335 }
336
337 pub async fn restart_process(&mut self, target: &str) -> Response {
338 let (command, name, cwd, env, port, restart_policy, watch_config) = match self.find(target)
339 {
340 Some(p) => (
341 p.command.clone(),
342 p.name.clone(),
343 p.cwd.clone(),
344 p.env.clone(),
345 p.port,
346 p.restart_policy.clone(),
347 p.watch_config.clone(),
348 ),
349 None => {
350 return Response::Error {
351 code: ErrorCode::NotFound,
352 message: format!("process not found: {}", target),
353 };
354 }
355 };
356 if let Some(p) = self.find_mut(target) {
358 p.manually_stopped = false;
359 p.restart_count = 0;
360 p.failed = false;
361 p.restart_pending = false;
362 }
363 let _ = self.stop_process(target).await;
364 self.processes.remove(&name);
365 let env = if env.is_empty() { None } else { Some(env) };
366 let resp = self
367 .spawn_process(
368 &command,
369 Some(name.clone()),
370 cwd.as_deref(),
371 env.as_ref(),
372 port,
373 )
374 .await;
375 if let Response::RunOk { .. } = resp
377 && let Some(p) = self.find_mut(&name)
378 {
379 p.restart_policy = restart_policy;
380 p.watch_config = watch_config;
381 }
382 resp
383 }
384
385 pub fn enable_proxy(&mut self) {
386 self.port_allocator.enable_proxy();
387 }
388
389 pub fn status(&mut self) -> Response {
390 self.refresh_exit_states();
391 Response::Status {
392 processes: self.build_process_infos(),
393 }
394 }
395
396 pub fn is_process_exited(&mut self, target: &str) -> Option<Option<i32>> {
399 self.refresh_exit_states();
400 self.find(target).and_then(|p| {
401 if p.child.is_none() {
402 Some(p.exit_code)
403 } else {
404 None
405 }
406 })
407 }
408
409 pub(crate) fn refresh_exit_states(&mut self) -> bool {
410 let mut changed = false;
411 for proc in self.processes.values_mut() {
412 if proc.child.is_some()
413 && proc.exit_code.is_none()
414 && let Some(ref mut child) = proc.child
415 && let Ok(Some(status)) = child.try_wait()
416 {
417 proc.exit_code = status.code();
418 proc.child = None;
419 changed = true;
420 }
421 }
422 changed
423 }
424
425 pub fn session_name(&self) -> &str {
426 &self.session
427 }
428
429 pub fn has_process(&self, target: &str) -> bool {
430 self.find(target).is_some()
431 }
432
433 pub fn running_ports(&self) -> HashMap<String, u16> {
435 self.processes
436 .iter()
437 .filter_map(|(name, p)| {
438 if p.child.is_some() {
439 p.port.map(|port| (name.clone(), port))
440 } else {
441 None
442 }
443 })
444 .collect()
445 }
446
447 pub fn status_snapshot(&self) -> Response {
450 Response::Status {
451 processes: self.build_process_infos(),
452 }
453 }
454
455 fn build_process_infos(&self) -> Vec<ProcessInfo> {
456 let mut infos: Vec<ProcessInfo> = self
457 .processes
458 .values()
459 .map(|p| ProcessInfo {
460 name: p.name.clone(),
461 id: p.id.clone(),
462 pid: p.pid,
463 state: if p.child.is_some() {
464 ProcessState::Running
465 } else if p.failed {
466 ProcessState::Failed
467 } else {
468 ProcessState::Exited
469 },
470 exit_code: p.exit_code,
471 uptime_secs: if p.child.is_some() {
472 Some(p.started_at.elapsed().as_secs())
473 } else {
474 None
475 },
476 command: p.command.clone(),
477 port: p.port,
478 url: p.port.map(|port| process_url(&p.name, port, None)),
479 restart_count: if p.restart_count > 0 {
480 Some(p.restart_count)
481 } else {
482 None
483 },
484 max_restarts: p.restart_policy.as_ref().and_then(|rp| rp.max_restarts),
485 restart_policy: p.restart_policy.as_ref().map(|rp| match rp.mode {
486 RestartMode::Always => "always".into(),
487 RestartMode::OnFailure => "on-failure".into(),
488 RestartMode::Never => "never".into(),
489 }),
490 watched: if p.watch_config.is_some() {
491 Some(true)
492 } else {
493 None
494 },
495 })
496 .collect();
497 infos.sort_by(|a, b| a.name.cmp(&b.name));
498 infos
499 }
500
501 pub(crate) fn find(&self, target: &str) -> Option<&ManagedProcess> {
502 self.processes
503 .get(target)
504 .or_else(|| self.processes.values().find(|p| p.id == target))
505 }
506
507 pub(crate) fn find_mut(&mut self, target: &str) -> Option<&mut ManagedProcess> {
508 if self.processes.contains_key(target) {
509 self.processes.get_mut(target)
510 } else {
511 self.processes.values_mut().find(|p| p.id == target)
512 }
513 }
514
515 pub fn classify_restart_candidates(&self) -> (Vec<String>, Vec<String>) {
518 let mut restartable = Vec::new();
519 let mut exhausted = Vec::new();
520 for p in self.processes.values() {
521 if p.child.is_some() || p.manually_stopped || p.restart_pending || p.failed {
522 continue;
523 }
524 let Some(ref policy) = p.restart_policy else {
525 continue;
526 };
527 if !policy.mode.should_restart(p.exit_code) {
528 continue;
529 }
530 if policy
531 .max_restarts
532 .is_some_and(|max| p.restart_count >= max)
533 {
534 exhausted.push(p.name.clone());
535 } else {
536 restartable.push(p.name.clone());
537 }
538 }
539 (restartable, exhausted)
540 }
541
542 pub fn mark_failed(&mut self, target: &str) {
544 if let Some(p) = self.find_mut(target) {
545 p.failed = true;
546 }
547 }
548
549 pub async fn respawn_in_place(&mut self, target: &str) -> Result<(), String> {
553 let proc = self
554 .find(target)
555 .ok_or_else(|| format!("process not found: {}", target))?;
556
557 let command = proc.command.clone();
559 let name = proc.name.clone();
560 let cwd = proc.cwd.clone();
561 let env = proc.env.clone();
562 let port = proc.port;
563 let restart_policy = proc.restart_policy.clone();
564 let watch_config = proc.watch_config.clone();
565 let restart_count = proc.restart_count;
566 let restart_pending = proc.restart_pending;
567
568 if let Some(proc) = self.find_mut(target) {
570 proc.supervisor_tx = None;
571 }
572
573 if let Some(proc) = self.find_mut(target) {
575 let handles = std::mem::take(&mut proc.capture_handles);
576 for h in handles {
577 let _ = h.await;
578 }
579 }
580
581 let log_dir = crate::paths::log_dir(&self.session);
583 let stdout_path = log_dir.join(format!("{}.stdout", name));
584 let stderr_path = log_dir.join(format!("{}.stderr", name));
585 log_writer::rotate_if_exists(&stdout_path).await;
586 log_writer::rotate_if_exists(&stderr_path).await;
587
588 self.processes.remove(&name);
590
591 let env_opt = if env.is_empty() {
593 None
594 } else {
595 Some(env.clone())
596 };
597 let resp = self
598 .spawn_process(
599 &command,
600 Some(name.clone()),
601 cwd.as_deref(),
602 env_opt.as_ref(),
603 port,
604 )
605 .await;
606
607 match resp {
608 Response::RunOk { .. } => {
609 if let Some(p) = self.find_mut(&name) {
611 p.restart_policy = restart_policy;
612 p.watch_config = watch_config;
613 p.restart_count = restart_count;
614 p.failed = false;
615 }
616 Ok(())
617 }
618 Response::Error { message, .. } => {
619 self.processes.insert(
621 name.clone(),
622 ManagedProcess {
623 name: name.clone(),
624 id: "tombstone".into(),
625 command,
626 cwd,
627 env,
628 child: None,
629 pid: 0,
630 started_at: Instant::now(),
631 exit_code: None,
632 port,
633 restart_policy,
634 watch_config,
635 restart_count,
636 manually_stopped: false,
637 restart_pending,
638 failed: true,
639 supervisor_tx: None,
640 capture_handles: Vec::new(),
641 watch_handle: None,
642 },
643 );
644 Err(message)
645 }
646 _ => Err("unexpected response from spawn".into()),
647 }
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654
655 #[test]
656 fn test_valid_dns_labels() {
657 assert!(is_valid_dns_label("api"));
658 assert!(is_valid_dns_label("my-app"));
659 assert!(is_valid_dns_label("a"));
660 assert!(is_valid_dns_label("a1"));
661 assert!(is_valid_dns_label("123"));
662 }
663
664 #[test]
665 fn test_invalid_dns_labels() {
666 assert!(!is_valid_dns_label(""));
667 assert!(!is_valid_dns_label("-start"));
668 assert!(!is_valid_dns_label("end-"));
669 assert!(!is_valid_dns_label("UPPER"));
670 assert!(!is_valid_dns_label("has.dot"));
671 assert!(!is_valid_dns_label("has space"));
672 assert!(!is_valid_dns_label(&"a".repeat(64))); assert!(!is_valid_dns_label("has_underscore"));
674 }
675
676 #[tokio::test]
677 async fn test_respawn_in_place_preserves_metadata() {
678 let mut pm = ProcessManager::new("test-respawn");
679 let resp = pm
680 .spawn_process("echo hello", Some("worker".into()), None, None, None)
681 .await;
682 assert!(matches!(resp, Response::RunOk { .. }));
683
684 if let Some(p) = pm.find_mut("worker") {
686 p.restart_policy = Some(RestartPolicy {
687 mode: RestartMode::OnFailure,
688 max_restarts: Some(5),
689 restart_delay_ms: 1000,
690 });
691 p.restart_count = 3;
692 }
693
694 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
696 pm.refresh_exit_states();
697
698 let result = pm.respawn_in_place("worker").await;
700 assert!(result.is_ok());
701
702 let p = pm.find("worker").unwrap();
704 assert!(p.child.is_some()); assert_eq!(p.restart_count, 3);
706 assert!(p.restart_policy.is_some());
707 assert_eq!(
708 p.restart_policy.as_ref().unwrap().mode,
709 RestartMode::OnFailure
710 );
711 assert!(!p.failed);
712 }
713
714 #[tokio::test]
715 async fn test_respawn_in_place_tombstone_on_failure() {
716 let mut pm = ProcessManager::new("test-tombstone");
717 let resp = pm
718 .spawn_process("echo hello", Some("worker".into()), None, None, None)
719 .await;
720 assert!(matches!(resp, Response::RunOk { .. }));
721
722 if let Some(p) = pm.find_mut("worker") {
723 p.restart_policy = Some(RestartPolicy {
724 mode: RestartMode::Always,
725 max_restarts: Some(3),
726 restart_delay_ms: 1000,
727 });
728 p.restart_count = 2;
729 p.name = "work/er".to_string();
731 }
732
733 let proc = pm.processes.remove("worker").unwrap();
735 pm.processes.insert("work/er".to_string(), proc);
736
737 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
738 pm.refresh_exit_states();
739
740 let result = pm.respawn_in_place("work/er").await;
741 assert!(result.is_err());
742
743 let p = pm.find("work/er").unwrap();
745 assert!(p.child.is_none());
746 assert!(p.failed);
747 assert_eq!(p.restart_count, 2); assert!(p.restart_policy.is_some());
749 }
750
751 fn make_exited_process(name: &str, exit_code: Option<i32>) -> ManagedProcess {
753 ManagedProcess {
754 name: name.to_string(),
755 id: format!("id-{}", name),
756 command: "true".to_string(),
757 cwd: None,
758 env: HashMap::new(),
759 child: None,
760 pid: 0,
761 started_at: Instant::now(),
762 exit_code,
763 port: None,
764 restart_policy: None,
765 watch_config: None,
766 restart_count: 0,
767 manually_stopped: false,
768 restart_pending: false,
769 failed: false,
770 supervisor_tx: None,
771 capture_handles: Vec::new(),
772 watch_handle: None,
773 }
774 }
775
776 #[tokio::test]
777 async fn test_classify_skips_running_process() {
778 let mut pm = ProcessManager::new("test-classify-running");
779 let mut proc = make_exited_process("runner", Some(1));
780 proc.restart_policy = Some(RestartPolicy {
781 mode: RestartMode::Always,
782 max_restarts: None,
783 restart_delay_ms: 0,
784 });
785 let child = Command::new("sleep")
787 .arg("60")
788 .stdout(std::process::Stdio::null())
789 .stderr(std::process::Stdio::null())
790 .spawn()
791 .unwrap();
792 let pid = child.id().unwrap_or(0) as i32;
793 proc.child = Some(child);
794 pm.processes.insert("runner".into(), proc);
795
796 let (restartable, exhausted) = pm.classify_restart_candidates();
797 assert!(restartable.is_empty());
798 assert!(exhausted.is_empty());
799
800 if pid > 0 {
802 let _ = nix::sys::signal::kill(
803 nix::unistd::Pid::from_raw(pid),
804 nix::sys::signal::Signal::SIGKILL,
805 );
806 }
807 if let Some(p) = pm.processes.get_mut("runner")
808 && let Some(ref mut c) = p.child
809 {
810 let _ = c.wait().await;
811 }
812 }
813
814 #[test]
815 fn test_classify_skips_manually_stopped() {
816 let mut pm = ProcessManager::new("test-classify-stopped");
817 let mut proc = make_exited_process("stopped", Some(1));
818 proc.restart_policy = Some(RestartPolicy {
819 mode: RestartMode::Always,
820 max_restarts: None,
821 restart_delay_ms: 0,
822 });
823 proc.manually_stopped = true;
824 pm.processes.insert("stopped".into(), proc);
825
826 let (restartable, exhausted) = pm.classify_restart_candidates();
827 assert!(restartable.is_empty());
828 assert!(exhausted.is_empty());
829 }
830
831 #[test]
832 fn test_classify_skips_clean_exit_on_failure_mode() {
833 let mut pm = ProcessManager::new("test-classify-clean");
834 let mut proc = make_exited_process("clean", Some(0));
835 proc.restart_policy = Some(RestartPolicy {
836 mode: RestartMode::OnFailure,
837 max_restarts: None,
838 restart_delay_ms: 0,
839 });
840 pm.processes.insert("clean".into(), proc);
841
842 let (restartable, exhausted) = pm.classify_restart_candidates();
843 assert!(restartable.is_empty());
844 assert!(exhausted.is_empty());
845 }
846
847 #[test]
848 fn test_classify_skips_restart_pending() {
849 let mut pm = ProcessManager::new("test-classify-pending");
850 let mut proc = make_exited_process("pending", Some(1));
851 proc.restart_policy = Some(RestartPolicy {
852 mode: RestartMode::Always,
853 max_restarts: None,
854 restart_delay_ms: 0,
855 });
856 proc.restart_pending = true;
857 pm.processes.insert("pending".into(), proc);
858
859 let (restartable, exhausted) = pm.classify_restart_candidates();
860 assert!(restartable.is_empty());
861 assert!(exhausted.is_empty());
862 }
863
864 #[test]
865 fn test_classify_restartable_on_failure() {
866 let mut pm = ProcessManager::new("test-classify-on-failure");
867 let mut proc = make_exited_process("crasher", Some(1));
868 proc.restart_policy = Some(RestartPolicy {
869 mode: RestartMode::OnFailure,
870 max_restarts: None,
871 restart_delay_ms: 0,
872 });
873 pm.processes.insert("crasher".into(), proc);
874
875 let (restartable, exhausted) = pm.classify_restart_candidates();
876 assert_eq!(restartable, vec!["crasher"]);
877 assert!(exhausted.is_empty());
878 }
879
880 #[test]
881 fn test_classify_exhausted() {
882 let mut pm = ProcessManager::new("test-classify-exhausted");
883 let mut proc = make_exited_process("tired", Some(1));
884 proc.restart_policy = Some(RestartPolicy {
885 mode: RestartMode::Always,
886 max_restarts: Some(3),
887 restart_delay_ms: 0,
888 });
889 proc.restart_count = 3;
890 pm.processes.insert("tired".into(), proc);
891
892 let (restartable, exhausted) = pm.classify_restart_candidates();
893 assert!(restartable.is_empty());
894 assert_eq!(exhausted, vec!["tired"]);
895 }
896
897 #[test]
898 fn test_classify_always_mode_clean_exit() {
899 let mut pm = ProcessManager::new("test-classify-always-clean");
900 let mut proc = make_exited_process("always-on", Some(0));
901 proc.restart_policy = Some(RestartPolicy {
902 mode: RestartMode::Always,
903 max_restarts: None,
904 restart_delay_ms: 0,
905 });
906 pm.processes.insert("always-on".into(), proc);
907
908 let (restartable, exhausted) = pm.classify_restart_candidates();
909 assert_eq!(restartable, vec!["always-on"]);
910 assert!(exhausted.is_empty());
911 }
912}