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