Skip to main content

fresh/app/window/
process_group.rs

1//! Per-window process-group tracking + signalling.
2//!
3//! Each `Window` owns a [`ProcessGroups`] that records the leader
4//! pid of every OS process group the window has spawned (today:
5//! pty children from `terminal_manager.spawn`; later: long-running
6//! tool agents, language servers spawned by the window's
7//! authority, …). The window's authority provides a concrete
8//! [`Signaller`] implementation, so "stop everything this window
9//! owns" is a single `process_groups.signal_all("SIGTERM")` call
10//! regardless of whether the spawns happened locally, inside a
11//! container, or on a remote host.
12//!
13//! ## Why per-window?
14//!
15//! The Orchestrator lifecycle wants to terminate every process
16//! belonging to one editor session without touching the others.
17//! Routing through the window keeps that aggregation in one
18//! place — callers don't need to know how many terminals the
19//! window has or whether a future feature added another kind of
20//! background process; they just say "signal this window".
21//!
22//! ## Authority pluggability
23//!
24//! The [`Signaller`] trait is the seam between "I know who I
25//! want to signal" and "I know how to deliver that signal in
26//! this authority's namespace". Local pty processes are reached
27//! by `kill(-pid, …)` on the host kernel. Container / SSH
28//! authorities will plug in their own implementations that
29//! forward through `docker exec kill -PGRP …` or an SSH
30//! channel — see the design doc in
31//! `docs/internal/orchestrator-open-dialog-and-lifecycle.md` for
32//! how that fits into the broader lifecycle.
33
34use std::sync::Arc;
35
36/// Authority-pluggable mechanism for sending OS signals to a
37/// process group whose leader pid is known. Concrete impls live
38/// per-authority — see [`LocalSignaller`] for the host shell
39/// case; container / SSH variants are tracked as future work.
40pub trait Signaller: Send + Sync + std::fmt::Debug {
41    /// Send `signal_name` ("SIGTERM" / "SIGKILL" / "SIGINT" /
42    /// "SIGHUP") to the process group led by `leader_pid`.
43    ///
44    /// Returns `Ok(true)` when the signal was delivered to a
45    /// live group; `Ok(false)` when the group has already exited
46    /// (idempotent no-op so retry loops are safe); and `Err`
47    /// for permission / lookup / authority-specific failures.
48    fn signal(&self, leader_pid: u32, signal_name: &str) -> Result<bool, String>;
49}
50
51/// Local-process signaller. The pty puts every spawned shell at
52/// the head of its own session, so `kill(-pid, sig)` targets the
53/// shell and every subprocess it forked.
54#[derive(Debug, Default)]
55pub struct LocalSignaller;
56
57impl Signaller for LocalSignaller {
58    #[cfg(unix)]
59    fn signal(&self, leader_pid: u32, signal_name: &str) -> Result<bool, String> {
60        let sig = match signal_name {
61            "SIGTERM" => libc::SIGTERM,
62            "SIGKILL" => libc::SIGKILL,
63            "SIGINT" => libc::SIGINT,
64            "SIGHUP" => libc::SIGHUP,
65            other => return Err(format!("unsupported signal: {}", other)),
66        };
67        // `kill(-pid, sig)` (note the negation) sends `sig` to the
68        // process group whose leader is `pid`.
69        let rc = unsafe { libc::kill(-(leader_pid as i32), sig) };
70        if rc == 0 {
71            Ok(true)
72        } else {
73            let err = std::io::Error::last_os_error();
74            // ESRCH = no such process / group. Treat as
75            // "nothing to signal" so the caller's stop flow
76            // stays idempotent.
77            if err.raw_os_error() == Some(libc::ESRCH) {
78                Ok(false)
79            } else {
80                Err(format!("kill(-{}, {}): {}", leader_pid, signal_name, err))
81            }
82        }
83    }
84
85    #[cfg(windows)]
86    fn signal(&self, _leader_pid: u32, signal_name: &str) -> Result<bool, String> {
87        // Windows has no direct pgrp signaling. Callers wanting
88        // a hard kill route through `TerminalManager::close`
89        // (which uses the pty child killer).
90        Err(format!(
91            "Windows LocalSignaller cannot deliver {} — use TerminalManager::close()",
92            signal_name
93        ))
94    }
95}
96
97/// One entry in a window's tracked process groups. The `label`
98/// is a human-readable hint shown in error messages and the
99/// Orchestrator preview pane (e.g. "terminal #3", "lsp:rust").
100#[derive(Debug, Clone)]
101pub struct ProcessGroupEntry {
102    pub leader_pid: u32,
103    pub label: String,
104}
105
106/// Per-window aggregation of process groups. Spawning code
107/// (terminal manager, future LSP spawn paths) calls
108/// [`ProcessGroups::register`] when a new leader pid is known;
109/// `signal_all` fans out through the authority's [`Signaller`]
110/// when the window-level lifecycle operation fires.
111#[derive(Debug)]
112pub struct ProcessGroups {
113    signaller: Arc<dyn Signaller>,
114    entries: Vec<ProcessGroupEntry>,
115}
116
117impl ProcessGroups {
118    /// Construct with an explicit [`Signaller`]. Window's
119    /// authority decides which signaller — local windows pass
120    /// `Arc::new(LocalSignaller)`.
121    pub fn new(signaller: Arc<dyn Signaller>) -> Self {
122        Self {
123            signaller,
124            entries: Vec::new(),
125        }
126    }
127
128    /// Track a new process group leader. Idempotent: calling
129    /// with the same `leader_pid` twice replaces the label
130    /// rather than duplicating the entry.
131    pub fn register(&mut self, leader_pid: u32, label: impl Into<String>) {
132        let label = label.into();
133        if let Some(e) = self.entries.iter_mut().find(|e| e.leader_pid == leader_pid) {
134            e.label = label;
135        } else {
136            self.entries.push(ProcessGroupEntry { leader_pid, label });
137        }
138    }
139
140    /// Drop tracking for `leader_pid`. Doesn't signal — call
141    /// when the process has already exited (e.g. from a
142    /// `terminal_exit` hook).
143    pub fn forget(&mut self, leader_pid: u32) {
144        self.entries.retain(|e| e.leader_pid != leader_pid);
145    }
146
147    /// Send `signal_name` to every registered process group.
148    /// Returns one result per entry — caller decides how to
149    /// surface aggregate failures. Entries whose `signal` says
150    /// "already exited" (`Ok(false)`) are forgotten in place so
151    /// a follow-up signal cycle stays small.
152    pub fn signal_all(
153        &mut self,
154        signal_name: &str,
155    ) -> Vec<(ProcessGroupEntry, Result<bool, String>)> {
156        let mut out = Vec::with_capacity(self.entries.len());
157        let mut dead: Vec<u32> = Vec::new();
158        for e in &self.entries {
159            let r = self.signaller.signal(e.leader_pid, signal_name);
160            if matches!(r, Ok(false)) {
161                dead.push(e.leader_pid);
162            }
163            out.push((e.clone(), r));
164        }
165        self.entries.retain(|e| !dead.contains(&e.leader_pid));
166        out
167    }
168
169    /// Replace the signaller (e.g. when the window's authority
170    /// changes mid-life). Existing entries stay tracked; future
171    /// `signal_all` calls go through the new signaller.
172    pub fn set_signaller(&mut self, signaller: Arc<dyn Signaller>) {
173        self.signaller = signaller;
174    }
175
176    pub fn entries(&self) -> &[ProcessGroupEntry] {
177        &self.entries
178    }
179}
180
181impl Default for ProcessGroups {
182    fn default() -> Self {
183        Self::new(Arc::new(LocalSignaller))
184    }
185}