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.
43///
44/// Always 0 in this crate's unit tests: callers without a delegation context
45/// fall back to it, and the test runner may itself be a delegated agent. The
46/// black-box tests cover the inherited read.
47pub 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/// A process, identified by PID plus start time so a reused PID never matches.
59#[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    /// Whether this exact process still runs. An exited process that its
75    /// parent has not yet collected (a zombie) does not count.
76    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/// One delegated run, as recorded on disk.
90#[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    /// The agent process, which leads its own process group.
98    pub process: ProcessIdentity,
99    pub pgid: u32,
100    pub cwd: PathBuf,
101    pub started_unix: u64,
102    /// Depth of the delegated process (the owner's depth plus one).
103    pub depth: u32,
104    /// The conversation this run is a turn of, and which turn.
105    #[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    /// A live child that keeps its process between turns (a nested SCV or an
110    /// ACP agent) and has no turn running: when its last turn ended. Absent
111    /// while a turn runs, and always for a per-turn run.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub idle_since_unix: Option<u64>,
114    /// A nested SCV's own background jobs that still run or whose results
115    /// its model has not yet taken in: finished and not yet reported, or in
116    /// the turn it started to report them, until that turn ends. Absent when
117    /// there are none, for every other agent, and from older releases.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub background_jobs: Option<u32>,
120}
121
122/// A record plus what SCV currently observes about it.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct DelegationEntry {
125    pub record: DelegationRecord,
126    /// The owning SCV process is gone; reconciliation will clean it up.
127    pub orphaned: bool,
128    /// Live processes in its group plus tagged processes outside it.
129    pub processes: usize,
130}
131
132impl DelegationEntry {
133    /// Whether the run is still at work: it has live processes and is not a
134    /// live child waiting between turns of its conversation with no
135    /// background jobs of its own.
136    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/// A delegation named in an `SCV_PARENT` chain, and the session that
144/// started it.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct ChainRun {
147    /// The delegation's handle, such as `codex-3f9a2c`.
148    pub handle: String,
149    /// The SCV session that started it.
150    pub session: String,
151}
152
153/// What a reconciliation pass did.
154#[derive(Debug, Clone, Default, PartialEq, Eq)]
155pub struct ReconcileReport {
156    /// Orphaned delegations whose processes were stopped.
157    pub reaped: Vec<String>,
158    /// Orphaned records whose processes had already exited.
159    pub removed: usize,
160    /// Conversation markers left by SCV processes that no longer run.
161    pub stale_markers: usize,
162}
163
164#[derive(Debug, Default)]
165struct Inner {
166    active: HashMap<String, Arc<AtomicBool>>,
167    reaped: u64,
168}
169
170/// The delegations one SCV process started, backed by the instance's records.
171#[derive(Debug)]
172pub struct DelegationRegistry {
173    record_dir: PathBuf,
174    conversation_dir: PathBuf,
175    /// Where each run's activity log and state go.
176    runs_dir: PathBuf,
177    instance: String,
178    owner: Option<ProcessIdentity>,
179    depth: u32,
180    chain: Option<String>,
181    inner: Mutex<Inner>,
182}
183
184/// A delegation about to start: its handle and the environment tagging it.
185pub(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 of the delegated process.
193    depth: u32,
194}
195
196impl DelegationRegistry {
197    /// The registry for the SCV instance at `layout`: records in
198    /// [`Layout::delegations`], conversation markers in
199    /// [`Layout::conversations`], and an instance ID hashed from its home.
200    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    /// A unit-test fixture whose caller is independent of the test runner.
221    /// [`Self::new`] reads the inherited chain in every build.
222    #[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    /// This process's own delegation depth.
232    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    /// Short identifier of the SCV instance, shared by all its processes.
241    pub fn instance(&self) -> &str {
242        &self.instance
243    }
244
245    /// Delegations this process stopped as orphans since it started.
246    pub fn reaped_total(&self) -> u64 {
247        lock(&self.inner).reaped
248    }
249
250    /// Where live conversations leave markers for `scv agents gc`.
251    pub fn conversation_dir(&self) -> &Path {
252        &self.conversation_dir
253    }
254
255    /// Where each run's activity log and state go.
256    pub fn runs_dir(&self) -> &Path {
257        &self.runs_dir
258    }
259
260    /// Start the activity log of the run `header` describes, owned by this
261    /// process; `None` when it cannot be written.
262    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    /// Start recording a delegation whose owner is at `owner_depth`: the
282    /// process's own depth, or more when its client is itself delegated.
283    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        // Never a handle a retained run log has: its run would get no log.
296        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    /// Record a spawned delegation. The returned guard removes the record and
325    /// stops leftovers when the run ends, even if the run is abandoned.
326    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    /// Delegations of this instance that are still running. With
371    /// `include_orphans`, also records whose owner died and await cleanup.
372    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    /// The running delegation this process started that an `SCV_PARENT`
400    /// `chain` names: the one a caller runs inside, whatever nested SCVs lie
401    /// between. Entries of other instances, runs other processes own, and
402    /// malformed entries are skipped.
403    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    /// Stop one delegation of this instance, whichever process owns it.
423    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    /// Stop and remove every orphaned delegation of this instance.
442    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    /// Whether the process that owns `record` still runs it. A record this
462    /// process owns counts only while its run is active here.
463    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
488/// Keeps a delegation recorded while it runs.
489pub(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    /// Record that the run moved on to `turn` of its conversation and is at
504    /// work, for a live child that serves every turn.
505    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    /// Record that a live child ended its turn and waits for the next one.
513    pub(crate) fn set_idle(&self) {
514        let now = unix_now();
515        self.update(|record| record.idle_since_unix = Some(now));
516    }
517
518    /// Record how many of a live child's own background jobs still run or
519    /// wait to be reported to it.
520    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    /// Rewrite the record. Bookkeeping only: a failure never fails the turn,
526    /// and a record already removed stays removed.
527    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    /// Whether `scv agents kill` stopped this run.
538    pub(crate) fn was_killed(&self) -> bool {
539        self.killed.load(Ordering::Acquire)
540    }
541
542    /// The run ended: stop anything still tagged with it, then forget it.
543    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        // The run was abandoned mid-flight: kill its group now and sweep
556        // tagged leftovers in the background.
557        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
572/// Stop a delegation's process group and tagged processes: TERM, then KILL
573/// after a short grace. Returns whether anything was still running.
574async fn stop_delegation(record: &DelegationRecord) -> bool {
575    let mut stopped = false;
576    // The group ID is the leader's PID, which the kernel does not reuse while
577    // the group has members. A live leader with a different start time means
578    // the PID was reused, so the group is not ours.
579    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
602/// TERM, then KILL, every process tagged with `handle`. Returns whether any was found.
603async 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
621/// Processes whose `SCV_PARENT` chain names `handle`.
622fn 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        // SAFETY: kill(2) takes plain integers and touches no memory of
643        // ours; a positive PID addresses exactly one process.
644        unsafe {
645            libc::kill(pid, signal);
646        }
647    }
648}
649
650/// Whether the process group still has a running member (zombies excluded on Linux).
651pub(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
679/// Atomically write `value` as `dir/name` with mode 0600, creating `dir` and
680/// keeping it and its parent (`run/`) private.
681pub(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
690/// [`write_private_json`] without syncing to disk, for state rewritten
691/// often that only says what is happening now.
692pub(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
701/// Make `dir` (and its parent) private, and serialize `value`.
702fn 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            // A record removed between listing and reading is normal.
721            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    // Only a record named after its own handle is trusted.
735    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
752/// Make this process the reaper of orphaned descendants (Linux), so processes
753/// a delegated agent leaves behind stay in SCV's process tree.
754pub fn become_child_subreaper() -> bool {
755    #[cfg(target_os = "linux")]
756    {
757        // SAFETY: PR_SET_CHILD_SUBREAPER takes integer arguments only and
758        // changes only this process's own reaping attribute.
759        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
769/// Note a child this process spawned and will wait for itself.
770pub(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
780/// Collect exited orphans reparented to this subreaper. Children SCV spawned
781/// itself are left to their own waiters.
782pub 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            // SAFETY: `status` is a live local that waitpid(2) writes one
802            // int into; WNOHANG keeps the call from blocking.
803            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
817/// Processes of interest at one moment: group membership and tags.
818struct 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        // `ps -E` appends each process's environment to its command line.
865        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    // SAFETY: proc_bsdinfo is a plain C struct of integers and byte arrays,
903    // for which all-zero bytes are a valid value.
904    let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
905    let size = std::mem::size_of::<libc::proc_bsdinfo>() as i32;
906    // SAFETY: the buffer is `info` itself and `size` is its exact size, so
907    // proc_pidinfo(2) writes at most that many bytes into it.
908    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    // SAFETY: signal 0 only checks that the process exists; kill(2) takes
937    // plain integers and touches no memory of ours.
938    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        // The command name is parenthesized and may contain spaces or ')'.
955        let rest = &text[text.rfind(')')? + 2..];
956        let fields: Vec<&str> = rest.split_whitespace().collect();
957        // After the name: state(3) ppid(4) pgrp(5) ... starttime(22).
958        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    /// `SCV_PARENT` from a process's environment, when readable.
979    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        // SAFETY: sysconf(3) only reads a system constant.
991        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;