1use std::{
15 collections::{HashMap, HashSet},
16 ffi::OsString,
17 path::{Path, PathBuf},
18 sync::{
19 Arc, Mutex,
20 atomic::{AtomicBool, Ordering},
21 },
22 time::{Duration, SystemTime, UNIX_EPOCH},
23};
24
25use scv_client::Layout;
26use serde::{Deserialize, Serialize};
27use sha2::{Digest, Sha256};
28
29use crate::{process::ProcessGroup, sync::lock};
30
31pub const PARENT_VARIABLE: &str = "SCV_PARENT";
33pub use scv_client::DELEGATION_DEPTH_VARIABLE as DEPTH_VARIABLE;
35pub(crate) const STOP_GRACE: Duration = Duration::from_secs(2);
37const MAX_RECORD_BYTES: u64 = 64 * 1024;
39const ZOMBIE_MIN_AGE: Duration = Duration::from_secs(10);
41
42pub fn current_depth() -> u32 {
48 #[cfg(test)]
49 {
50 0
51 }
52 #[cfg(not(test))]
53 {
54 scv_client::inherited_delegation_depth().unwrap_or(0)
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60pub struct ProcessIdentity {
61 pub pid: u32,
62 pub(crate) start_time: u64,
63}
64
65impl ProcessIdentity {
66 pub fn current() -> Option<Self> {
67 Self::of(std::process::id())
68 }
69
70 pub fn of(pid: u32) -> Option<Self> {
71 process_start_time(pid).map(|start_time| Self { pid, start_time })
72 }
73
74 pub fn is_alive(&self) -> bool {
77 #[cfg(target_os = "linux")]
78 {
79 linux::stat(self.pid)
80 .is_some_and(|info| info.start_time == self.start_time && info.state != 'Z')
81 }
82 #[cfg(not(target_os = "linux"))]
83 {
84 Self::of(self.pid) == Some(*self) && !process_is_zombie(self.pid)
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct DelegationRecord {
92 pub handle: String,
93 pub agent: String,
94 pub instance: String,
95 pub session: String,
96 pub owner: ProcessIdentity,
97 pub process: ProcessIdentity,
99 pub pgid: u32,
100 pub cwd: PathBuf,
101 pub started_unix: u64,
102 pub depth: u32,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub conversation: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub turn: Option<u32>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub idle_since_unix: Option<u64>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub background_jobs: Option<u32>,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct DelegationEntry {
125 pub record: DelegationRecord,
126 pub orphaned: bool,
128 pub processes: usize,
130}
131
132impl DelegationEntry {
133 pub fn working(&self) -> bool {
137 self.processes > 0
138 && (self.record.idle_since_unix.is_none()
139 || self.record.background_jobs.is_some_and(|jobs| jobs > 0))
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct ChainRun {
147 pub handle: String,
149 pub session: String,
151}
152
153#[derive(Debug, Clone, Default, PartialEq, Eq)]
155pub struct ReconcileReport {
156 pub reaped: Vec<String>,
158 pub removed: usize,
160 pub stale_markers: usize,
162}
163
164#[derive(Debug, Default)]
165struct Inner {
166 active: HashMap<String, Arc<AtomicBool>>,
167 reaped: u64,
168}
169
170#[derive(Debug)]
172pub struct DelegationRegistry {
173 record_dir: PathBuf,
174 conversation_dir: PathBuf,
175 runs_dir: PathBuf,
177 instance: String,
178 owner: Option<ProcessIdentity>,
179 depth: u32,
180 chain: Option<String>,
181 inner: Mutex<Inner>,
182}
183
184pub(crate) struct PendingDelegation {
186 pub(crate) handle: String,
187 pub(crate) environment: Vec<(OsString, OsString)>,
188 agent: String,
189 session: String,
190 cwd: PathBuf,
191 conversation: Option<(String, u32)>,
192 depth: u32,
194}
195
196impl DelegationRegistry {
197 pub fn new(layout: &Layout) -> Self {
201 let digest = Sha256::digest(layout.home().as_os_str().as_encoded_bytes());
202 let instance = digest[..4]
203 .iter()
204 .map(|byte| format!("{byte:02x}"))
205 .collect();
206 Self {
207 record_dir: layout.delegations(),
208 conversation_dir: layout.conversations(),
209 runs_dir: layout.runs(),
210 instance,
211 owner: ProcessIdentity::current(),
212 depth: current_depth(),
213 chain: std::env::var(PARENT_VARIABLE)
214 .ok()
215 .filter(|value| !value.trim().is_empty()),
216 inner: Mutex::new(Inner::default()),
217 }
218 }
219
220 #[cfg(test)]
223 pub(crate) fn for_test(layout: &Layout) -> Self {
224 Self {
225 depth: 0,
226 chain: None,
227 ..Self::new(layout)
228 }
229 }
230
231 pub(crate) fn depth(&self) -> u32 {
233 self.depth
234 }
235
236 pub fn record_dir(&self) -> &Path {
237 &self.record_dir
238 }
239
240 pub fn instance(&self) -> &str {
242 &self.instance
243 }
244
245 pub fn reaped_total(&self) -> u64 {
247 lock(&self.inner).reaped
248 }
249
250 pub fn conversation_dir(&self) -> &Path {
252 &self.conversation_dir
253 }
254
255 pub fn runs_dir(&self) -> &Path {
257 &self.runs_dir
258 }
259
260 pub(crate) fn open_activity(
263 &self,
264 header: crate::delegate::activity::RunHeader,
265 ) -> Option<crate::delegate::activity::RunActivity> {
266 let owner = self.owner?;
267 crate::delegate::activity::RunActivity::open(&self.runs_dir, owner, header)
268 }
269
270 #[cfg(test)]
271 pub(crate) fn begin(
272 &self,
273 agent: &str,
274 session: &str,
275 cwd: &Path,
276 conversation: Option<(&str, u32)>,
277 ) -> PendingDelegation {
278 self.begin_at(self.depth, agent, session, cwd, conversation)
279 }
280
281 pub(crate) fn begin_at(
284 &self,
285 owner_depth: u32,
286 agent: &str,
287 session: &str,
288 cwd: &Path,
289 conversation: Option<(&str, u32)>,
290 ) -> PendingDelegation {
291 let draw = || {
292 let suffix = uuid::Uuid::new_v4().simple().to_string();
293 format!("{agent}-{}", &suffix[..6])
294 };
295 let handle = std::iter::repeat_with(draw)
297 .take(16)
298 .find(|handle| {
299 std::fs::symlink_metadata(self.runs_dir.join(format!("{handle}.jsonl"))).is_err()
300 })
301 .unwrap_or_else(draw);
302 let entry = format!("{}/{session}/{handle}", self.instance);
303 let chain = match &self.chain {
304 Some(chain) => format!("{chain};{entry}"),
305 None => entry,
306 };
307 PendingDelegation {
308 environment: vec![
309 (PARENT_VARIABLE.into(), chain.into()),
310 (
311 DEPTH_VARIABLE.into(),
312 owner_depth.saturating_add(1).to_string().into(),
313 ),
314 ],
315 handle,
316 agent: agent.to_owned(),
317 session: session.to_owned(),
318 cwd: cwd.to_owned(),
319 conversation: conversation.map(|(handle, turn)| (handle.to_owned(), turn)),
320 depth: owner_depth.saturating_add(1),
321 }
322 }
323
324 pub(crate) fn register(
327 self: &Arc<Self>,
328 pending: PendingDelegation,
329 pid: u32,
330 ) -> std::io::Result<DelegationGuard> {
331 let killed = Arc::new(AtomicBool::new(false));
332 let record = DelegationRecord {
333 handle: pending.handle.clone(),
334 agent: pending.agent,
335 instance: self.instance.clone(),
336 session: pending.session,
337 owner: self.owner.unwrap_or(ProcessIdentity {
338 pid: std::process::id(),
339 start_time: 0,
340 }),
341 process: ProcessIdentity::of(pid).unwrap_or(ProcessIdentity { pid, start_time: 0 }),
342 pgid: pid,
343 cwd: pending.cwd,
344 started_unix: unix_now(),
345 depth: pending.depth,
346 conversation: pending
347 .conversation
348 .as_ref()
349 .map(|(handle, _)| handle.clone()),
350 turn: pending.conversation.as_ref().map(|(_, turn)| *turn),
351 idle_since_unix: None,
352 background_jobs: None,
353 };
354 lock(&self.inner)
355 .active
356 .insert(record.handle.clone(), Arc::clone(&killed));
357 if let Err(error) = write_record(&self.record_dir, &record) {
358 lock(&self.inner).active.remove(&record.handle);
359 return Err(error);
360 }
361 Ok(DelegationGuard {
362 registry: Arc::clone(self),
363 handle: record.handle,
364 pgid: pid,
365 killed,
366 finished: false,
367 })
368 }
369
370 pub fn list(&self, include_orphans: bool) -> Vec<DelegationEntry> {
373 let table = ProcessTable::snapshot();
374 let mut entries: Vec<_> = self
375 .records()
376 .into_iter()
377 .filter_map(|record| {
378 let orphaned = !self.owner_alive(&record);
379 if orphaned && !include_orphans {
380 return None;
381 }
382 let processes = table.members(&record).len();
383 Some(DelegationEntry {
384 record,
385 orphaned,
386 processes,
387 })
388 })
389 .collect();
390 entries.sort_by(|a, b| {
391 a.record
392 .started_unix
393 .cmp(&b.record.started_unix)
394 .then_with(|| a.record.handle.cmp(&b.record.handle))
395 });
396 entries
397 }
398
399 pub fn own_run(&self, chain: &str) -> Option<ChainRun> {
404 let own = std::process::id();
405 let entries = self.list(true);
406 chain.split(';').find_map(|entry| {
407 let mut parts = entry.splitn(3, '/');
408 let (instance, session, handle) = (parts.next()?, parts.next()?, parts.next()?);
409 if instance != self.instance {
410 return None;
411 }
412 entries
413 .iter()
414 .find(|running| running.record.handle == handle && running.record.owner.pid == own)
415 .map(|_| ChainRun {
416 handle: handle.to_owned(),
417 session: session.to_owned(),
418 })
419 })
420 }
421
422 pub async fn kill(&self, handle: &str) -> Result<(), String> {
424 let record = self
425 .records()
426 .into_iter()
427 .find(|record| record.handle == handle)
428 .ok_or_else(|| format!("no running delegation {handle:?}"))?;
429 let local = lock(&self.inner).active.get(handle).cloned();
430 if let Some(killed) = &local {
431 killed.store(true, Ordering::Release);
432 }
433 stop_delegation(&record).await;
434 if local.is_none() && !self.owner_alive(&record) {
435 remove_record(&self.record_dir, handle);
436 lock(&self.inner).reaped += 1;
437 }
438 Ok(())
439 }
440
441 pub async fn reconcile(&self) -> ReconcileReport {
443 let mut report = ReconcileReport::default();
444 for record in self.records() {
445 if self.owner_alive(&record) {
446 continue;
447 }
448 if stop_delegation(&record).await {
449 report.reaped.push(record.handle.clone());
450 } else {
451 report.removed += 1;
452 }
453 remove_record(&self.record_dir, &record.handle);
454 }
455 lock(&self.inner).reaped += report.reaped.len() as u64;
456 report.stale_markers =
457 crate::delegate::conversation::remove_stale_markers(self.conversation_dir());
458 report
459 }
460
461 fn owner_alive(&self, record: &DelegationRecord) -> bool {
464 if Some(record.owner) == self.owner {
465 return lock(&self.inner).active.contains_key(&record.handle);
466 }
467 record.owner.is_alive()
468 }
469
470 fn records(&self) -> Vec<DelegationRecord> {
471 let Ok(entries) = std::fs::read_dir(&self.record_dir) else {
472 return Vec::new();
473 };
474 entries
475 .filter_map(Result::ok)
476 .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
477 .filter_map(|entry| read_record(&entry.path()))
478 .filter(|record| record.instance == self.instance)
479 .collect()
480 }
481
482 fn finish_local(&self, handle: &str) {
483 lock(&self.inner).active.remove(handle);
484 remove_record(&self.record_dir, handle);
485 }
486}
487
488pub(crate) struct DelegationGuard {
490 registry: Arc<DelegationRegistry>,
491 handle: String,
492 pgid: u32,
493 killed: Arc<AtomicBool>,
494 finished: bool,
495}
496
497impl DelegationGuard {
498 #[cfg(test)]
499 pub(crate) fn handle(&self) -> &str {
500 &self.handle
501 }
502
503 pub(crate) fn set_turn(&self, turn: u32) {
506 self.update(|record| {
507 record.turn = Some(turn);
508 record.idle_since_unix = None;
509 });
510 }
511
512 pub(crate) fn set_idle(&self) {
514 let now = unix_now();
515 self.update(|record| record.idle_since_unix = Some(now));
516 }
517
518 pub(crate) fn set_background_jobs(&self, jobs: usize) {
521 let jobs = (jobs > 0).then(|| u32::try_from(jobs).unwrap_or(u32::MAX));
522 self.update(|record| record.background_jobs = jobs);
523 }
524
525 fn update(&self, change: impl FnOnce(&mut DelegationRecord)) {
528 let dir = &self.registry.record_dir;
529 if let Some(mut record) = read_record(&dir.join(format!("{}.json", self.handle))) {
530 change(&mut record);
531 if let Err(error) = write_record(dir, &record) {
532 tracing::debug!(handle = %record.handle, %error, "could not update a delegation record");
533 }
534 }
535 }
536
537 pub(crate) fn was_killed(&self) -> bool {
539 self.killed.load(Ordering::Acquire)
540 }
541
542 pub(crate) async fn finish(mut self) {
544 self.finished = true;
545 stop_tagged(&self.handle).await;
546 self.registry.finish_local(&self.handle);
547 }
548}
549
550impl Drop for DelegationGuard {
551 fn drop(&mut self) {
552 if self.finished {
553 return;
554 }
555 if let Some(group) = ProcessGroup::new(self.pgid) {
558 group.signal(libc::SIGKILL);
559 }
560 self.registry.finish_local(&self.handle);
561 let handle = self.handle.clone();
562 if let Ok(runtime) = tokio::runtime::Handle::try_current() {
563 runtime.spawn(async move { stop_tagged(&handle).await });
564 } else {
565 for identity in tagged_processes(&handle) {
566 signal(identity.pid, libc::SIGKILL);
567 }
568 }
569 }
570}
571
572async fn stop_delegation(record: &DelegationRecord) -> bool {
575 let mut stopped = false;
576 let leader = ProcessIdentity::of(record.process.pid);
580 let group_is_ours = record.pgid == record.process.pid
581 && match leader {
582 Some(leader) => leader == record.process,
583 None => group_exists(record.pgid),
584 };
585 if group_is_ours && group_exists(record.pgid) {
586 stopped = true;
587 let group = ProcessGroup::new(record.pgid);
588 if let Some(group) = group {
589 group.signal(libc::SIGTERM);
590 }
591 let deadline = tokio::time::Instant::now() + STOP_GRACE;
592 while group_exists(record.pgid) && tokio::time::Instant::now() < deadline {
593 tokio::time::sleep(Duration::from_millis(50)).await;
594 }
595 if let Some(group) = group {
596 group.signal(libc::SIGKILL);
597 }
598 }
599 stopped | stop_tagged(&record.handle).await
600}
601
602async fn stop_tagged(handle: &str) -> bool {
604 let tagged = tagged_processes(handle);
605 if tagged.is_empty() {
606 return false;
607 }
608 for identity in &tagged {
609 signal(identity.pid, libc::SIGTERM);
610 }
611 let deadline = tokio::time::Instant::now() + STOP_GRACE;
612 while tagged.iter().any(ProcessIdentity::is_alive) && tokio::time::Instant::now() < deadline {
613 tokio::time::sleep(Duration::from_millis(50)).await;
614 }
615 for identity in tagged.iter().filter(|identity| identity.is_alive()) {
616 signal(identity.pid, libc::SIGKILL);
617 }
618 true
619}
620
621fn tagged_processes(handle: &str) -> Vec<ProcessIdentity> {
623 let own = std::process::id();
624 ProcessTable::snapshot()
625 .tagged
626 .into_iter()
627 .filter(|(identity, chain)| identity.pid != own && chain_names(chain, handle))
628 .map(|(identity, _)| identity)
629 .collect()
630}
631
632fn chain_names(chain: &str, handle: &str) -> bool {
633 chain
634 .split(';')
635 .any(|entry| entry.rsplit('/').next() == Some(handle))
636}
637
638fn signal(pid: u32, signal: i32) {
639 if let Ok(pid) = i32::try_from(pid)
640 && pid > 0
641 {
642 unsafe {
645 libc::kill(pid, signal);
646 }
647 }
648}
649
650pub(crate) fn group_exists(pgid: u32) -> bool {
652 let Some(group) = ProcessGroup::new(pgid) else {
653 return false;
654 };
655 let signalable = group.is_signalable();
656 #[cfg(target_os = "linux")]
657 {
658 signalable
659 && linux::all_stats()
660 .iter()
661 .any(|info| info.pgid == pgid && info.state != 'Z')
662 }
663 #[cfg(not(target_os = "linux"))]
664 {
665 signalable
666 }
667}
668
669fn unix_now() -> u64 {
670 SystemTime::now()
671 .duration_since(UNIX_EPOCH)
672 .map_or(0, |elapsed| elapsed.as_secs())
673}
674
675fn write_record(dir: &Path, record: &DelegationRecord) -> std::io::Result<()> {
676 write_private_json(dir, &format!("{}.json", record.handle), record)
677}
678
679pub(crate) fn write_private_json(
682 dir: &Path,
683 name: &str,
684 value: &impl Serialize,
685) -> std::io::Result<()> {
686 let bytes = private_json(dir, value)?;
687 scv_client::fs::replace_private(&dir.join(name), &bytes)
688}
689
690pub(crate) fn write_private_json_unsynced(
693 dir: &Path,
694 name: &str,
695 value: &impl Serialize,
696) -> std::io::Result<()> {
697 let bytes = private_json(dir, value)?;
698 scv_client::fs::replace_private_unsynced(&dir.join(name), &bytes)
699}
700
701fn private_json(dir: &Path, value: &impl Serialize) -> std::io::Result<Vec<u8>> {
703 use std::os::unix::fs::PermissionsExt as _;
704 std::fs::create_dir_all(dir)?;
705 if let Some(run) = dir.parent() {
706 std::fs::set_permissions(run, std::fs::Permissions::from_mode(0o700))?;
707 }
708 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
709 serde_json::to_vec_pretty(value).map_err(std::io::Error::other)
710}
711
712fn read_record(path: &Path) -> Option<DelegationRecord> {
713 let bytes = match std::fs::File::open(path).and_then(|file| {
714 let mut bytes = Vec::new();
715 std::io::Read::read_to_end(&mut std::io::Read::take(file, MAX_RECORD_BYTES), &mut bytes)
716 .map(|_| bytes)
717 }) {
718 Ok(bytes) => bytes,
719 Err(error) => {
720 if error.kind() != std::io::ErrorKind::NotFound {
722 tracing::debug!(path = %path.display(), %error, "unreadable delegation record");
723 }
724 return None;
725 }
726 };
727 let record: DelegationRecord = match serde_json::from_slice(&bytes) {
728 Ok(record) => record,
729 Err(error) => {
730 tracing::debug!(path = %path.display(), %error, "malformed delegation record");
731 return None;
732 }
733 };
734 if path.file_stem().and_then(|stem| stem.to_str()) == Some(record.handle.as_str()) {
736 Some(record)
737 } else {
738 tracing::debug!(path = %path.display(), "delegation record named for another handle");
739 None
740 }
741}
742
743fn remove_record(dir: &Path, handle: &str) {
744 let path = dir.join(format!("{handle}.json"));
745 if let Err(error) = std::fs::remove_file(&path)
746 && error.kind() != std::io::ErrorKind::NotFound
747 {
748 tracing::debug!(path = %path.display(), %error, "could not remove a delegation record");
749 }
750}
751
752pub fn become_child_subreaper() -> bool {
755 #[cfg(target_os = "linux")]
756 {
757 unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) == 0 }
760 }
761 #[cfg(not(target_os = "linux"))]
762 {
763 false
764 }
765}
766
767static SPAWNED: Mutex<Option<HashSet<u32>>> = Mutex::new(None);
768
769pub(crate) fn track_spawned(pid: u32) {
771 lock(&SPAWNED).get_or_insert_with(HashSet::new).insert(pid);
772}
773
774pub(crate) fn untrack_spawned(pid: u32) {
775 if let Some(spawned) = lock(&SPAWNED).as_mut() {
776 spawned.remove(&pid);
777 }
778}
779
780pub fn reap_orphaned_zombies() -> usize {
783 #[cfg(target_os = "linux")]
784 {
785 let own = std::process::id();
786 let spawned = lock(&SPAWNED).clone().unwrap_or_default();
787 let uptime = linux::uptime_ticks();
788 let mut reaped = 0;
789 for info in linux::all_stats() {
790 if info.ppid != own || info.state != 'Z' || spawned.contains(&info.pid) {
791 continue;
792 }
793 let old_enough = uptime.is_some_and(|now| {
794 now.saturating_sub(info.start_time)
795 >= ZOMBIE_MIN_AGE.as_secs() * linux::clock_ticks()
796 });
797 if !old_enough {
798 continue;
799 }
800 let mut status = 0;
801 if unsafe { libc::waitpid(info.pid as i32, &raw mut status, libc::WNOHANG) }
804 == info.pid as i32
805 {
806 reaped += 1;
807 }
808 }
809 reaped
810 }
811 #[cfg(not(target_os = "linux"))]
812 {
813 0
814 }
815}
816
817struct ProcessTable {
819 groups: Vec<(ProcessIdentity, u32)>,
820 tagged: Vec<(ProcessIdentity, String)>,
821}
822
823impl ProcessTable {
824 fn members(&self, record: &DelegationRecord) -> HashSet<u32> {
825 let mut members: HashSet<u32> = self
826 .groups
827 .iter()
828 .filter(|(_, pgid)| *pgid == record.pgid)
829 .map(|(identity, _)| identity.pid)
830 .collect();
831 members.extend(
832 self.tagged
833 .iter()
834 .filter(|(_, chain)| chain_names(chain, &record.handle))
835 .map(|(identity, _)| identity.pid),
836 );
837 members
838 }
839
840 #[cfg(target_os = "linux")]
841 fn snapshot() -> Self {
842 let mut groups = Vec::new();
843 let mut tagged = Vec::new();
844 for info in linux::all_stats() {
845 if info.state == 'Z' {
846 continue;
847 }
848 let identity = ProcessIdentity {
849 pid: info.pid,
850 start_time: info.start_time,
851 };
852 groups.push((identity, info.pgid));
853 if let Some(chain) = linux::parent_chain(info.pid) {
854 tagged.push((identity, chain));
855 }
856 }
857 Self { groups, tagged }
858 }
859
860 #[cfg(not(target_os = "linux"))]
861 fn snapshot() -> Self {
862 let mut groups = Vec::new();
863 let mut tagged = Vec::new();
864 let Ok(output) = std::process::Command::new("ps")
866 .args(["-E", "-ww", "-axo", "pid=,pgid=,command="])
867 .output()
868 else {
869 return Self { groups, tagged };
870 };
871 for line in String::from_utf8_lossy(&output.stdout).lines() {
872 let mut fields = line.split_whitespace();
873 let (Some(pid), Some(pgid)) = (
874 fields.next().and_then(|value| value.parse::<u32>().ok()),
875 fields.next().and_then(|value| value.parse::<u32>().ok()),
876 ) else {
877 continue;
878 };
879 let Some(identity) = ProcessIdentity::of(pid) else {
880 continue;
881 };
882 groups.push((identity, pgid));
883 if let Some(chain) = fields.find_map(|field| {
884 field
885 .strip_prefix(PARENT_VARIABLE)
886 .and_then(|rest| rest.strip_prefix('='))
887 }) {
888 tagged.push((identity, chain.to_owned()));
889 }
890 }
891 Self { groups, tagged }
892 }
893}
894
895#[cfg(target_os = "linux")]
896fn process_start_time(pid: u32) -> Option<u64> {
897 linux::stat(pid).map(|info| info.start_time)
898}
899
900#[cfg(target_os = "macos")]
901fn process_start_time(pid: u32) -> Option<u64> {
902 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
905 let size = std::mem::size_of::<libc::proc_bsdinfo>() as i32;
906 let written = unsafe {
909 libc::proc_pidinfo(
910 pid as i32,
911 libc::PROC_PIDTBSDINFO,
912 0,
913 (&mut info as *mut libc::proc_bsdinfo).cast(),
914 size,
915 )
916 };
917 (written == size).then(|| info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec)
918}
919
920#[cfg(not(target_os = "linux"))]
921fn process_is_zombie(pid: u32) -> bool {
922 std::process::Command::new("ps")
923 .args(["-o", "stat=", "-p", &pid.to_string()])
924 .output()
925 .ok()
926 .filter(|output| output.status.success())
927 .is_some_and(|output| {
928 String::from_utf8_lossy(&output.stdout)
929 .trim_start()
930 .starts_with('Z')
931 })
932}
933
934#[cfg(not(any(target_os = "linux", target_os = "macos")))]
935fn process_start_time(pid: u32) -> Option<u64> {
936 let alive = unsafe { libc::kill(pid as i32, 0) } == 0;
939 alive.then_some(0)
940}
941
942#[cfg(target_os = "linux")]
943mod linux {
944 pub(super) struct Stat {
945 pub(crate) pid: u32,
946 pub(crate) ppid: u32,
947 pub(crate) pgid: u32,
948 pub(crate) state: char,
949 pub(crate) start_time: u64,
950 }
951
952 pub(super) fn stat(pid: u32) -> Option<Stat> {
953 let text = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
954 let rest = &text[text.rfind(')')? + 2..];
956 let fields: Vec<&str> = rest.split_whitespace().collect();
957 Some(Stat {
959 pid,
960 state: fields.first()?.chars().next()?,
961 ppid: fields.get(1)?.parse().ok()?,
962 pgid: fields.get(2)?.parse().ok()?,
963 start_time: fields.get(19)?.parse().ok()?,
964 })
965 }
966
967 pub(super) fn all_stats() -> Vec<Stat> {
968 let Ok(entries) = std::fs::read_dir("/proc") else {
969 return Vec::new();
970 };
971 entries
972 .filter_map(Result::ok)
973 .filter_map(|entry| entry.file_name().to_str()?.parse::<u32>().ok())
974 .filter_map(stat)
975 .collect()
976 }
977
978 pub(super) fn parent_chain(pid: u32) -> Option<String> {
980 let environ = std::fs::read(format!("/proc/{pid}/environ")).ok()?;
981 let prefix = format!("{}=", super::PARENT_VARIABLE);
982 environ.split(|byte| *byte == 0).find_map(|entry| {
983 entry
984 .strip_prefix(prefix.as_bytes())
985 .map(|value| String::from_utf8_lossy(value).into_owned())
986 })
987 }
988
989 pub(super) fn clock_ticks() -> u64 {
990 let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
992 u64::try_from(ticks)
993 .ok()
994 .filter(|ticks| *ticks > 0)
995 .unwrap_or(100)
996 }
997
998 pub(super) fn uptime_ticks() -> Option<u64> {
999 let text = std::fs::read_to_string("/proc/uptime").ok()?;
1000 let seconds: f64 = text.split_whitespace().next()?.parse().ok()?;
1001 Some((seconds * clock_ticks() as f64) as u64)
1002 }
1003}
1004
1005#[cfg(test)]
1006mod tests;