aion-integration-acp 0.31.0

The first-party Agent Client Protocol (ACP) adapter for Aion: implements the aion-integrations AgentHarness trait against any ACP agent subprocess over newline-delimited JSON-RPC 2.0 stdio.
Documentation
//! Process-group containment for a spawned ACP agent.
//!
//! An ACP agent is a launcher as often as it is a program: `npx` execs a Node
//! process, which spawns the agent, which spawns tools of its own. Killing the
//! direct child kills the launcher and leaves the tree — so a session that ended
//! would leave an agent running against the operator's files with nothing left
//! holding its stdin.
//!
//! So a contained agent leads a FRESH process group, and the shutdown ladder
//! signals the GROUP. This is the same discipline
//! `aion_worker::process::ContainedChild` applies to every declared body the
//! distribution runs; it is re-stated here rather than depended on because
//! `aion-integration-acp` sits BELOW the worker in the crate graph, and it is
//! deliberately the smaller surface: this module owns a group id and two
//! signals, and nothing else.
//!
//! # The one rule
//!
//! **No signal is sent to the group after the leading child has been reaped.** A
//! process-group id IS the leader's pid, and a reaped pid is the operating
//! system's to hand out again. Signalling afterwards aims at whatever took the
//! number. Every signal below is therefore sent while the caller still holds an
//! unreaped child, and [`ProcessGroup::is_gone`] — the only call left afterwards
//! — is a READ, which can never act on a wrong answer.

/// The process group a contained agent leads.
///
/// Copied rather than borrowed: it is one integer, and a handle that could
/// outlive its child is exactly what the module rule is about.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ProcessGroup(i32);

impl ProcessGroup {
    /// The group led by a child spawned with `process_group(0)`, whose leader
    /// has process id `pid`.
    ///
    /// `None` when the platform pid does not fit a Unix `pid_t`, which is the
    /// only way the number could name a group nobody leads.
    #[must_use]
    pub fn of_leader(pid: u32) -> Option<Self> {
        i32::try_from(pid).ok().map(Self)
    }

    /// The raw group id, for a diagnostic or a test that has to probe it.
    #[must_use]
    pub const fn as_raw(self) -> i32 {
        self.0
    }

    /// Ask every process in the group to stop.
    ///
    /// Call while the leader is UNREAPED. A failure is logged rather than
    /// returned: the caller's next step is the bounded wait either way, and a
    /// group that has already departed (`ESRCH`) or that this process may no
    /// longer signal (`EPERM`, which for a group of one's own descendants means
    /// only unreapable remnants are left) is the signal having nothing to do
    /// rather than a fault.
    pub fn terminate(self) {
        self.signal(Signal::Term);
    }

    /// Kill every process in the group outright.
    ///
    /// Call while the leader is UNREAPED, after the grace has elapsed.
    pub fn kill(self) {
        self.signal(Signal::Kill);
    }

    /// Whether the group is observably gone.
    ///
    /// A READ, and the only call here that is safe once the leader has been
    /// reaped. `ESRCH` is the group having departed; `EPERM` is equally
    /// conclusive, because a process can always signal its own live
    /// descendants, so being refused means the id belongs to somebody else now.
    #[must_use]
    pub fn is_gone(self) -> bool {
        #[cfg(unix)]
        {
            match nix::sys::signal::killpg(nix::unistd::Pid::from_raw(self.0), None) {
                Ok(()) => false,
                Err(nix::errno::Errno::ESRCH | nix::errno::Errno::EPERM) => true,
                Err(source) => {
                    tracing::warn!(
                        process_group = self.0,
                        %source,
                        "acp adapter: could not probe the agent's process group; reporting it as \
                         still present rather than claiming it is gone"
                    );
                    false
                }
            }
        }
        #[cfg(not(unix))]
        {
            // A platform with no process groups never had one to lose. Reported
            // as present rather than gone, because "gone" is a claim and this
            // platform has no evidence for it.
            false
        }
    }

    /// Send one signal to the group, saying so when the OS refuses.
    fn signal(self, signal: Signal) {
        #[cfg(unix)]
        {
            let result = nix::sys::signal::killpg(nix::unistd::Pid::from_raw(self.0), signal.nix());
            match result {
                Ok(()) | Err(nix::errno::Errno::ESRCH | nix::errno::Errno::EPERM) => {}
                Err(source) => tracing::warn!(
                    process_group = self.0,
                    signal = signal.label(),
                    %source,
                    "acp adapter: could not signal the agent's process group; a descendant of the \
                     agent may still be running"
                ),
            }
        }
        #[cfg(not(unix))]
        {
            tracing::warn!(
                signal = signal.label(),
                "acp adapter: this platform has no process groups, so the agent's descendants \
                 cannot be stopped as a tree"
            );
        }
    }
}

/// The two rungs of the termination ladder.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Signal {
    /// Ask the tree to stop.
    Term,
    /// Stop it.
    Kill,
}

impl Signal {
    /// The name a diagnostic prints.
    const fn label(self) -> &'static str {
        match self {
            Self::Term => "SIGTERM",
            Self::Kill => "SIGKILL",
        }
    }

    /// The platform signal.
    #[cfg(unix)]
    const fn nix(self) -> nix::sys::signal::Signal {
        match self {
            Self::Term => nix::sys::signal::Signal::SIGTERM,
            Self::Kill => nix::sys::signal::Signal::SIGKILL,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_group_id_is_the_leaders_pid_and_survives_the_round_trip() {
        assert_eq!(
            ProcessGroup::of_leader(4321).map(ProcessGroup::as_raw),
            Some(4321),
            "a pid inside the pid_t range names the group its leader leads"
        );
    }

    #[test]
    fn a_pid_outside_the_platform_range_names_no_group() {
        assert!(
            ProcessGroup::of_leader(u32::MAX).is_none(),
            "a pid that does not fit pid_t cannot be signalled, and pretending it can would aim a \
             SIGKILL at a number nobody leads"
        );
    }

    #[test]
    fn a_group_that_never_existed_reads_as_gone() {
        // pid 1 leads init's group and is certainly present; a group id this
        // process could never have created is the negative arm. Both are READS.
        let absent = ProcessGroup::of_leader(0x7FFF_0000);
        #[cfg(unix)]
        assert!(
            absent.is_some_and(ProcessGroup::is_gone),
            "a group nothing leads must read as gone, or the shutdown ladder would wait out its \
             whole window on every session"
        );
        #[cfg(not(unix))]
        let _ = absent;
    }
}