fix(desktop): reap orphaned agent processes across instances (#954)

Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-10 17:38:39 -04:00
committed by GitHub
parent c5a54dcc39
commit 53e3f09485
7 changed files with 881 additions and 40 deletions

View File

@@ -32,7 +32,7 @@ const rules = [
const overrides = new Map([
["src-tauri/src/commands/agents.rs", 1287],
["src-tauri/src/managed_agents/nest.rs", 1420],
["src-tauri/src/managed_agents/runtime.rs", 1465],
["src-tauri/src/managed_agents/runtime.rs", 1940],
["src-tauri/src/managed_agents/personas.rs", 1080],
["src-tauri/src/managed_agents/persona_card.rs", 1050],
["src-tauri/src/huddle/tts.rs", 1364],

View File

@@ -239,6 +239,10 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
// known agent binaries that are still running.
managed_agents::sweep_system_agent_processes(&managed_agents::current_instance_id(app), &[]);
// Dead-instance reaping: find agents belonging to Sprout instances
// whose desktop process is no longer running and reap them.
managed_agents::reap_dead_instance_agents(&managed_agents::current_instance_id(app), &[]);
if changed {
save_managed_agents(app, &records)?;
}
@@ -628,6 +632,45 @@ pub fn run() {
}
});
// Periodic sweep: reap orphaned agents from dead instances every 60s.
// Catches agents that escaped both the Justfile trap and boot-time
// reaping (e.g. a `just staging` Ctrl+C leak that only gets collected
// by a different instance's periodic sweep).
let sweep_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
use std::collections::HashSet;
use std::time::Duration;
use tauri::Manager;
let instance_id = managed_agents::current_instance_id(&sweep_handle);
let state = sweep_handle.state::<AppState>();
// Two-tick grace: only reap same-instance orphans seen on two
// consecutive sweeps. Prevents killing a legitimately-starting
// agent that spawned between the skip-list snapshot and the scan.
let mut prev_orphans: HashSet<u32> = HashSet::new();
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
// Collect PIDs of our own live agents to avoid killing them.
let skip_pids: Vec<u32> = state
.managed_agent_processes
.lock()
.map(|runtimes| runtimes.values().map(|rt| rt.child.id()).collect())
.unwrap_or_default();
let prev = prev_orphans.clone();
let inst = instance_id.clone();
// Run the blocking syscall work off the async executor.
let new_orphans = tauri::async_runtime::spawn_blocking(move || {
let orphans = managed_agents::sweep_system_agent_processes_with_grace(
&inst, &skip_pids, &prev,
);
managed_agents::reap_dead_instance_agents(&inst, &skip_pids);
orphans
})
.await
.unwrap_or_default();
prev_orphans = new_orphans;
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![

View File

@@ -61,6 +61,10 @@ pub async fn restore_managed_agents_on_launch(
// process group whose parent harness exited).
super::sweep_system_agent_processes(&super::current_instance_id(app), &tracked_pids);
// Dead-instance reaping: find agents belonging to Sprout instances
// whose desktop process is no longer running and reap them.
super::reap_dead_instance_agents(&super::current_instance_id(app), &tracked_pids);
let candidates: Vec<String> = records
.iter()
.filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local)

View File

@@ -365,6 +365,38 @@ pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, _skip_pids: &[u32]
let _ = app;
}
// ── macOS process-info FFI (shared by all sweep/reap functions) ──────────
#[cfg(target_os = "macos")]
extern "C" {
fn proc_listallpids(buffer: *mut libc::c_int, buffersize: libc::c_int) -> libc::c_int;
fn proc_pidinfo(
pid: libc::c_int,
flavor: libc::c_int,
arg: u64,
buffer: *mut libc::c_void,
buffersize: libc::c_int,
) -> libc::c_int;
}
/// Subset of `struct proc_bsdinfo` from `<sys/proc_info.h>`. Layout verified
/// against the macOS SDK — total size 136 bytes.
#[cfg(target_os = "macos")]
#[repr(C)]
struct BSDInfo {
_flags_status_xstatus: [u8; 12], // pbi_flags + pbi_status + pbi_xstatus
pbi_pid: u32, // offset 12
pbi_ppid: u32, // offset 16
pbi_uid: u32, // offset 20
_rest: [u8; 112],
}
#[cfg(target_os = "macos")]
const _: () = assert!(std::mem::size_of::<BSDInfo>() == 136);
#[cfg(target_os = "macos")]
const PROC_PIDTBSDINFO: libc::c_int = 3;
/// Enumerate all processes on the system owned by the current user and kill any
/// agent binary stamped with *this* instance's `SPROUT_MANAGED_AGENT` marker
/// (`instance_id`) that isn't in `skip_pids`. This catches orphans that escaped
@@ -373,46 +405,34 @@ pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, _skip_pids: &[u32]
/// while leaving another live Sprout instance's agents untouched.
#[cfg(target_os = "macos")]
pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) {
extern "C" {
fn proc_listallpids(buffer: *mut libc::c_int, buffersize: libc::c_int) -> libc::c_int;
fn proc_pidinfo(
pid: libc::c_int,
flavor: libc::c_int,
arg: u64,
buffer: *mut libc::c_void,
buffersize: libc::c_int,
) -> libc::c_int;
}
#[repr(C)]
struct BSDInfo {
_pad: [u8; 20],
pbi_uid: u32,
_rest: [u8; 112],
}
const _: () = assert!(std::mem::size_of::<BSDInfo>() == 136);
const PROC_PIDTBSDINFO: libc::c_int = 3;
let my_uid = unsafe { libc::getuid() };
let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) };
if count <= 0 {
return;
// Loop until the buffer is large enough to hold all PIDs. Under a fork
// storm the process table can outgrow the initial estimate between the
// probe and the fill call.
let mut pids: Vec<libc::c_int>;
loop {
let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) };
if count <= 0 {
return;
}
let buf_len = (count as usize) * 2;
pids = vec![0; buf_len];
let actual = unsafe {
proc_listallpids(
pids.as_mut_ptr(),
(buf_len * std::mem::size_of::<libc::c_int>()) as libc::c_int,
)
};
if actual <= 0 {
return;
}
pids.truncate(actual as usize);
if (actual as usize) < buf_len {
break;
}
}
let buf_len = (count as usize) * 2;
let mut pids: Vec<libc::c_int> = vec![0; buf_len];
let actual = unsafe {
proc_listallpids(
pids.as_mut_ptr(),
(buf_len * std::mem::size_of::<libc::c_int>()) as libc::c_int,
)
};
if actual <= 0 {
return;
}
pids.truncate(actual as usize);
let my_pid = std::process::id() as i32;
let mut orphans: Vec<i32> = Vec::new();
@@ -428,7 +448,7 @@ pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32])
if !process_belongs_to_us(upid) {
continue;
}
// Verify UID to avoid killing another user's identically-named binary.
// Verify UID and PPID via proc_pidinfo.
let mut info = std::mem::MaybeUninit::<BSDInfo>::zeroed();
let ret = unsafe {
proc_pidinfo(
@@ -446,6 +466,10 @@ pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32])
if info.pbi_uid != my_uid {
continue;
}
// Live child of a tracked harness — not an orphan.
if skip_pids.contains(&info.pbi_ppid) {
continue;
}
if !process_has_sprout_marker(upid, instance_id) {
continue;
}
@@ -461,6 +485,18 @@ pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32])
}
}
/// Read the parent PID of a process from /proc/<pid>/stat.
/// The comm field (field 2) may contain spaces and parens, so we find the last
/// ')' and parse fields after it. Field 1 after ')' is state, field 2 is PPID.
#[cfg(all(unix, not(target_os = "macos")))]
fn read_ppid_linux(pid: u32) -> Option<u32> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let after_comm = stat.rsplit_once(')')?.1;
// Fields after ')': " S ppid pgid ..."
let ppid_str = after_comm.split_whitespace().nth(1)?;
ppid_str.parse::<u32>().ok()
}
#[cfg(all(unix, not(target_os = "macos")))]
pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) {
let my_uid = unsafe { libc::getuid() };
@@ -493,9 +529,20 @@ pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32])
if meta.uid() != my_uid {
continue;
}
if process_belongs_to_us(upid) && process_has_sprout_marker(upid, instance_id) {
orphans.push(pid);
if !process_belongs_to_us(upid) || !process_has_sprout_marker(upid, instance_id) {
continue;
}
// Live child of a tracked harness — not an orphan. If /proc/<pid>/stat
// is unreadable (process exiting, transient I/O error), we treat the
// process as orphaned — safe because an exiting process will disappear
// shortly, and the two-tick grace in the periodic path prevents acting
// on transient failures.
if let Some(ppid) = read_ppid_linux(upid) {
if skip_pids.contains(&ppid) {
continue;
}
}
orphans.push(pid);
}
if !orphans.is_empty() {
@@ -510,6 +557,623 @@ pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32])
#[cfg(not(unix))]
pub(crate) fn sweep_system_agent_processes(_instance_id: &str, _skip_pids: &[u32]) {}
/// Periodic-sweep variant with two-tick grace: only reaps same-instance orphans
/// that were also seen orphaned on the previous tick. This prevents killing a
/// legitimately-starting agent that spawned between the skip-list snapshot and
/// the process scan. Returns the current orphan set for use as `prev_orphans`
/// on the next tick.
#[cfg(unix)]
pub(crate) fn sweep_system_agent_processes_with_grace(
instance_id: &str,
skip_pids: &[u32],
prev_orphans: &std::collections::HashSet<u32>,
) -> std::collections::HashSet<u32> {
let current = collect_same_instance_orphans(instance_id, skip_pids);
// Only reap PIDs seen orphaned on two consecutive ticks.
let confirmed: Vec<i32> = current
.iter()
.filter(|pid| prev_orphans.contains(pid))
.map(|&pid| pid as i32)
.collect();
if !confirmed.is_empty() {
eprintln!(
"sprout-desktop: periodic sweep confirmed {} orphaned agent process(es), cleaning up",
confirmed.len()
);
sigterm_then_sigkill(&confirmed);
}
current
}
#[cfg(not(unix))]
pub(crate) fn sweep_system_agent_processes_with_grace(
_instance_id: &str,
_skip_pids: &[u32],
_prev_orphans: &std::collections::HashSet<u32>,
) -> std::collections::HashSet<u32> {
std::collections::HashSet::new()
}
/// Collect PIDs of same-instance agent processes that appear orphaned (not in
/// `skip_pids`). Returns the set for use in two-tick grace logic — does NOT
/// kill anything.
#[cfg(target_os = "macos")]
pub(crate) fn collect_same_instance_orphans(
instance_id: &str,
skip_pids: &[u32],
) -> std::collections::HashSet<u32> {
let my_uid = unsafe { libc::getuid() };
let my_pid = std::process::id() as i32;
let mut orphans = std::collections::HashSet::new();
let mut pids: Vec<libc::c_int>;
loop {
let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) };
if count <= 0 {
return orphans;
}
let buf_len = (count as usize) * 2;
pids = vec![0; buf_len];
let actual = unsafe {
proc_listallpids(
pids.as_mut_ptr(),
(buf_len * std::mem::size_of::<libc::c_int>()) as libc::c_int,
)
};
if actual <= 0 {
return orphans;
}
pids.truncate(actual as usize);
if (actual as usize) < buf_len {
break;
}
}
for &pid in &pids {
if pid <= 0 || pid == my_pid {
continue;
}
let upid = pid as u32;
if skip_pids.contains(&upid) {
continue;
}
if !process_belongs_to_us(upid) {
continue;
}
let mut info = std::mem::MaybeUninit::<BSDInfo>::zeroed();
let ret = unsafe {
proc_pidinfo(
pid,
PROC_PIDTBSDINFO,
0,
info.as_mut_ptr() as *mut libc::c_void,
std::mem::size_of::<BSDInfo>() as libc::c_int,
)
};
if ret <= 0 {
continue;
}
let info = unsafe { info.assume_init() };
if info.pbi_uid != my_uid {
continue;
}
// Live child of a tracked harness — not an orphan.
if skip_pids.contains(&info.pbi_ppid) {
continue;
}
if process_has_sprout_marker(upid, instance_id) {
orphans.insert(upid);
}
}
orphans
}
#[cfg(all(unix, not(target_os = "macos")))]
pub(crate) fn collect_same_instance_orphans(
instance_id: &str,
skip_pids: &[u32],
) -> std::collections::HashSet<u32> {
let my_uid = unsafe { libc::getuid() };
let my_pid = std::process::id() as i32;
let mut orphans = std::collections::HashSet::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return orphans;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
let Ok(pid) = name_str.parse::<i32>() else {
continue;
};
if pid <= 0 || pid == my_pid {
continue;
}
let upid = pid as u32;
if skip_pids.contains(&upid) {
continue;
}
let Ok(meta) = entry.metadata() else {
continue;
};
use std::os::unix::fs::MetadataExt;
if meta.uid() != my_uid {
continue;
}
if !process_belongs_to_us(upid) || !process_has_sprout_marker(upid, instance_id) {
continue;
}
// Live child of a tracked harness — not an orphan. If /proc/<pid>/stat
// is unreadable (process exiting, transient I/O error), we treat the
// process as orphaned — safe because an exiting process will disappear
// shortly, and the two-tick grace prevents acting on transient failures.
if let Some(ppid) = read_ppid_linux(upid) {
if skip_pids.contains(&ppid) {
continue;
}
}
orphans.insert(upid);
}
orphans
}
#[cfg(not(unix))]
pub(crate) fn collect_same_instance_orphans(
_instance_id: &str,
_skip_pids: &[u32],
) -> std::collections::HashSet<u32> {
std::collections::HashSet::new()
}
/// Binary names for the Sprout desktop/Tauri process. Used by dead-instance
/// detection to confirm the owning desktop is still alive. The release .app
/// bundle reports as "Sprout"; `tauri dev` reports as "sprout-desktop".
const DESKTOP_BINARY_NAMES: &[&str] = &["Sprout", "sprout-desktop", "sprout_desktop"];
/// Check if a process name matches a known Sprout desktop binary.
fn is_desktop_binary(name: &str) -> bool {
DESKTOP_BINARY_NAMES.contains(&name)
}
/// Check whether `buf` contains `id` as a complete identifier — not as a
/// prefix of a longer dotted name. The identifier appears in the Tauri config
/// JSON as `"identifier":"xyz.block.sprout.app.dev"` and in environment entries
/// as `KEY=...app.dev\0`, so a valid match is followed by a non-identifier byte
/// (not `[A-Za-z0-9._-]`) or sits at the end of the buffer. This prevents
/// `xyz.block.sprout.app` from matching inside `xyz.block.sprout.app.dev`.
fn buffer_contains_identifier(buf: &[u8], id: &[u8]) -> bool {
if id.is_empty() {
return false;
}
buf.windows(id.len()).enumerate().any(|(i, w)| {
if w != id {
return false;
}
// Boundary check on the byte immediately after the match: end-of-buffer
// or any byte that can't continue a dotted reverse-DNS identifier.
match buf.get(i + id.len()) {
None => true,
Some(&next) => {
!next.is_ascii_alphanumeric() && next != b'.' && next != b'_' && next != b'-'
}
}
})
}
/// Extract the `SPROUT_MANAGED_AGENT` value from a process's environment.
/// Returns `None` if the process doesn't have the marker or can't be read.
#[cfg(target_os = "macos")]
fn extract_sprout_marker_value(pid: u32) -> Option<String> {
let prefix = b"SPROUT_MANAGED_AGENT=";
let mut mib: [libc::c_int; 3] = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as libc::c_int];
let mut buf_size: libc::size_t = 0;
if unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
std::ptr::null_mut(),
&mut buf_size,
std::ptr::null_mut(),
0,
)
} != 0
{
return None;
}
let mut buf: Vec<u8> = vec![0; buf_size];
if unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
buf.as_mut_ptr() as *mut libc::c_void,
&mut buf_size,
std::ptr::null_mut(),
0,
)
} != 0
{
return None;
}
buf.truncate(buf_size);
if buf.len() < std::mem::size_of::<libc::c_int>() {
return None;
}
let mut n_args: libc::c_int = 0;
unsafe {
std::ptr::copy_nonoverlapping(
buf.as_ptr(),
&mut n_args as *mut libc::c_int as *mut u8,
std::mem::size_of::<libc::c_int>(),
);
}
let mut pos = std::mem::size_of::<libc::c_int>();
// Skip exec path.
while pos < buf.len() && buf[pos] != 0 {
pos += 1;
}
while pos < buf.len() && buf[pos] == 0 {
pos += 1;
}
// Skip argc argument strings.
let mut args_remaining = n_args;
while args_remaining > 0 && pos < buf.len() {
while pos < buf.len() && buf[pos] != 0 {
pos += 1;
}
while pos < buf.len() && buf[pos] == 0 {
pos += 1;
}
args_remaining -= 1;
}
// Search environment entries for our marker.
for entry in buf[pos..].split(|&b| b == 0) {
if entry.starts_with(prefix) {
return String::from_utf8(entry[prefix.len()..].to_vec()).ok();
}
}
None
}
#[cfg(all(unix, not(target_os = "macos")))]
fn extract_sprout_marker_value(pid: u32) -> Option<String> {
let prefix = b"SPROUT_MANAGED_AGENT=";
let data = std::fs::read(format!("/proc/{pid}/environ")).ok()?;
for entry in data.split(|&b| b == 0) {
if entry.starts_with(prefix) {
return String::from_utf8(entry[prefix.len()..].to_vec()).ok();
}
}
None
}
#[cfg(not(unix))]
fn extract_sprout_marker_value(_pid: u32) -> Option<String> {
None
}
/// Check if a Sprout desktop process is still alive for the given instance ID.
/// Scans all user-owned processes named "Sprout" or "sprout-desktop" and checks
/// whether any has the identifier in its command-line args (KERN_PROCARGS2 buffer
/// includes both argv and environ — the `--config` JSON from `tauri dev` contains
/// the identifier string).
#[cfg(target_os = "macos")]
fn desktop_is_alive_for_instance(instance_id: &str) -> bool {
extern "C" {
fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int;
}
let my_uid = unsafe { libc::getuid() };
let identifier_bytes = instance_id.as_bytes();
let mut pids: Vec<libc::c_int>;
loop {
let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) };
if count <= 0 {
return false;
}
let buf_len = (count as usize) * 2;
pids = vec![0; buf_len];
let actual = unsafe {
proc_listallpids(
pids.as_mut_ptr(),
(buf_len * std::mem::size_of::<libc::c_int>()) as libc::c_int,
)
};
if actual <= 0 {
return false;
}
pids.truncate(actual as usize);
if (actual as usize) < buf_len {
break;
}
}
for &pid in &pids {
if pid <= 0 {
continue;
}
// Check binary name — only look at desktop binaries.
let mut name_buf = [0u8; 1024];
let len = unsafe {
proc_name(
pid,
name_buf.as_mut_ptr() as *mut libc::c_void,
name_buf.len() as u32,
)
};
if len <= 0 {
continue;
}
let name = String::from_utf8_lossy(&name_buf[..len as usize]);
if !is_desktop_binary(&name) {
continue;
}
// Verify UID.
let mut info = std::mem::MaybeUninit::<BSDInfo>::zeroed();
let ret = unsafe {
proc_pidinfo(
pid,
PROC_PIDTBSDINFO,
0,
info.as_mut_ptr() as *mut libc::c_void,
std::mem::size_of::<BSDInfo>() as libc::c_int,
)
};
if ret <= 0 {
continue;
}
let info = unsafe { info.assume_init() };
if info.pbi_uid != my_uid {
continue;
}
// Check if this desktop process's args/env contain the identifier.
// The KERN_PROCARGS2 buffer holds argv + environ as null-delimited strings.
let mut mib: [libc::c_int; 3] = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid];
let mut buf_size: libc::size_t = 0;
if unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
std::ptr::null_mut(),
&mut buf_size,
std::ptr::null_mut(),
0,
)
} != 0
{
continue;
}
let mut args_buf: Vec<u8> = vec![0; buf_size];
if unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
args_buf.as_mut_ptr() as *mut libc::c_void,
&mut buf_size,
std::ptr::null_mut(),
0,
)
} != 0
{
continue;
}
args_buf.truncate(buf_size);
// Boundary-anchored search: the identifier in the config JSON is
// followed by a non-identifier char (typically `"`). A raw substring
// match would let `...app` match inside `...app.dev`.
if buffer_contains_identifier(&args_buf, identifier_bytes) {
return true;
}
}
false
}
#[cfg(all(unix, not(target_os = "macos")))]
fn desktop_is_alive_for_instance(instance_id: &str) -> bool {
let my_uid = unsafe { libc::getuid() };
let Ok(entries) = std::fs::read_dir("/proc") else {
return false;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
let Ok(pid) = name_str.parse::<u32>() else {
continue;
};
// Check ownership.
let Ok(meta) = entry.metadata() else {
continue;
};
use std::os::unix::fs::MetadataExt;
if meta.uid() != my_uid {
continue;
}
// Check binary name via /proc/<pid>/comm.
let Ok(comm) = std::fs::read_to_string(format!("/proc/{pid}/comm")) else {
continue;
};
if !is_desktop_binary(comm.trim()) {
continue;
}
// Check cmdline for the identifier with boundary anchoring.
let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else {
continue;
};
if buffer_contains_identifier(&cmdline, instance_id.as_bytes()) {
return true;
}
}
false
}
#[cfg(not(unix))]
fn desktop_is_alive_for_instance(_instance_id: &str) -> bool {
false
}
/// Reap agent processes belonging to dead Sprout desktop instances.
///
/// Scans all user processes for `SPROUT_MANAGED_AGENT=*`, groups them by
/// instance ID, and for each foreign instance (≠ `our_instance_id`) checks
/// whether a Sprout desktop binary is still alive for that instance. If not,
/// all agents from that dead instance are reaped.
#[cfg(target_os = "macos")]
pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]) {
let my_uid = unsafe { libc::getuid() };
let my_pid = std::process::id() as i32;
let mut pids: Vec<libc::c_int>;
loop {
let count = unsafe { proc_listallpids(std::ptr::null_mut(), 0) };
if count <= 0 {
return;
}
let buf_len = (count as usize) * 2;
pids = vec![0; buf_len];
let actual = unsafe {
proc_listallpids(
pids.as_mut_ptr(),
(buf_len * std::mem::size_of::<libc::c_int>()) as libc::c_int,
)
};
if actual <= 0 {
return;
}
pids.truncate(actual as usize);
if (actual as usize) < buf_len {
break;
}
}
// Collect (pid, instance_id) for all foreign agent processes.
let mut foreign_agents: HashMap<String, Vec<i32>> = HashMap::new();
for &pid in &pids {
if pid <= 0 || pid == my_pid {
continue;
}
let upid = pid as u32;
if skip_pids.contains(&upid) {
continue;
}
if !process_belongs_to_us(upid) {
continue;
}
// Verify UID.
let mut info = std::mem::MaybeUninit::<BSDInfo>::zeroed();
let ret = unsafe {
proc_pidinfo(
pid,
PROC_PIDTBSDINFO,
0,
info.as_mut_ptr() as *mut libc::c_void,
std::mem::size_of::<BSDInfo>() as libc::c_int,
)
};
if ret <= 0 {
continue;
}
let info = unsafe { info.assume_init() };
if info.pbi_uid != my_uid {
continue;
}
// Extract the instance ID from this agent's env.
let Some(agent_instance_id) = extract_sprout_marker_value(upid) else {
continue;
};
// Skip agents belonging to our own instance (handled by sweep_system_agent_processes).
if agent_instance_id == our_instance_id {
continue;
}
foreign_agents
.entry(agent_instance_id)
.or_default()
.push(pid);
}
// For each foreign instance, check if its desktop is still alive.
for (instance_id, agent_pids) in &foreign_agents {
if desktop_is_alive_for_instance(instance_id) {
continue;
}
eprintln!(
"sprout-desktop: reaping {} orphaned agent(s) from dead instance '{instance_id}'",
agent_pids.len()
);
sigterm_then_sigkill(agent_pids);
}
}
#[cfg(all(unix, not(target_os = "macos")))]
pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]) {
let my_uid = unsafe { libc::getuid() };
let my_pid = std::process::id() as i32;
let mut foreign_agents: HashMap<String, Vec<i32>> = HashMap::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
let Ok(pid) = name_str.parse::<i32>() else {
continue;
};
if pid <= 0 || pid == my_pid {
continue;
}
let upid = pid as u32;
if skip_pids.contains(&upid) {
continue;
}
let Ok(meta) = entry.metadata() else {
continue;
};
use std::os::unix::fs::MetadataExt;
if meta.uid() != my_uid {
continue;
}
if !process_belongs_to_us(upid) {
continue;
}
let Some(agent_instance_id) = extract_sprout_marker_value(upid) else {
continue;
};
if agent_instance_id == our_instance_id {
continue;
}
foreign_agents
.entry(agent_instance_id)
.or_default()
.push(pid);
}
for (instance_id, agent_pids) in &foreign_agents {
if desktop_is_alive_for_instance(instance_id) {
continue;
}
eprintln!(
"sprout-desktop: reaping {} orphaned agent(s) from dead instance '{instance_id}'",
agent_pids.len()
);
sigterm_then_sigkill(agent_pids);
}
}
#[cfg(not(unix))]
pub(crate) fn reap_dead_instance_agents(_our_instance_id: &str, _skip_pids: &[u32]) {}
/// Kill stale agent processes from a previous session whose PID is still alive
/// but not tracked in the current `runtimes` map. Updates the record fields and
/// returns `true` if any records were modified.

View File

@@ -1,5 +1,68 @@
use crate::managed_agents::known_acp_runtime;
// ── buffer_contains_identifier tests ────────────────────────────────────
#[test]
fn identifier_prefix_does_not_match_longer_id() {
// DMG identifier should NOT match inside a dev desktop's config JSON.
let buf = br#""identifier":"xyz.block.sprout.app.dev""#;
let id = b"xyz.block.sprout.app";
assert!(!super::buffer_contains_identifier(buf, id));
}
#[test]
fn identifier_prefix_does_not_match_worktree_slug() {
// Main dev identifier should NOT match inside a worktree desktop's buffer.
let buf = br#""identifier":"xyz.block.sprout.app.dev.my-branch""#;
let id = b"xyz.block.sprout.app.dev";
assert!(!super::buffer_contains_identifier(buf, id));
}
#[test]
fn identifier_exact_match_with_quote_boundary() {
// Exact match followed by closing quote — should match.
let buf = br#""identifier":"xyz.block.sprout.app.dev""#;
let id = b"xyz.block.sprout.app.dev";
assert!(super::buffer_contains_identifier(buf, id));
}
#[test]
fn identifier_match_with_null_boundary() {
// In KERN_PROCARGS2, entries are null-delimited.
let mut buf = b"SPROUT_MANAGED_AGENT=xyz.block.sprout.app.dev".to_vec();
buf.push(0);
buf.extend_from_slice(b"OTHER_VAR=value");
let id = b"xyz.block.sprout.app.dev";
assert!(super::buffer_contains_identifier(&buf, id));
}
#[test]
fn identifier_exact_match_at_end_of_buffer() {
// Exact match with end-of-buffer as the boundary — Thufir's case 1.
let buf = b"xyz.block.sprout.app.dev";
let id = b"xyz.block.sprout.app.dev";
assert!(super::buffer_contains_identifier(buf, id));
}
#[test]
fn longer_id_matches_when_short_prefix_also_present() {
// Searching for the longer ID finds it even when a shorter prefix token
// appears earlier — Thufir's "longer-of-prefix must match" case.
let mut buf = b"xyz.block.sprout.app".to_vec();
buf.push(0);
buf.extend_from_slice(br#""identifier":"xyz.block.sprout.app.dev""#);
let id = b"xyz.block.sprout.app.dev";
assert!(super::buffer_contains_identifier(&buf, id));
}
#[test]
fn identifier_empty_returns_false() {
let buf = b"anything";
assert!(!super::buffer_contains_identifier(buf, b""));
}
// ── marker_entry tests ──────────────────────────────────────────────────
#[test]
fn marker_entry_is_namespaced_by_instance_id() {
// The spawn stamp and the sweep matcher must produce identical bytes;

View File

@@ -277,6 +277,10 @@ dev *ARGS: _ensure-sidecar-stubs
cd {{desktop_dir}}
[[ -d node_modules ]] || pnpm install
source ../scripts/instance-env.sh
# Ctrl+C kills the Tauri app before its in-process sweep finishes, leaking
# agent workers. Reap this instance's agents on exit as a backstop.
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.SPROUT_TAURI_CONFIG).identifier)")
trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID"' EXIT
echo "Starting on Vite port ${SPROUT_VITE_PORT}, relay ${SPROUT_RELAY_URL}"
pnpm exec tauri dev --features mesh-llm --config "$SPROUT_TAURI_CONFIG" {{ARGS}}
@@ -294,6 +298,10 @@ staging *ARGS: _ensure-sidecar-stubs
cd {{desktop_dir}}
source ../scripts/instance-env.sh
export SPROUT_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co"
# Ctrl+C kills the Tauri app before its in-process sweep finishes, leaking
# agent workers. Reap this instance's agents on exit as a backstop.
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.SPROUT_TAURI_CONFIG).identifier)")
trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID"' EXIT
echo "Starting staging on Vite port ${SPROUT_VITE_PORT}, relay ${SPROUT_RELAY_URL}"
pnpm exec tauri dev --features mesh-llm --config "$SPROUT_TAURI_CONFIG" {{ARGS}}

View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Reap the agent processes belonging to a single desktop instance.
#
# `tauri dev` Ctrl+C tears down the Rust app before its in-process system sweep
# can finish, so agent workers (goose, sprout-agent, ...) it spawned in their
# own process groups survive as orphans. This script is the shell-side backstop:
# run it from an EXIT trap in the `just dev`/`just staging` recipes.
#
# It reads the PID-file receipts the desktop already writes — one file per agent
# under `<app-data>/agents/agent-pids/<pubkey>.pid`, each containing the agent's
# PGID (agents are spawned with `process_group(0)`, so PID == PGID). Killing by
# PGID reaches the whole agent subtree. We deliberately do NOT match the
# `SPROUT_MANAGED_AGENT` env var from the shell: on macOS `pkill -f` matches only
# argv, not the environment, so an env-marker match silently reaps nothing.
#
# Scoping is exact because the app-data directory is keyed by the instance's
# bundle identifier, so this only ever touches the receipts this instance wrote
# (the main checkout never reaps a worktree's agents, or vice versa).
#
# Usage: cleanup-instance-agents.sh <instance-id>
# <instance-id> is the desktop bundle identifier, e.g. `xyz.block.sprout.app.dev`
# (main checkout) or `xyz.block.sprout.app.dev.my-branch` (a worktree).
set -euo pipefail
instance_id="${1:-}"
if [[ -z "$instance_id" ]]; then
echo "cleanup-instance-agents: no instance id given, skipping" >&2
exit 0
fi
case "$(uname -s)" in
Darwin) app_data="$HOME/Library/Application Support/$instance_id" ;;
*) app_data="${XDG_DATA_HOME:-$HOME/.local/share}/$instance_id" ;;
esac
pids_dir="$app_data/agents/agent-pids"
[[ -d "$pids_dir" ]] || exit 0
shopt -s nullglob
pgids=()
for pid_file in "$pids_dir"/*.pid; do
pgid="$(<"$pid_file")"
pgid="${pgid//[$'\t\r\n ']/}"
[[ "$pgid" =~ ^[0-9]+$ ]] || continue
pgids+=("$pgid")
done
[[ ${#pgids[@]} -gt 0 ]] || exit 0
# SIGTERM the whole group first, give it a moment, then SIGKILL survivors.
# `kill -- -<pgid>` targets the process group. Failures are expected and fine
# (already-dead group, recycled PGID owned by someone else we can't signal).
for pgid in "${pgids[@]}"; do
kill -TERM -- "-$pgid" 2>/dev/null || true
done
sleep 0.2
for pgid in "${pgids[@]}"; do
kill -KILL -- "-$pgid" 2>/dev/null || true
done