fix(desktop): reap orphaned agent processes on shutdown and restart (#787)

This commit is contained in:
Will Pfleger
2026-05-29 18:18:55 +00:00
committed by GitHub
parent 8fc27b6554
commit 7beb0f8e68
6 changed files with 321 additions and 10 deletions
+2 -2
View File
@@ -52,7 +52,7 @@ const overrides = new Map([
["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload ["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload
["src-tauri/src/commands/messages.rs", 515], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + edit_message media_tags param (Slack-style attachment-editable edits) ["src-tauri/src/commands/messages.rs", 515], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + edit_message media_tags param (Slack-style attachment-editable edits)
["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests ["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests
["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) ["src-tauri/src/managed_agents/runtime.rs", 1300], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux) + SPROUT_MANAGED_AGENT env marker check (KERN_PROCARGS2 on macOS, /proc/environ on Linux)
["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests ["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests
["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field ["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field
["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery) ["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery)
@@ -79,7 +79,7 @@ const overrides = new Map([
["src-tauri/src/huddle/tts.rs", 1380], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fade_out (fade-out only — leading fade removed 2026-05-18 after onset-attenuation regression measured in examples/pocket_onset_probe.rs) + FIRST_APPEND_LEAD_IN_SAMPLES + build_sentence_append_plan (pure helper enforcing the lead-in fires exactly once per utterance, not per sentence — see lead_in_pad_fires_exactly_once_per_utterance regression test) + normalize_for_playback (per-sentence peak normalization to -3 dBFS ceiling with MAX_GAIN cap) + 30 unit tests (18 interrupt + 5 fade-out + 1 first-append-lead-in + 3 build-sentence-append-plan + 6 normalize) ["src-tauri/src/huddle/tts.rs", 1380], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fade_out (fade-out only — leading fade removed 2026-05-18 after onset-attenuation regression measured in examples/pocket_onset_probe.rs) + FIRST_APPEND_LEAD_IN_SAMPLES + build_sentence_append_plan (pure helper enforcing the lead-in fires exactly once per utterance, not per sentence — see lead_in_pad_fires_exactly_once_per_utterance regression test) + normalize_for_playback (per-sentence peak normalization to -3 dBFS ceiling with MAX_GAIN cap) + 30 unit tests (18 interrupt + 5 fade-out + 1 first-append-lead-in + 3 build-sentence-append-plan + 6 normalize)
["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test ["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers ["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers
["src-tauri/src/lib.rs", 735], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch ["src-tauri/src/lib.rs", 770], // +4 lines for PairingHandle managed state + 3 pairing command registrations + parse_message_deep_link helper extracted with 6 unit tests covering empty-param filter regression + mod migration + sync_shared_agent_data/reconcile_provider_mcp_commands/reconcile_persona_pack_paths calls on launch + SIGINT/SIGTERM/SIGHUP signal handlers for agent process cleanup
["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime ["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime
]); ]);
+25 -1
View File
@@ -271,7 +271,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84790c55b5704b0d35130bf16a4ce22a8e70eb0ea773522557524d9a4852663d" checksum = "84790c55b5704b0d35130bf16a4ce22a8e70eb0ea773522557524d9a4852663d"
dependencies = [ dependencies = [
"nix", "nix 0.30.1",
"rand 0.9.4", "rand 0.9.4",
] ]
@@ -1101,6 +1101,17 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "ctrlc"
version = "3.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix 0.31.3",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "ctutils" name = "ctutils"
version = "0.4.2" version = "0.4.2"
@@ -2931,6 +2942,18 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.11.1",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "nostr" name = "nostr"
version = "0.44.3" version = "0.44.3"
@@ -4855,6 +4878,7 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bzip2 0.6.1", "bzip2 0.6.1",
"chrono", "chrono",
"ctrlc",
"dirs", "dirs",
"earshot", "earshot",
"futures-util", "futures-util",
+1
View File
@@ -21,6 +21,7 @@ tauri-build = { version = "2", features = [] }
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" libc = "0.2"
ctrlc = { version = "3", features = ["termination"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
+32 -2
View File
@@ -148,6 +148,12 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
// All tracked PIDs have already been killed above, so pass an empty skip list. // All tracked PIDs have already been killed above, so pass an empty skip list.
managed_agents::sweep_orphaned_agent_processes(app, &[]); managed_agents::sweep_orphaned_agent_processes(app, &[]);
// System-wide sweep: agent workers (goose, sprout-agent, etc.) are spawned
// in their own process groups by sprout-acp, so group-kills above only
// reach the harness, not the workers. Scan all user processes and kill any
// known agent binaries that are still running.
managed_agents::sweep_system_agent_processes(&[]);
if changed { if changed {
save_managed_agents(app, &records)?; save_managed_agents(app, &records)?;
} }
@@ -645,11 +651,35 @@ pub fn run() {
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("error while building tauri application"); .expect("error while building tauri application");
let shutdown_done = AtomicBool::new(false); let shutdown_done = Arc::new(AtomicBool::new(false));
// Agent cleanup on SIGINT (Ctrl+C), SIGTERM, and SIGHUP (terminal close).
// The ctrlc crate with the "termination" feature covers all three signals
// and runs the handler on a dedicated thread (safe for mutex operations).
// `shutdown_done` prevents double-execution with the RunEvent handler.
// `process::exit(0)` intentionally skips Drop impls to avoid re-entrant
// locking in destructors during signal teardown.
#[cfg(unix)]
{
let signal_app = app.handle().clone();
let signal_shutdown_done = Arc::clone(&shutdown_done);
let signal_shutdown_started = Arc::clone(&shutdown_started);
if let Err(e) = ctrlc::set_handler(move || {
signal_shutdown_started.store(true, Ordering::SeqCst);
if !signal_shutdown_done.swap(true, Ordering::SeqCst) {
let _ = shutdown_managed_agents(&signal_app);
}
std::process::exit(0);
}) {
eprintln!("sprout-desktop: failed to register signal handler: {e}");
}
}
let run_shutdown_done = Arc::clone(&shutdown_done);
app.run(move |app_handle, event| match event { app.run(move |app_handle, event| match event {
RunEvent::ExitRequested { .. } | RunEvent::Exit => { RunEvent::ExitRequested { .. } | RunEvent::Exit => {
shutdown_started.store(true, Ordering::SeqCst); shutdown_started.store(true, Ordering::SeqCst);
if !shutdown_done.swap(true, Ordering::SeqCst) { if !run_shutdown_done.swap(true, Ordering::SeqCst) {
prevent_sleep::release(&app_handle.state::<AppState>().prevent_sleep); prevent_sleep::release(&app_handle.state::<AppState>().prevent_sleep);
if let Err(error) = shutdown_managed_agents(app_handle) { if let Err(error) = shutdown_managed_agents(app_handle) {
eprintln!("sprout-desktop: failed to stop managed agents: {error}"); eprintln!("sprout-desktop: failed to stop managed agents: {error}");
@@ -53,6 +53,12 @@ pub fn restore_managed_agents_on_launch(
.collect(); .collect();
super::sweep_orphaned_agent_processes(app, &tracked_pids); super::sweep_orphaned_agent_processes(app, &tracked_pids);
// System-wide sweep: enumerate all user processes and kill any known
// agent binaries not tracked by this session. Catches orphans whose
// PID files were already cleaned up (e.g. agent workers in their own
// process group whose parent harness exited).
super::sweep_system_agent_processes(&tracked_pids);
let candidates: Vec<String> = records let candidates: Vec<String> = records
.iter() .iter()
.filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local) .filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local)
+255 -5
View File
@@ -118,6 +118,97 @@ pub(crate) fn process_belongs_to_us(_pid: u32) -> bool {
false false
} }
/// Check if a running process has `SPROUT_MANAGED_AGENT=1` in its environment,
/// distinguishing Sprout-spawned agent trees from independently-launched ones.
#[cfg(target_os = "macos")]
fn process_has_sprout_marker(pid: u32) -> bool {
const MARKER: &[u8] = b"SPROUT_MANAGED_AGENT=1";
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;
// First call: get required buffer size.
if unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
std::ptr::null_mut(),
&mut buf_size,
std::ptr::null_mut(),
0,
)
} != 0
{
return false;
}
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 false;
}
buf.truncate(buf_size);
// Buffer layout: [i32 argc][exec_path\0][null padding][argv\0...][env\0...]
if buf.len() < std::mem::size_of::<libc::c_int>() {
return false;
}
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 (scan to first null).
while pos < buf.len() && buf[pos] != 0 {
pos += 1;
}
// Skip null padding between exec path and argv[0].
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;
}
// Remaining bytes are null-delimited environment strings.
buf[pos..].split(|&b| b == 0).any(|entry| entry == MARKER)
}
#[cfg(all(unix, not(target_os = "macos")))]
fn process_has_sprout_marker(pid: u32) -> bool {
let Ok(data) = std::fs::read(format!("/proc/{pid}/environ")) else {
return false;
};
data.split(|&b| b == 0)
.any(|entry| entry == b"SPROUT_MANAGED_AGENT=1")
}
#[cfg(not(unix))]
fn process_has_sprout_marker(_pid: u32) -> bool {
false
}
#[cfg(unix)] #[cfg(unix)]
fn signal_process_group_or_leader(pid: u32, signal: i32, action: &str) -> Result<(), String> { fn signal_process_group_or_leader(pid: u32, signal: i32, action: &str) -> Result<(), String> {
let pgid = -(pid as i32); let pgid = -(pid as i32);
@@ -202,7 +293,9 @@ fn sigterm_then_sigkill(pids: &[i32]) {
std::thread::sleep(std::time::Duration::from_millis(200)); std::thread::sleep(std::time::Duration::from_millis(200));
for &pid in pids { for &pid in pids {
if process_is_running(pid as u32) { // Check if the group has any living members, not just the leader.
// kill(-pid, 0) returns 0 if ANY member of the group is signalable.
if unsafe { libc::kill(-pid, 0) } == 0 {
unsafe { unsafe {
libc::kill(-pid, libc::SIGKILL); libc::kill(-pid, libc::SIGKILL);
} }
@@ -218,16 +311,23 @@ fn sigterm_then_sigkill(pids: &[i32]) {
#[cfg(unix)] #[cfg(unix)]
pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32]) { pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32]) {
let entries = super::read_all_agent_pid_files(app); let entries = super::read_all_agent_pid_files(app);
let orphans: Vec<i32> = entries // Collect live orphans AND dead-leader groups into a single kill batch.
// Dead leaders: PGID may have been recycled, but the window is narrow
// (PID files are from this session) and the cost of missing surviving
// group members outweighs the recycling risk.
let targets: Vec<i32> = entries
.iter() .iter()
.filter(|(_, pid)| { .filter(|(_, pid)| {
!skip_pids.contains(pid) && process_is_running(*pid) && process_belongs_to_us(*pid) if skip_pids.contains(pid) {
return false;
}
(process_is_running(*pid) && process_belongs_to_us(*pid)) || !process_is_running(*pid)
}) })
.map(|(_, pid)| *pid as i32) .map(|(_, pid)| *pid as i32)
.collect(); .collect();
if !orphans.is_empty() { if !targets.is_empty() {
sigterm_then_sigkill(&orphans); sigterm_then_sigkill(&targets);
} }
// Clean up PID files for processes we just killed or that are already gone. // Clean up PID files for processes we just killed or that are already gone.
@@ -246,6 +346,150 @@ pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, _skip_pids: &[u32]
let _ = app; let _ = app;
} }
/// Enumerate all processes on the system owned by the current user and kill any
/// that match `KNOWN_AGENT_BINARIES` but aren't in `skip_pids`. This catches
/// orphans that escaped PID-file-based cleanup (e.g. agent workers spawned with
/// their own process group whose parent harness already exited and had its PID
/// file removed).
#[cfg(target_os = "macos")]
pub(crate) fn sweep_system_agent_processes(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;
}
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();
for &pid in &pids {
if pid <= 0 {
continue;
}
let upid = pid as u32;
if skip_pids.contains(&upid) || pid == my_pid {
continue;
}
// Check binary name first (cheap proc_name call) before UID lookup.
if !process_belongs_to_us(upid) {
continue;
}
// Verify UID to avoid killing another user's identically-named binary.
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;
}
if !process_has_sprout_marker(upid) {
continue;
}
orphans.push(pid);
}
if !orphans.is_empty() {
eprintln!(
"sprout-desktop: system sweep found {} orphaned agent process(es), cleaning up",
orphans.len()
);
sigterm_then_sigkill(&orphans);
}
}
#[cfg(all(unix, not(target_os = "macos")))]
pub(crate) fn sweep_system_agent_processes(skip_pids: &[u32]) {
let my_uid = unsafe { libc::getuid() };
let mut orphans: Vec<i32> = Vec::new();
let my_pid = std::process::id() as i32;
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;
}
// Check ownership via /proc/<pid> metadata.
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) {
orphans.push(pid);
}
}
if !orphans.is_empty() {
eprintln!(
"sprout-desktop: system sweep found {} orphaned agent process(es), cleaning up",
orphans.len()
);
sigterm_then_sigkill(&orphans);
}
}
#[cfg(not(unix))]
pub(crate) fn sweep_system_agent_processes(_skip_pids: &[u32]) {}
/// Kill stale agent processes from a previous session whose PID is still alive /// 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 /// but not tracked in the current `runtimes` map. Updates the record fields and
/// returns `true` if any records were modified. /// returns `true` if any records were modified.
@@ -738,6 +982,12 @@ pub fn spawn_agent_child(
command.env(key, value); command.env(key, value);
} }
// Mark as Sprout-managed so the system-wide orphan sweep can
// distinguish our processes from independently-launched agent binaries.
// Propagates automatically through the full tree (sprout-acp → goose →
// MCP servers) because neither sprout-acp nor goose calls env_clear().
command.env("SPROUT_MANAGED_AGENT", "1");
// Spawn the harness in its own process group so we can kill the entire // Spawn the harness in its own process group so we can kill the entire
// tree (harness + MCP servers + agent subprocesses) on shutdown. // tree (harness + MCP servers + agent subprocesses) on shutdown.
#[cfg(unix)] #[cfg(unix)]