mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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],
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
8
justfile
8
justfile
@@ -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}}
|
||||
|
||||
|
||||
59
scripts/cleanup-instance-agents.sh
Executable file
59
scripts/cleanup-instance-agents.sh
Executable 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
|
||||
Reference in New Issue
Block a user