1use super::Supervisor;
6use crate::Result;
7use crate::daemon::Daemon;
8use crate::daemon::RunOptions;
9use crate::daemon_id::DaemonId;
10use crate::daemon_status::DaemonStatus;
11use crate::pitchfork_toml::CpuLimit;
12use crate::pitchfork_toml::CronRetrigger;
13use crate::pitchfork_toml::HealthCmd;
14use crate::pitchfork_toml::HealthHttp;
15use crate::pitchfork_toml::HealthPort;
16use crate::pitchfork_toml::MemoryLimit;
17use crate::pitchfork_toml::PitchforkToml;
18use crate::pitchfork_toml::PortConfig;
19use crate::pitchfork_toml::ReadyCmd;
20use crate::pitchfork_toml::ReadyHttp;
21use crate::pitchfork_toml::ReadyOutput;
22use crate::pitchfork_toml::ReadyPort;
23use crate::pitchfork_toml::Retry;
24use crate::pitchfork_toml::StopConfig;
25use crate::pitchfork_toml::WatchMode;
26use crate::procs::PROCS;
27use crate::state_file::DiskRecord;
28use indexmap::IndexMap;
29use std::collections::HashMap;
30use std::path::PathBuf;
31
32fn should_clean_daemon(
33 id: &DaemonId,
34 daemon: &Daemon,
35 namespaces: &[String],
36 daemons: &[DaemonId],
37) -> bool {
38 let namespace_matches =
39 namespaces.is_empty() || namespaces.iter().any(|ns| ns == id.namespace());
40 let daemon_matches = daemons.is_empty() || daemons.contains(id);
41 daemon.pid.is_none() && namespace_matches && daemon_matches
42}
43
44fn prune_candidate(
45 id: &DaemonId,
46 daemon: &Daemon,
47 namespaces: &[String],
48 daemons: &[DaemonId],
49) -> Option<(DaemonId, PathBuf)> {
50 should_clean_daemon(id, daemon, namespaces, daemons)
51 .then(|| daemon.dir.clone())
52 .flatten()
53 .map(|dir| (id.clone(), dir))
54}
55
56#[derive(Debug, Default)]
60pub(crate) struct UpsertDaemonOpts {
61 pub id: DaemonId,
62 pub pid: Option<u32>,
63 pub status: DaemonStatus,
64 pub shell_pid: Option<u32>,
65 pub dir: Option<PathBuf>,
66 pub cmd: Option<Vec<String>>,
67 pub run: Option<String>,
68 pub no_shell: Option<bool>,
71 pub scheduled_from_config: Option<bool>,
74 pub autostop: Option<bool>,
78 pub oneshot: Option<bool>,
82 pub cron_schedule: Option<Option<String>>,
87 pub cron_retrigger: Option<CronRetrigger>,
88 pub cron_immediate: Option<bool>,
89 pub last_exit_success: Option<bool>,
90 pub retry: Option<Retry>,
91 pub retry_count: Option<u32>,
92 pub ready_delay: Option<u64>,
93 pub ready_output: Option<ReadyOutput>,
94 pub ready_http: Option<ReadyHttp>,
95 pub ready_port: Option<ReadyPort>,
96 pub configured_ready_port: Option<ReadyPort>,
98 pub ready_cmd: Option<ReadyCmd>,
99 pub replaces_ready_checks: bool,
102 pub health_cmd: Option<HealthCmd>,
103 pub health_http: Option<HealthHttp>,
104 pub health_port: Option<HealthPort>,
105 pub port: Option<PortConfig>,
107 pub resolved_port: Option<Vec<u16>>,
111 pub active_port: Option<u16>,
113 pub slug: Option<String>,
115 pub proxy: Option<bool>,
117 pub depends: Option<Vec<DaemonId>>,
118 pub env: Option<IndexMap<String, String>>,
119 pub watch: Option<Vec<String>>,
120 pub watch_mode: Option<WatchMode>,
121 pub watch_base_dir: Option<Option<PathBuf>>,
126 pub mise: Option<bool>,
127 pub deferred_template_context: Option<Option<String>>,
130 pub mise_bin: Option<Option<PathBuf>>,
134 pub user: Option<String>,
136 pub memory_limit: Option<MemoryLimit>,
138 pub cpu_limit: Option<CpuLimit>,
140 pub stop_signal: Option<StopConfig>,
142 pub archive_hook: Option<String>,
144 pub log_format: Option<String>,
146 pub pty: Option<bool>,
148 pub config_registered: bool,
150 pub replaces_config: bool,
155 pub proxy_idle_timeout_ms: Option<Option<u64>>,
159}
160
161#[derive(Debug)]
173pub(crate) struct UpsertDaemonOptsBuilder {
174 pub opts: UpsertDaemonOpts,
175}
176
177impl UpsertDaemonOpts {
178 pub fn builder(id: DaemonId) -> UpsertDaemonOptsBuilder {
180 UpsertDaemonOptsBuilder {
181 opts: UpsertDaemonOpts {
182 id,
183 ..Default::default()
184 },
185 }
186 }
187
188 pub(crate) fn from_run_options(
192 opts: &RunOptions,
193 status: DaemonStatus,
194 ) -> UpsertDaemonOptsBuilder {
195 UpsertDaemonOpts::builder(opts.id.clone()).set(|o| {
196 o.replaces_config = opts.replaces_saved_record;
197 o.status = status;
198 o.shell_pid = opts.shell_pid;
199 o.dir = Some(opts.dir.0.clone());
200 o.cmd = Some(opts.cmd.clone());
201 o.run = opts.run.clone();
202 o.no_shell = Some(opts.no_shell);
203 o.scheduled_from_config = opts.requested_by_client.then_some(false);
206 o.autostop = Some(opts.autostop);
207 o.oneshot = Some(opts.oneshot);
208 o.cron_schedule = Some(opts.cron_schedule.clone());
209 o.cron_retrigger = opts.cron_retrigger;
210 o.cron_immediate = opts.cron_immediate;
211 o.retry = Some(opts.retry);
212 o.retry_count = Some(opts.retry_count);
213 o.ready_delay = opts.ready_delay;
214 o.ready_output = opts.ready_output.clone();
215 o.ready_http = opts.ready_http.clone();
216 o.ready_port = opts.ready_port.clone();
217 o.configured_ready_port = opts.ready_port.clone();
218 o.ready_cmd = opts.ready_cmd.clone();
219 o.replaces_ready_checks = opts.replaces_ready_checks;
220 o.health_cmd = opts.health_cmd.clone();
221 o.health_http = opts.health_http.clone();
222 o.health_port = opts.health_port.clone();
223 o.port = opts.port.clone();
224 o.depends = Some(opts.depends.clone());
225 o.env = opts.env.clone();
226 o.watch = Some(opts.watch.clone());
227 o.watch_mode = Some(opts.watch_mode);
228 o.watch_base_dir = Some(opts.watch_base_dir.clone());
229 o.mise = opts.mise;
230 o.deferred_template_context = Some(opts.deferred_template_context.clone());
231 o.mise_bin = Some(opts.mise_bin.clone());
232 o.user = opts.user.clone();
233 o.memory_limit = opts.memory_limit;
234 o.cpu_limit = opts.cpu_limit;
235 o.stop_signal = opts.stop_signal;
236 o.pty = opts.pty;
237 o.archive_hook = opts.archive_hook.clone();
238 o.log_format = opts.log_format.clone();
239 o.proxy_idle_timeout_ms = Some(opts.proxy_idle_timeout_ms);
240 })
241 }
242}
243
244impl UpsertDaemonOptsBuilder {
245 pub fn set<F: FnOnce(&mut UpsertDaemonOpts)>(mut self, f: F) -> Self {
247 f(&mut self.opts);
248 self
249 }
250
251 pub fn build(self) -> UpsertDaemonOpts {
253 self.opts
254 }
255}
256
257impl Supervisor {
258 pub(crate) async fn restore_own_record(&self) {
268 if self
269 .shutting_down
270 .load(std::sync::atomic::Ordering::Acquire)
271 {
272 return;
273 }
274 let pitchfork_id = DaemonId::pitchfork();
275 let state = self.state_file.lock().await;
276 if self
280 .shutting_down
281 .load(std::sync::atomic::Ordering::Acquire)
282 {
283 return;
284 }
285 let Some(own) = state
286 .daemons
287 .get(&pitchfork_id)
288 .filter(|d| d.pid.is_some())
289 .cloned()
290 else {
291 return;
292 };
293 let own_pid = own.pid.unwrap_or_default();
294 let path = state.path.clone();
295 let result = tokio::task::spawn_blocking(move || {
298 crate::state_file::StateFile::restore_daemon_in_file(&path, &own)
299 })
300 .await;
301 match result {
302 Ok(Ok(DiskRecord::Present)) => {}
303 Ok(Ok(DiskRecord::Restored)) => {
304 if state.is_dirty() {
307 debug!("recorded this supervisor in the state file ahead of the next flush");
308 } else {
309 warn!(
310 "state file {} no longer recorded this supervisor (pid {own_pid}); restored it",
311 state.path.display()
312 );
313 }
314 state.forget_written_snapshot();
317 }
318 Ok(Ok(DiskRecord::Unparseable)) => {
319 warn!(
320 "state file {} cannot be parsed; rewriting it from the supervisor's state",
321 state.path.display()
322 );
323 state.force_next_write();
324 }
325 Ok(Err(e)) => warn!("failed to restore the supervisor record in the state file: {e}"),
326 Err(e) => warn!("failed to restore the supervisor record in the state file: {e}"),
327 }
328 }
329
330 pub(crate) async fn run_options_from_config(
337 &self,
338 id: &DaemonId,
339 config: &crate::pitchfork_toml::PitchforkTomlDaemon,
340 pt: &PitchforkToml,
341 ) -> Result<RunOptions> {
342 let resolved_daemons: std::collections::HashMap<DaemonId, Vec<u16>> = {
343 let state = self.state_file.lock().await;
344 state
345 .daemons
346 .iter()
347 .filter(|(_, d)| !d.resolved_port.is_empty())
348 .map(|(id, d)| (id.clone(), d.resolved_port.clone()))
349 .collect()
350 };
351 let id = id.clone();
355 let mut config = config.clone();
356 let pt = pt.clone();
357 tokio::task::spawn_blocking(move || {
358 crate::ipc::batch::render_daemon_config_with(&id, &mut config, &pt, &resolved_daemons)?;
359 let cmd = config
360 .run
361 .argv()
362 .map_err(|e| miette::miette!("failed to parse command for daemon {id}: {e}"))?;
363 let mut opts = config.to_run_options(&id, cmd);
364 let (mise, mise_bin) = crate::ipc::batch::project_mise_options(&config);
368 opts.mise = Some(mise);
369 opts.mise_bin = mise_bin;
370 Ok(opts)
371 })
372 .await
373 .map_err(|e| miette::miette!("rendering daemon config panicked: {e}"))?
374 }
375
376 pub(crate) async fn upsert_daemon(&self, opts: UpsertDaemonOpts) -> Result<Daemon> {
378 info!(
379 "upserting daemon: {} pid: {} status: {}",
380 opts.id,
381 opts.pid.unwrap_or(0),
382 opts.status
383 );
384 let mut state_file = self.state_file.lock().await;
385 let existing = state_file.daemons.get(&opts.id);
386 let configured = existing.filter(|_| !opts.replaces_config);
389 let ready_base = configured.filter(|_| !opts.replaces_ready_checks);
392 let daemon = Daemon {
393 id: opts.id.clone(),
394 title: opts.pid.and_then(|pid| {
400 PROCS.title(pid).or_else(|| {
401 existing
402 .filter(|d| d.pid == Some(pid))
403 .and_then(|d| d.title.clone())
404 })
405 }),
406 start_time: opts.pid.and_then(|pid| {
407 PROCS.start_time(pid).or_else(|| {
408 existing
409 .filter(|d| d.pid == Some(pid))
410 .and_then(|d| d.start_time)
411 })
412 }),
413 boot_time: opts.pid.map(|_| PROCS.boot_time()),
417 pid: opts.pid,
418 status: opts.status,
419 shell_pid: opts.shell_pid,
420 autostop: opts
421 .autostop
422 .unwrap_or_else(|| existing.is_some_and(|d| d.autostop)),
423 oneshot: opts
428 .oneshot
429 .unwrap_or_else(|| existing.is_some_and(|d| d.oneshot)),
430 dir: opts.dir.or(configured.and_then(|d| d.dir.clone())),
431 cmd: opts.cmd.or(configured.and_then(|d| d.cmd.clone())),
432 run: if opts.no_shell == Some(true) {
435 None
436 } else {
437 opts.run.or(configured.and_then(|d| d.run.clone()))
438 },
439 no_shell: opts
440 .no_shell
441 .unwrap_or_else(|| existing.is_some_and(|d| d.no_shell)),
442 scheduled_from_config: opts
443 .scheduled_from_config
444 .unwrap_or_else(|| existing.is_some_and(|d| d.scheduled_from_config)),
445 cron_schedule: opts
446 .cron_schedule
447 .unwrap_or_else(|| configured.and_then(|d| d.cron_schedule.clone())),
448 cron_retrigger: opts
449 .cron_retrigger
450 .or(configured.and_then(|d| d.cron_retrigger)),
451 cron_immediate: opts
452 .cron_immediate
453 .or(configured.and_then(|d| d.cron_immediate)),
454 last_cron_triggered: existing.and_then(|d| d.last_cron_triggered),
455 last_cron_run: existing.and_then(|d| d.last_cron_run),
456 last_exit_success: opts
457 .last_exit_success
458 .or(existing.and_then(|d| d.last_exit_success)),
459 retry: opts
460 .retry
461 .unwrap_or_else(|| existing.map(|d| d.retry).unwrap_or_default()),
462 retry_count: opts
463 .retry_count
464 .unwrap_or(existing.map(|d| d.retry_count).unwrap_or(0)),
465 ready_delay: opts.ready_delay.or(ready_base.and_then(|d| d.ready_delay)),
466 ready_output: opts
467 .ready_output
468 .or(ready_base.and_then(|d| d.ready_output.clone())),
469 ready_http: opts
470 .ready_http
471 .or(ready_base.and_then(|d| d.ready_http.clone())),
472 ready_port: opts
473 .ready_port
474 .or(ready_base.and_then(|d| d.ready_port.clone())),
475 configured_ready_port: opts
476 .configured_ready_port
477 .or(ready_base.and_then(|d| d.configured_ready_port.clone())),
478 ready_cmd: opts
479 .ready_cmd
480 .or(ready_base.and_then(|d| d.ready_cmd.clone())),
481 health_cmd: opts
482 .health_cmd
483 .or(configured.and_then(|d| d.health_cmd.clone())),
484 health_http: opts
485 .health_http
486 .or(configured.and_then(|d| d.health_http.clone())),
487 health_port: opts
488 .health_port
489 .or(configured.and_then(|d| d.health_port.clone())),
490 port: opts
491 .port
492 .or_else(|| configured.and_then(|d| d.port.clone())),
493 resolved_port: match opts.resolved_port {
494 Some(ports) => ports,
495 None => existing
496 .map(|d| d.resolved_port.clone())
497 .unwrap_or_default(),
498 },
499 depends: opts
500 .depends
501 .unwrap_or_else(|| existing.map(|d| d.depends.clone()).unwrap_or_default()),
502 env: opts.env.or(configured.and_then(|d| d.env.clone())),
503 watch: opts
504 .watch
505 .unwrap_or_else(|| existing.map(|d| d.watch.clone()).unwrap_or_default()),
506 watch_mode: opts
507 .watch_mode
508 .unwrap_or_else(|| existing.map(|d| d.watch_mode).unwrap_or_default()),
509 watch_base_dir: opts
510 .watch_base_dir
511 .unwrap_or_else(|| configured.and_then(|d| d.watch_base_dir.clone())),
512 mise: opts.mise.or(configured.and_then(|d| d.mise)),
513 deferred_template_context: opts
514 .deferred_template_context
515 .unwrap_or_else(|| existing.and_then(|d| d.deferred_template_context.clone())),
516 mise_bin: opts
517 .mise_bin
518 .unwrap_or_else(|| configured.and_then(|d| d.mise_bin.clone())),
519 user: opts.user.or(configured.and_then(|d| d.user.clone())),
520 proxy: opts.proxy.or(existing.and_then(|d| d.proxy)),
521 active_port: opts.active_port,
527 slug: opts.slug.or(existing.and_then(|d| d.slug.clone())),
528 memory_limit: opts
529 .memory_limit
530 .or(configured.and_then(|d| d.memory_limit)),
531 cpu_limit: opts.cpu_limit.or(configured.and_then(|d| d.cpu_limit)),
532 stop_signal: opts.stop_signal.or(configured.and_then(|d| d.stop_signal)),
533 archive_hook: opts
534 .archive_hook
535 .or(configured.and_then(|d| d.archive_hook.clone())),
536 log_format: opts
537 .log_format
538 .or(configured.and_then(|d| d.log_format.clone())),
539 pty: opts.pty.or(configured.and_then(|d| d.pty)),
540 config_registered: opts.config_registered,
541 proxy_idle_timeout_ms: opts
542 .proxy_idle_timeout_ms
543 .unwrap_or_else(|| existing.and_then(|d| d.proxy_idle_timeout_ms)),
544 };
545 state_file.insert_daemon(&opts.id, daemon.clone());
546 Ok(daemon)
547 }
548
549 pub async fn enable(&self, id: &DaemonId) -> Result<bool> {
551 info!("enabling daemon: {id}");
552 let config = PitchforkToml::all_merged_all_namespaces().await?;
553 let mut state_file = self.state_file.lock().await;
554 let exists = state_file.daemons.contains_key(id) || config.daemons.contains_key(id);
555 if !exists {
556 return Err(miette::miette!("daemon '{}' not found", id));
557 }
558 let result = state_file.enable_daemon(id);
559 Ok(result)
560 }
561
562 pub async fn disable(&self, id: &DaemonId) -> Result<bool> {
564 info!("disabling daemon: {id}");
565 let config = PitchforkToml::all_merged_all_namespaces().await?;
566 let not_found = || miette::miette!("daemon '{}' not found", id);
567 if !config.daemons.contains_key(id)
570 && !self.state_file.lock().await.daemons.contains_key(id)
571 {
572 return Err(not_found());
573 }
574 let lock = self.stop_lock(id).await;
577 let _guard = lock.lock().await;
578 let mut state_file = self.state_file.lock().await;
579 let exists = state_file.daemons.contains_key(id) || config.daemons.contains_key(id);
580 if !exists {
581 return Err(not_found());
582 }
583 let result = state_file.disable_daemon(id);
584 self.cancel_retrying(id);
587 Ok(result)
588 }
589
590 pub(crate) async fn get_daemon(&self, id: &DaemonId) -> Option<Daemon> {
592 self.state_file.lock().await.daemons.get(id).cloned()
593 }
594
595 pub(crate) async fn active_daemons(&self) -> Vec<Daemon> {
597 let pitchfork_id = DaemonId::pitchfork();
598 self.state_file
599 .lock()
600 .await
601 .daemons
602 .values()
603 .filter(|d| d.pid.is_some() && d.id != pitchfork_id)
604 .cloned()
605 .collect()
606 }
607
608 pub(crate) async fn remove_daemon(&self, id: &DaemonId) -> Result<()> {
610 let mut state_file = self.state_file.lock().await;
611 state_file.remove_daemon(id);
612 Ok(())
613 }
614
615 pub(crate) async fn set_shell_dir(&self, shell_pid: u32, dir: PathBuf) -> Result<()> {
617 let mut state_file = self.state_file.lock().await;
618 state_file.set_shell_dir(shell_pid, dir);
619 Ok(())
620 }
621
622 pub(crate) async fn get_shell_dir(&self, shell_pid: u32) -> Option<PathBuf> {
624 self.state_file
625 .lock()
626 .await
627 .shell_dirs
628 .get(&shell_pid.to_string())
629 .cloned()
630 }
631
632 #[cfg(unix)]
634 pub(crate) async fn remove_shell_pid(&self, shell_pid: u32) -> Result<()> {
635 let mut state_file = self.state_file.lock().await;
636 state_file.remove_shell_dir(shell_pid);
637 Ok(())
638 }
639
640 pub(crate) async fn get_dirs_with_shell_pids(&self) -> HashMap<PathBuf, Vec<u32>> {
642 self.state_file.lock().await.shell_dirs.iter().fold(
643 HashMap::new(),
644 |mut acc, (pid, dir)| {
645 if let Ok(pid) = pid.parse() {
646 acc.entry(dir.clone()).or_default().push(pid);
647 }
648 acc
649 },
650 )
651 }
652
653 pub(crate) async fn get_notifications(&self) -> Vec<(log::LevelFilter, String)> {
655 self.pending_notifications.lock().await.drain(..).collect()
656 }
657
658 pub(crate) async fn clean(&self) -> Result<()> {
660 self.clean_filtered(&[], &[], false).await?;
661 Ok(())
662 }
663
664 pub(crate) async fn clean_filtered(
666 &self,
667 namespaces: &[String],
668 daemons: &[DaemonId],
669 prune: bool,
670 ) -> Result<u64> {
671 if prune {
672 let candidates: Vec<(DaemonId, PathBuf)> = {
673 let state_file = self.state_file.lock().await;
674 state_file
675 .daemons
676 .iter()
677 .filter_map(|(id, daemon)| prune_candidate(id, daemon, namespaces, daemons))
678 .collect()
679 };
680
681 let mut missing = HashMap::new();
682 for (id, dir) in candidates {
683 match tokio::fs::try_exists(&dir).await {
684 Ok(false) => {
685 missing.insert(id, dir);
686 }
687 Ok(true) => {}
688 Err(e) => warn!("cannot check {} to prune {id}: {e}", dir.display()),
690 }
691 }
692
693 let mut removed = 0;
694 let mut state_file = self.state_file.lock().await;
695 state_file.retain_daemons(|id, daemon| {
696 let remove = should_clean_daemon(id, daemon, namespaces, daemons)
697 && missing
698 .get(id)
699 .is_some_and(|missing_dir| daemon.dir.as_ref() == Some(missing_dir));
700 removed += u64::from(remove);
701 !remove
702 });
703 return Ok(removed);
704 }
705
706 let mut removed = 0;
707 let mut state_file = self.state_file.lock().await;
708 state_file.retain_daemons(|id, daemon| {
709 let remove = should_clean_daemon(id, daemon, namespaces, daemons);
710 removed += u64::from(remove);
711 !remove
712 });
713 Ok(removed)
714 }
715
716 pub(crate) async fn get_active_directories(&self) -> Vec<PathBuf> {
720 self.state_file.lock().await.active_directories()
721 }
722
723 pub(crate) async fn get_liveness_sessions(&self) -> Vec<(u32, PathBuf, Option<String>)> {
727 self.state_file
728 .lock()
729 .await
730 .iter_project_sessions()
731 .into_iter()
732 .filter_map(|(pid_str, dir, session)| {
733 pid_str
734 .parse()
735 .ok()
736 .map(|pid| (pid, dir.clone(), session.liveness_title.clone()))
737 })
738 .collect()
739 }
740
741 pub(crate) async fn get_project_sessions_info(&self) -> Vec<crate::ipc::ProjectSessionInfo> {
745 let sessions: Vec<(u32, PathBuf, Option<String>)> = self
746 .state_file
747 .lock()
748 .await
749 .iter_project_sessions()
750 .into_iter()
751 .filter_map(|(pid_str, dir, session)| {
752 pid_str
753 .parse()
754 .ok()
755 .map(|pid| (pid, dir.clone(), session.liveness_title.clone()))
756 })
757 .collect();
758 let pids: Vec<u32> = sessions.iter().map(|(pid, _, _)| *pid).collect();
759 if !pids.is_empty() {
760 PROCS.refresh_pids(&pids);
761 }
762 sessions
763 .into_iter()
764 .map(
765 |(pid, directory, liveness_title)| crate::ipc::ProjectSessionInfo {
766 pid,
767 directory,
768 liveness_title,
769 alive: PROCS.is_running(pid),
770 current_title: PROCS.title(pid),
771 },
772 )
773 .collect()
774 }
775
776 pub(crate) async fn enter_project_session(
780 &self,
781 pid: u32,
782 dir: PathBuf,
783 ) -> Result<Option<crate::state_file::ProjectSession>> {
784 if pid == 0 {
785 return Err(miette::miette!("invalid host PID 0"));
786 }
787 if pid > i32::MAX as u32 {
788 return Err(miette::miette!("host PID {pid} exceeds i32::MAX"));
789 }
790 PROCS.refresh_pids(&[pid]);
791 let liveness_title = PROCS.title(pid);
792 #[cfg(unix)]
798 if !PROCS.is_running(pid) {
799 return Err(miette::miette!("host PID {pid} is not running"));
800 }
801 let mut state_file = self.state_file.lock().await;
802 let previous = state_file.set_project_session(
803 pid,
804 dir,
805 crate::state_file::ProjectSession { liveness_title },
806 );
807 Ok(previous)
808 }
809
810 pub(crate) async fn leave_project_session(
814 &self,
815 pid: u32,
816 dir: &std::path::Path,
817 ) -> Result<Option<PathBuf>> {
818 let mut state_file = self.state_file.lock().await;
819 if state_file.remove_project_session(pid, dir).is_some() {
820 Ok(Some(dir.to_path_buf()))
821 } else {
822 Ok(None)
823 }
824 }
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830
831 fn daemon(id: &DaemonId, pid: Option<u32>, dir: Option<PathBuf>) -> Daemon {
832 Daemon {
833 id: id.clone(),
834 pid,
835 dir,
836 ..Daemon::default()
837 }
838 }
839
840 #[test]
841 fn clean_filters_intersect_and_preserve_running_daemons() {
842 let api = DaemonId::new("project-a", "api");
843 let worker = DaemonId::new("project-a", "worker");
844 let namespaces = vec!["project-a".to_string()];
845 let daemons = vec![api.clone()];
846
847 assert!(should_clean_daemon(
848 &api,
849 &daemon(&api, None, None),
850 &namespaces,
851 &daemons,
852 ));
853 assert!(!should_clean_daemon(
854 &worker,
855 &daemon(&worker, None, None),
856 &namespaces,
857 &daemons,
858 ));
859 assert!(!should_clean_daemon(
860 &api,
861 &daemon(&api, Some(42), None),
862 &namespaces,
863 &daemons,
864 ));
865 }
866
867 #[test]
868 fn prune_candidates_require_a_recorded_directory() {
869 let id = DaemonId::new("project-a", "api");
870 assert!(prune_candidate(&id, &daemon(&id, None, None), &[], &[]).is_none());
871 assert_eq!(
872 prune_candidate(
873 &id,
874 &daemon(&id, None, Some(PathBuf::from("missing"))),
875 &[],
876 &[]
877 ),
878 Some((id, PathBuf::from("missing")))
879 );
880 }
881}