Skip to main content

scv_tools/
delegation.rs

1//! What SCV started, so it can list, stop, and clean up delegated agents.
2//!
3//! Every delegated process is tagged through its environment
4//! (`SCV_PARENT=<instance>/<session>/<handle>`, chained through nested SCVs,
5//! and `SCV_DELEGATION_DEPTH`) and recorded in
6//! `$SCV_HOME/run/delegations/<handle>.json` while it runs. A record whose
7//! owning SCV process died is an orphan: the daemon's reconciliation kills its
8//! process group and anything still carrying its tag, then removes it.
9//!
10//! This is cooperative bookkeeping. Delegated agents run as the user, so one
11//! that deliberately clears its environment or leaves its process group can
12//! escape it; the tags and records exist to clean up accidental leaks.
13
14use std::{
15    collections::{HashMap, HashSet},
16    ffi::OsString,
17    io::Write as _,
18    path::{Path, PathBuf},
19    sync::{
20        Arc, Mutex,
21        atomic::{AtomicBool, Ordering},
22    },
23    time::{Duration, SystemTime, UNIX_EPOCH},
24};
25
26use serde::{Deserialize, Serialize};
27use sha2::{Digest, Sha256};
28
29/// Environment variable carrying the delegation chain.
30pub const PARENT_VARIABLE: &str = "SCV_PARENT";
31/// Environment variable carrying how deeply this process is delegated.
32pub const DEPTH_VARIABLE: &str = "SCV_DELEGATION_DEPTH";
33/// Grace between TERM and KILL when stopping delegated processes.
34const STOP_GRACE: Duration = Duration::from_secs(2);
35/// Largest record file read.
36const MAX_RECORD_BYTES: u64 = 64 * 1024;
37/// A zombie child younger than this may still be awaited by its spawner.
38const ZOMBIE_MIN_AGE: Duration = Duration::from_secs(10);
39
40/// Delegation depth of the current process: 0 unless an SCV started it.
41pub fn current_depth() -> u32 {
42    std::env::var(DEPTH_VARIABLE)
43        .ok()
44        .and_then(|value| value.trim().parse().ok())
45        .unwrap_or(0)
46}
47
48/// A process, identified by PID plus start time so a reused PID never matches.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ProcessIdentity {
51    pub pid: u32,
52    pub start_time: u64,
53}
54
55impl ProcessIdentity {
56    pub fn current() -> Option<Self> {
57        Self::of(std::process::id())
58    }
59
60    pub fn of(pid: u32) -> Option<Self> {
61        process_start_time(pid).map(|start_time| Self { pid, start_time })
62    }
63
64    /// Whether this exact process still runs. An exited process that its
65    /// parent has not yet collected (a zombie) does not count.
66    pub fn is_alive(&self) -> bool {
67        #[cfg(target_os = "linux")]
68        {
69            linux::stat(self.pid)
70                .is_some_and(|info| info.start_time == self.start_time && info.state != 'Z')
71        }
72        #[cfg(not(target_os = "linux"))]
73        {
74            Self::of(self.pid) == Some(*self)
75        }
76    }
77}
78
79/// One delegated run, as recorded on disk.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct DelegationRecord {
82    pub handle: String,
83    pub agent: String,
84    pub instance: String,
85    pub session: String,
86    pub owner: ProcessIdentity,
87    /// The agent process, which leads its own process group.
88    pub process: ProcessIdentity,
89    pub pgid: u32,
90    pub cwd: PathBuf,
91    pub started_unix: u64,
92    /// Depth of the delegated process (the owner's depth plus one).
93    pub depth: u32,
94}
95
96/// A record plus what SCV currently observes about it.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct DelegationEntry {
99    pub record: DelegationRecord,
100    /// The owning SCV process is gone; reconciliation will clean it up.
101    pub orphaned: bool,
102    /// Live processes in its group plus tagged processes outside it.
103    pub processes: usize,
104}
105
106/// What a reconciliation pass did.
107#[derive(Debug, Clone, Default, PartialEq, Eq)]
108pub struct ReconcileReport {
109    /// Orphaned delegations whose processes were stopped.
110    pub reaped: Vec<String>,
111    /// Orphaned records whose processes had already exited.
112    pub removed: usize,
113}
114
115#[derive(Debug, Default)]
116struct Inner {
117    active: HashMap<String, Arc<AtomicBool>>,
118    reaped: u64,
119}
120
121/// The delegations one SCV process started, backed by the instance's records.
122#[derive(Debug)]
123pub struct DelegationRegistry {
124    record_dir: PathBuf,
125    instance: String,
126    owner: Option<ProcessIdentity>,
127    depth: u32,
128    chain: Option<String>,
129    inner: Mutex<Inner>,
130}
131
132/// A delegation about to start: its handle and the environment tagging it.
133pub(crate) struct PendingDelegation {
134    pub handle: String,
135    pub environment: Vec<(OsString, OsString)>,
136    agent: String,
137    session: String,
138    cwd: PathBuf,
139}
140
141impl DelegationRegistry {
142    /// The registry for the SCV instance rooted at `instance_home`.
143    pub fn new(instance_home: &Path) -> Self {
144        let digest = Sha256::digest(instance_home.as_os_str().as_encoded_bytes());
145        let instance = digest[..4]
146            .iter()
147            .map(|byte| format!("{byte:02x}"))
148            .collect();
149        Self {
150            record_dir: instance_home.join("run").join("delegations"),
151            instance,
152            owner: ProcessIdentity::current(),
153            depth: current_depth(),
154            chain: std::env::var(PARENT_VARIABLE)
155                .ok()
156                .filter(|value| !value.trim().is_empty()),
157            inner: Mutex::new(Inner::default()),
158        }
159    }
160
161    /// This process's own delegation depth.
162    pub fn depth(&self) -> u32 {
163        self.depth
164    }
165
166    pub fn record_dir(&self) -> &Path {
167        &self.record_dir
168    }
169
170    /// Short identifier of the SCV instance, shared by all its processes.
171    pub fn instance(&self) -> &str {
172        &self.instance
173    }
174
175    /// Delegations this process stopped as orphans since it started.
176    pub fn reaped_total(&self) -> u64 {
177        self.inner.lock().expect("registry lock").reaped
178    }
179
180    pub(crate) fn begin(&self, agent: &str, session: &str, cwd: &Path) -> PendingDelegation {
181        let suffix = uuid::Uuid::new_v4().simple().to_string();
182        let handle = format!("{agent}-{}", &suffix[..6]);
183        let entry = format!("{}/{session}/{handle}", self.instance);
184        let chain = match &self.chain {
185            Some(chain) => format!("{chain};{entry}"),
186            None => entry,
187        };
188        PendingDelegation {
189            environment: vec![
190                (PARENT_VARIABLE.into(), chain.into()),
191                (DEPTH_VARIABLE.into(), (self.depth + 1).to_string().into()),
192            ],
193            handle,
194            agent: agent.to_owned(),
195            session: session.to_owned(),
196            cwd: cwd.to_owned(),
197        }
198    }
199
200    /// Record a spawned delegation. The returned guard removes the record and
201    /// stops leftovers when the run ends, even if the run is abandoned.
202    pub(crate) fn register(
203        self: &Arc<Self>,
204        pending: PendingDelegation,
205        pid: u32,
206    ) -> std::io::Result<DelegationGuard> {
207        let killed = Arc::new(AtomicBool::new(false));
208        let record = DelegationRecord {
209            handle: pending.handle.clone(),
210            agent: pending.agent,
211            instance: self.instance.clone(),
212            session: pending.session,
213            owner: self.owner.unwrap_or(ProcessIdentity {
214                pid: std::process::id(),
215                start_time: 0,
216            }),
217            process: ProcessIdentity::of(pid).unwrap_or(ProcessIdentity { pid, start_time: 0 }),
218            pgid: pid,
219            cwd: pending.cwd,
220            started_unix: SystemTime::now()
221                .duration_since(UNIX_EPOCH)
222                .map_or(0, |elapsed| elapsed.as_secs()),
223            depth: self.depth + 1,
224        };
225        self.inner
226            .lock()
227            .expect("registry lock")
228            .active
229            .insert(record.handle.clone(), Arc::clone(&killed));
230        if let Err(error) = write_record(&self.record_dir, &record) {
231            self.inner
232                .lock()
233                .expect("registry lock")
234                .active
235                .remove(&record.handle);
236            return Err(error);
237        }
238        Ok(DelegationGuard {
239            registry: Arc::clone(self),
240            handle: record.handle,
241            pgid: pid,
242            killed,
243            finished: false,
244        })
245    }
246
247    /// Delegations of this instance that are still running. With
248    /// `include_orphans`, also records whose owner died and await cleanup.
249    pub fn list(&self, include_orphans: bool) -> Vec<DelegationEntry> {
250        let table = ProcessTable::snapshot();
251        let mut entries: Vec<_> = self
252            .records()
253            .into_iter()
254            .filter_map(|record| {
255                let orphaned = !self.owner_alive(&record);
256                if orphaned && !include_orphans {
257                    return None;
258                }
259                let processes = table.members(&record).len();
260                Some(DelegationEntry {
261                    record,
262                    orphaned,
263                    processes,
264                })
265            })
266            .collect();
267        entries.sort_by(|a, b| {
268            a.record
269                .started_unix
270                .cmp(&b.record.started_unix)
271                .then_with(|| a.record.handle.cmp(&b.record.handle))
272        });
273        entries
274    }
275
276    /// Stop one delegation of this instance, whichever process owns it.
277    pub async fn kill(&self, handle: &str) -> Result<(), String> {
278        let record = self
279            .records()
280            .into_iter()
281            .find(|record| record.handle == handle)
282            .ok_or_else(|| format!("no running delegation {handle:?}"))?;
283        let local = self
284            .inner
285            .lock()
286            .expect("registry lock")
287            .active
288            .get(handle)
289            .cloned();
290        if let Some(killed) = &local {
291            killed.store(true, Ordering::Release);
292        }
293        stop_delegation(&record).await;
294        if local.is_none() && !self.owner_alive(&record) {
295            remove_record(&self.record_dir, handle);
296            self.inner.lock().expect("registry lock").reaped += 1;
297        }
298        Ok(())
299    }
300
301    /// Stop and remove every orphaned delegation of this instance.
302    pub async fn reconcile(&self) -> ReconcileReport {
303        let mut report = ReconcileReport::default();
304        for record in self.records() {
305            if self.owner_alive(&record) {
306                continue;
307            }
308            if stop_delegation(&record).await {
309                report.reaped.push(record.handle.clone());
310            } else {
311                report.removed += 1;
312            }
313            remove_record(&self.record_dir, &record.handle);
314        }
315        self.inner.lock().expect("registry lock").reaped += report.reaped.len() as u64;
316        report
317    }
318
319    /// Whether the process that owns `record` still runs it. A record this
320    /// process owns counts only while its run is active here.
321    fn owner_alive(&self, record: &DelegationRecord) -> bool {
322        if Some(record.owner) == self.owner {
323            return self
324                .inner
325                .lock()
326                .expect("registry lock")
327                .active
328                .contains_key(&record.handle);
329        }
330        record.owner.is_alive()
331    }
332
333    fn records(&self) -> Vec<DelegationRecord> {
334        let Ok(entries) = std::fs::read_dir(&self.record_dir) else {
335            return Vec::new();
336        };
337        entries
338            .filter_map(Result::ok)
339            .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
340            .filter_map(|entry| read_record(&entry.path()))
341            .filter(|record| record.instance == self.instance)
342            .collect()
343    }
344
345    fn finish_local(&self, handle: &str) {
346        self.inner
347            .lock()
348            .expect("registry lock")
349            .active
350            .remove(handle);
351        remove_record(&self.record_dir, handle);
352    }
353}
354
355/// Keeps a delegation recorded while it runs.
356pub(crate) struct DelegationGuard {
357    registry: Arc<DelegationRegistry>,
358    handle: String,
359    pgid: u32,
360    killed: Arc<AtomicBool>,
361    finished: bool,
362}
363
364impl DelegationGuard {
365    #[cfg(test)]
366    pub(crate) fn handle(&self) -> &str {
367        &self.handle
368    }
369
370    /// Whether `scv agents kill` stopped this run.
371    pub(crate) fn was_killed(&self) -> bool {
372        self.killed.load(Ordering::Acquire)
373    }
374
375    /// The run ended: stop anything still tagged with it, then forget it.
376    pub(crate) async fn finish(mut self) {
377        self.finished = true;
378        stop_tagged(&self.handle).await;
379        self.registry.finish_local(&self.handle);
380    }
381}
382
383impl Drop for DelegationGuard {
384    fn drop(&mut self) {
385        if self.finished {
386            return;
387        }
388        // The run was abandoned mid-flight: kill its group now and sweep
389        // tagged leftovers in the background.
390        signal_group(self.pgid, libc::SIGKILL);
391        self.registry.finish_local(&self.handle);
392        let handle = self.handle.clone();
393        if let Ok(runtime) = tokio::runtime::Handle::try_current() {
394            runtime.spawn(async move { stop_tagged(&handle).await });
395        } else {
396            for identity in tagged_processes(&handle) {
397                signal(identity.pid, libc::SIGKILL);
398            }
399        }
400    }
401}
402
403/// Stop a delegation's process group and tagged processes: TERM, then KILL
404/// after a short grace. Returns whether anything was still running.
405async fn stop_delegation(record: &DelegationRecord) -> bool {
406    let mut stopped = false;
407    // The group ID is the leader's PID, which the kernel does not reuse while
408    // the group has members. A live leader with a different start time means
409    // the PID was reused, so the group is not ours.
410    let leader = ProcessIdentity::of(record.process.pid);
411    let group_is_ours = record.pgid == record.process.pid
412        && match leader {
413            Some(leader) => leader == record.process,
414            None => group_exists(record.pgid),
415        };
416    if group_is_ours && group_exists(record.pgid) {
417        stopped = true;
418        signal_group(record.pgid, libc::SIGTERM);
419        let deadline = tokio::time::Instant::now() + STOP_GRACE;
420        while group_exists(record.pgid) && tokio::time::Instant::now() < deadline {
421            tokio::time::sleep(Duration::from_millis(50)).await;
422        }
423        signal_group(record.pgid, libc::SIGKILL);
424    }
425    stopped | stop_tagged(&record.handle).await
426}
427
428/// TERM, then KILL, every process tagged with `handle`. Returns whether any was found.
429async fn stop_tagged(handle: &str) -> bool {
430    let tagged = tagged_processes(handle);
431    if tagged.is_empty() {
432        return false;
433    }
434    for identity in &tagged {
435        signal(identity.pid, libc::SIGTERM);
436    }
437    let deadline = tokio::time::Instant::now() + STOP_GRACE;
438    while tagged.iter().any(ProcessIdentity::is_alive) && tokio::time::Instant::now() < deadline {
439        tokio::time::sleep(Duration::from_millis(50)).await;
440    }
441    for identity in tagged.iter().filter(|identity| identity.is_alive()) {
442        signal(identity.pid, libc::SIGKILL);
443    }
444    true
445}
446
447/// Processes whose `SCV_PARENT` chain names `handle`.
448fn tagged_processes(handle: &str) -> Vec<ProcessIdentity> {
449    let own = std::process::id();
450    ProcessTable::snapshot()
451        .tagged
452        .into_iter()
453        .filter(|(identity, chain)| identity.pid != own && chain_names(chain, handle))
454        .map(|(identity, _)| identity)
455        .collect()
456}
457
458fn chain_names(chain: &str, handle: &str) -> bool {
459    chain
460        .split(';')
461        .any(|entry| entry.rsplit('/').next() == Some(handle))
462}
463
464fn signal(pid: u32, signal: i32) {
465    if let Ok(pid) = i32::try_from(pid)
466        && pid > 0
467    {
468        unsafe {
469            libc::kill(pid, signal);
470        }
471    }
472}
473
474fn signal_group(pgid: u32, signal: i32) {
475    // Never address group 0 or 1 (this process's own group, or init's).
476    if let Ok(pgid) = i32::try_from(pgid)
477        && pgid > 1
478    {
479        unsafe {
480            libc::kill(-pgid, signal);
481        }
482    }
483}
484
485/// Whether the process group still has a running member (zombies excluded on Linux).
486fn group_exists(pgid: u32) -> bool {
487    let Ok(group) = i32::try_from(pgid) else {
488        return false;
489    };
490    if group <= 1 {
491        return false;
492    }
493    let result = unsafe { libc::kill(-group, 0) };
494    let signalable =
495        result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
496    #[cfg(target_os = "linux")]
497    {
498        signalable
499            && linux::all_stats()
500                .iter()
501                .any(|info| info.pgid == pgid && info.state != 'Z')
502    }
503    #[cfg(not(target_os = "linux"))]
504    {
505        signalable
506    }
507}
508
509fn write_record(dir: &Path, record: &DelegationRecord) -> std::io::Result<()> {
510    use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
511    std::fs::create_dir_all(dir)?;
512    if let Some(run) = dir.parent() {
513        std::fs::set_permissions(run, std::fs::Permissions::from_mode(0o700))?;
514    }
515    std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
516    let bytes = serde_json::to_vec_pretty(record).map_err(std::io::Error::other)?;
517    let temporary = dir.join(format!(".{}.json.tmp", record.handle));
518    let mut file = std::fs::OpenOptions::new()
519        .write(true)
520        .create(true)
521        .truncate(true)
522        .mode(0o600)
523        .open(&temporary)?;
524    file.write_all(&bytes)?;
525    file.sync_all()?;
526    drop(file);
527    std::fs::rename(&temporary, dir.join(format!("{}.json", record.handle)))
528}
529
530fn read_record(path: &Path) -> Option<DelegationRecord> {
531    let file = std::fs::File::open(path).ok()?;
532    let mut bytes = Vec::new();
533    std::io::Read::read_to_end(&mut std::io::Read::take(file, MAX_RECORD_BYTES), &mut bytes)
534        .ok()?;
535    let record: DelegationRecord = serde_json::from_slice(&bytes).ok()?;
536    // Only a record named after its own handle is trusted.
537    (path.file_stem().and_then(|stem| stem.to_str()) == Some(record.handle.as_str()))
538        .then_some(record)
539}
540
541fn remove_record(dir: &Path, handle: &str) {
542    let _ = std::fs::remove_file(dir.join(format!("{handle}.json")));
543}
544
545/// Make this process the reaper of orphaned descendants (Linux), so processes
546/// a delegated agent leaves behind stay in SCV's process tree.
547pub fn become_child_subreaper() -> bool {
548    #[cfg(target_os = "linux")]
549    {
550        unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) == 0 }
551    }
552    #[cfg(not(target_os = "linux"))]
553    {
554        false
555    }
556}
557
558static SPAWNED: Mutex<Option<HashSet<u32>>> = Mutex::new(None);
559
560/// Note a child this process spawned and will wait for itself.
561pub(crate) fn track_spawned(pid: u32) {
562    SPAWNED
563        .lock()
564        .expect("spawned lock")
565        .get_or_insert_with(HashSet::new)
566        .insert(pid);
567}
568
569pub(crate) fn untrack_spawned(pid: u32) {
570    if let Some(spawned) = SPAWNED.lock().expect("spawned lock").as_mut() {
571        spawned.remove(&pid);
572    }
573}
574
575/// Collect exited orphans reparented to this subreaper. Children SCV spawned
576/// itself are left to their own waiters.
577pub fn reap_orphaned_zombies() -> usize {
578    #[cfg(target_os = "linux")]
579    {
580        let own = std::process::id();
581        let spawned = SPAWNED
582            .lock()
583            .expect("spawned lock")
584            .clone()
585            .unwrap_or_default();
586        let uptime = linux::uptime_ticks();
587        let mut reaped = 0;
588        for info in linux::all_stats() {
589            if info.ppid != own || info.state != 'Z' || spawned.contains(&info.pid) {
590                continue;
591            }
592            let old_enough = uptime.is_some_and(|now| {
593                now.saturating_sub(info.start_time)
594                    >= ZOMBIE_MIN_AGE.as_secs() * linux::clock_ticks()
595            });
596            if !old_enough {
597                continue;
598            }
599            let mut status = 0;
600            if unsafe { libc::waitpid(info.pid as i32, &mut status, libc::WNOHANG) }
601                == info.pid as i32
602            {
603                reaped += 1;
604            }
605        }
606        reaped
607    }
608    #[cfg(not(target_os = "linux"))]
609    {
610        0
611    }
612}
613
614/// Processes of interest at one moment: group membership and tags.
615struct ProcessTable {
616    groups: Vec<(ProcessIdentity, u32)>,
617    tagged: Vec<(ProcessIdentity, String)>,
618}
619
620impl ProcessTable {
621    fn members(&self, record: &DelegationRecord) -> HashSet<u32> {
622        let mut members: HashSet<u32> = self
623            .groups
624            .iter()
625            .filter(|(_, pgid)| *pgid == record.pgid)
626            .map(|(identity, _)| identity.pid)
627            .collect();
628        members.extend(
629            self.tagged
630                .iter()
631                .filter(|(_, chain)| chain_names(chain, &record.handle))
632                .map(|(identity, _)| identity.pid),
633        );
634        members
635    }
636
637    #[cfg(target_os = "linux")]
638    fn snapshot() -> Self {
639        let mut groups = Vec::new();
640        let mut tagged = Vec::new();
641        for info in linux::all_stats() {
642            if info.state == 'Z' {
643                continue;
644            }
645            let identity = ProcessIdentity {
646                pid: info.pid,
647                start_time: info.start_time,
648            };
649            groups.push((identity, info.pgid));
650            if let Some(chain) = linux::parent_chain(info.pid) {
651                tagged.push((identity, chain));
652            }
653        }
654        Self { groups, tagged }
655    }
656
657    #[cfg(not(target_os = "linux"))]
658    fn snapshot() -> Self {
659        let mut groups = Vec::new();
660        let mut tagged = Vec::new();
661        // `ps -E` appends each process's environment to its command line.
662        let Ok(output) = std::process::Command::new("ps")
663            .args(["-E", "-ww", "-axo", "pid=,pgid=,command="])
664            .output()
665        else {
666            return Self { groups, tagged };
667        };
668        for line in String::from_utf8_lossy(&output.stdout).lines() {
669            let mut fields = line.split_whitespace();
670            let (Some(pid), Some(pgid)) = (
671                fields.next().and_then(|value| value.parse::<u32>().ok()),
672                fields.next().and_then(|value| value.parse::<u32>().ok()),
673            ) else {
674                continue;
675            };
676            let Some(identity) = ProcessIdentity::of(pid) else {
677                continue;
678            };
679            groups.push((identity, pgid));
680            if let Some(chain) = fields.find_map(|field| {
681                field
682                    .strip_prefix(PARENT_VARIABLE)
683                    .and_then(|rest| rest.strip_prefix('='))
684            }) {
685                tagged.push((identity, chain.to_owned()));
686            }
687        }
688        Self { groups, tagged }
689    }
690}
691
692#[cfg(target_os = "linux")]
693fn process_start_time(pid: u32) -> Option<u64> {
694    linux::stat(pid).map(|info| info.start_time)
695}
696
697#[cfg(target_os = "macos")]
698fn process_start_time(pid: u32) -> Option<u64> {
699    let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
700    let size = std::mem::size_of::<libc::proc_bsdinfo>() as i32;
701    let written = unsafe {
702        libc::proc_pidinfo(
703            pid as i32,
704            libc::PROC_PIDTBSDINFO,
705            0,
706            (&mut info as *mut libc::proc_bsdinfo).cast(),
707            size,
708        )
709    };
710    (written == size).then(|| info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec)
711}
712
713#[cfg(not(any(target_os = "linux", target_os = "macos")))]
714fn process_start_time(pid: u32) -> Option<u64> {
715    let alive = unsafe { libc::kill(pid as i32, 0) } == 0;
716    alive.then_some(0)
717}
718
719#[cfg(target_os = "linux")]
720mod linux {
721    pub(super) struct Stat {
722        pub pid: u32,
723        pub ppid: u32,
724        pub pgid: u32,
725        pub state: char,
726        pub start_time: u64,
727    }
728
729    pub(super) fn stat(pid: u32) -> Option<Stat> {
730        let text = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
731        // The command name is parenthesized and may contain spaces or ')'.
732        let rest = &text[text.rfind(')')? + 2..];
733        let fields: Vec<&str> = rest.split_whitespace().collect();
734        // After the name: state(3) ppid(4) pgrp(5) ... starttime(22).
735        Some(Stat {
736            pid,
737            state: fields.first()?.chars().next()?,
738            ppid: fields.get(1)?.parse().ok()?,
739            pgid: fields.get(2)?.parse().ok()?,
740            start_time: fields.get(19)?.parse().ok()?,
741        })
742    }
743
744    pub(super) fn all_stats() -> Vec<Stat> {
745        let Ok(entries) = std::fs::read_dir("/proc") else {
746            return Vec::new();
747        };
748        entries
749            .filter_map(Result::ok)
750            .filter_map(|entry| entry.file_name().to_str()?.parse::<u32>().ok())
751            .filter_map(stat)
752            .collect()
753    }
754
755    /// `SCV_PARENT` from a process's environment, when readable.
756    pub(super) fn parent_chain(pid: u32) -> Option<String> {
757        let environ = std::fs::read(format!("/proc/{pid}/environ")).ok()?;
758        let prefix = format!("{}=", super::PARENT_VARIABLE);
759        environ.split(|byte| *byte == 0).find_map(|entry| {
760            entry
761                .strip_prefix(prefix.as_bytes())
762                .map(|value| String::from_utf8_lossy(value).into_owned())
763        })
764    }
765
766    pub(super) fn clock_ticks() -> u64 {
767        let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
768        u64::try_from(ticks)
769            .ok()
770            .filter(|ticks| *ticks > 0)
771            .unwrap_or(100)
772    }
773
774    pub(super) fn uptime_ticks() -> Option<u64> {
775        let text = std::fs::read_to_string("/proc/uptime").ok()?;
776        let seconds: f64 = text.split_whitespace().next()?.parse().ok()?;
777        Some((seconds * clock_ticks() as f64) as u64)
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784    use std::os::unix::{fs::PermissionsExt as _, process::CommandExt as _};
785
786    fn registry(home: &Path) -> Arc<DelegationRegistry> {
787        Arc::new(DelegationRegistry::new(home))
788    }
789
790    /// Spawn `sh -c script` in its own process group with `environment`.
791    fn spawn_tagged(script: &str, environment: &[(OsString, OsString)]) -> std::process::Child {
792        std::process::Command::new("sh")
793            .args(["-c", script])
794            .envs(environment.iter().map(|(key, value)| (key, value)))
795            .stdin(std::process::Stdio::null())
796            .stdout(std::process::Stdio::null())
797            .stderr(std::process::Stdio::null())
798            .process_group(0)
799            .spawn()
800            .unwrap()
801    }
802
803    async fn wait_for(mut condition: impl FnMut() -> bool) -> bool {
804        for _ in 0..200 {
805            if condition() {
806                return true;
807            }
808            tokio::time::sleep(Duration::from_millis(25)).await;
809        }
810        false
811    }
812
813    #[test]
814    fn chains_match_only_their_own_handle() {
815        assert!(chain_names("abcd/s1/codex-1a2b3c", "codex-1a2b3c"));
816        assert!(chain_names(
817            "x/s/claude-000000;abcd/s1/codex-1a2b3c",
818            "codex-1a2b3c"
819        ));
820        assert!(!chain_names("abcd/s1/codex-1a2b3c", "codex-1a2b3"));
821        assert!(!chain_names("abcd/s1/codex-1a2b3c", "1a2b3c"));
822    }
823
824    #[test]
825    fn nested_tags_extend_the_chain_and_depth() {
826        let home = tempfile::tempdir().unwrap();
827        let mut registry = DelegationRegistry::new(home.path());
828        registry.chain = Some("aaaa/s0/codex-111111".into());
829        registry.depth = 1;
830        let pending = registry.begin("claude", "s1", home.path());
831        let value = |name: &str| {
832            pending
833                .environment
834                .iter()
835                .find(|(key, _)| key == name)
836                .map(|(_, value)| value.to_str().unwrap().to_owned())
837                .unwrap()
838        };
839        assert_eq!(
840            value(PARENT_VARIABLE),
841            format!(
842                "aaaa/s0/codex-111111;{}/s1/{}",
843                registry.instance, pending.handle
844            )
845        );
846        assert_eq!(value(DEPTH_VARIABLE), "2");
847        assert!(pending.handle.starts_with("claude-"));
848    }
849
850    #[tokio::test]
851    async fn records_are_private_and_removed_when_the_run_finishes() {
852        let home = tempfile::tempdir().unwrap();
853        let registry = registry(home.path());
854        let pending = registry.begin("codex", "session", home.path());
855        let environment = pending.environment.clone();
856        let mut child = spawn_tagged("sleep 30", &environment);
857        let guard = registry.register(pending, child.id()).unwrap();
858        let path = registry
859            .record_dir()
860            .join(format!("{}.json", guard.handle()));
861        let mode = |path: &Path| std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
862        assert_eq!(mode(&path), 0o600);
863        assert_eq!(mode(registry.record_dir()), 0o700);
864        assert_eq!(mode(registry.record_dir().parent().unwrap()), 0o700);
865
866        let listed = registry.list(false);
867        assert_eq!(listed.len(), 1);
868        assert!(!listed[0].orphaned);
869        assert_eq!(listed[0].record.process.pid, child.id());
870        assert!(listed[0].processes >= 1);
871
872        signal_group(child.id(), libc::SIGKILL);
873        child.wait().unwrap();
874        guard.finish().await;
875        assert!(!path.exists());
876        assert!(registry.list(true).is_empty());
877    }
878
879    #[tokio::test]
880    async fn kill_stops_a_local_run_and_marks_it_killed() {
881        let home = tempfile::tempdir().unwrap();
882        let registry = registry(home.path());
883        let pending = registry.begin("claude", "session", home.path());
884        let environment = pending.environment.clone();
885        let mut child = spawn_tagged("trap '' TERM; sleep 30", &environment);
886        let guard = registry.register(pending, child.id()).unwrap();
887        registry.kill(guard.handle()).await.unwrap();
888        assert!(guard.was_killed());
889        assert!(child.wait().unwrap().code().is_none(), "killed by a signal");
890        assert!(registry.kill("claude-nosuch").await.is_err());
891        guard.finish().await;
892    }
893
894    #[cfg(target_os = "linux")]
895    #[tokio::test]
896    async fn reconcile_reaps_an_orphan_and_its_detached_descendants() {
897        let home = tempfile::tempdir().unwrap();
898        let owner = registry(home.path());
899        let pending = owner.begin("codex", "session", home.path());
900        let environment = pending.environment.clone();
901        // The agent starts a detached descendant in a new session, outside its group.
902        let mut child = spawn_tagged("setsid sleep 60 & exec sleep 60", &environment);
903        let guard = owner.register(pending, child.id()).unwrap();
904        let handle = guard.handle().to_owned();
905        assert!(wait_for(|| tagged_processes(&handle).len() >= 2).await);
906        let path = owner.record_dir().join(format!("{handle}.json"));
907        // Rewrite the record as if a process that has since died owned it.
908        let mut record = read_record(&path).unwrap();
909        let mut gone = std::process::Command::new("true").spawn().unwrap();
910        let gone_pid = gone.id();
911        gone.wait().unwrap();
912        record.owner = ProcessIdentity {
913            pid: gone_pid,
914            start_time: 1,
915        };
916        write_record(owner.record_dir(), &record).unwrap();
917        std::mem::forget(guard);
918
919        // Another SCV process of the same instance reconciles.
920        let daemon = registry(home.path());
921        assert!(daemon.list(false).is_empty());
922        let orphans = daemon.list(true);
923        assert_eq!(orphans.len(), 1);
924        assert!(orphans[0].orphaned);
925        assert!(orphans[0].processes >= 2);
926        let report = daemon.reconcile().await;
927        assert_eq!(report.reaped, vec![handle.clone()]);
928        assert_eq!(daemon.reaped_total(), 1);
929        assert!(child.wait().unwrap().code().is_none());
930        assert!(wait_for(|| tagged_processes(&handle).is_empty()).await);
931        assert!(!path.exists());
932        assert_eq!(daemon.reconcile().await, ReconcileReport::default());
933    }
934
935    #[tokio::test]
936    async fn an_abandoned_run_is_cleaned_up_when_its_guard_drops() {
937        let home = tempfile::tempdir().unwrap();
938        let registry = registry(home.path());
939        let pending = registry.begin("pi", "session", home.path());
940        let environment = pending.environment.clone();
941        let mut child = spawn_tagged("sleep 30", &environment);
942        let guard = registry.register(pending, child.id()).unwrap();
943        let path = registry
944            .record_dir()
945            .join(format!("{}.json", guard.handle()));
946        drop(guard);
947        assert!(child.wait().unwrap().code().is_none());
948        assert!(!path.exists());
949    }
950
951    #[test]
952    fn records_for_another_instance_or_under_the_wrong_name_are_ignored() {
953        let home = tempfile::tempdir().unwrap();
954        let registry = DelegationRegistry::new(home.path());
955        let dir = registry.record_dir().to_owned();
956        let record = DelegationRecord {
957            handle: "codex-abcdef".into(),
958            agent: "codex".into(),
959            instance: "other".into(),
960            session: "s".into(),
961            owner: ProcessIdentity {
962                pid: 1,
963                start_time: 1,
964            },
965            process: ProcessIdentity {
966                pid: 1,
967                start_time: 1,
968            },
969            pgid: 1,
970            cwd: "/".into(),
971            started_unix: 0,
972            depth: 1,
973        };
974        write_record(&dir, &record).unwrap();
975        std::fs::copy(
976            dir.join("codex-abcdef.json"),
977            dir.join("codex-renamed.json"),
978        )
979        .unwrap();
980        assert!(registry.list(true).is_empty());
981    }
982}