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}