Skip to main content

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