mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): reap orphaned agent processes on shutdown and restart (#787)
This commit is contained in:
@@ -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/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/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/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)
|
||||
@@ -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/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/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
|
||||
]);
|
||||
|
||||
|
||||
Generated
+25
-1
@@ -271,7 +271,7 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84790c55b5704b0d35130bf16a4ce22a8e70eb0ea773522557524d9a4852663d"
|
||||
dependencies = [
|
||||
"nix",
|
||||
"nix 0.30.1",
|
||||
"rand 0.9.4",
|
||||
]
|
||||
|
||||
@@ -1101,6 +1101,17 @@ version = "0.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
@@ -2931,6 +2942,18 @@ dependencies = [
|
||||
"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]]
|
||||
name = "nostr"
|
||||
version = "0.44.3"
|
||||
@@ -4855,6 +4878,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bzip2 0.6.1",
|
||||
"chrono",
|
||||
"ctrlc",
|
||||
"dirs",
|
||||
"earshot",
|
||||
"futures-util",
|
||||
|
||||
@@ -21,6 +21,7 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
ctrlc = { version = "3", features = ["termination"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
|
||||
|
||||
@@ -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.
|
||||
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 {
|
||||
save_managed_agents(app, &records)?;
|
||||
}
|
||||
@@ -645,11 +651,35 @@ pub fn run() {
|
||||
.build(tauri::generate_context!())
|
||||
.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 {
|
||||
RunEvent::ExitRequested { .. } | RunEvent::Exit => {
|
||||
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);
|
||||
if let Err(error) = shutdown_managed_agents(app_handle) {
|
||||
eprintln!("sprout-desktop: failed to stop managed agents: {error}");
|
||||
|
||||
@@ -53,6 +53,12 @@ pub fn restore_managed_agents_on_launch(
|
||||
.collect();
|
||||
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
|
||||
.iter()
|
||||
.filter(|record| record.start_on_app_launch && record.backend == BackendKind::Local)
|
||||
|
||||
@@ -118,6 +118,97 @@ pub(crate) fn process_belongs_to_us(_pid: u32) -> bool {
|
||||
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)]
|
||||
fn signal_process_group_or_leader(pid: u32, signal: i32, action: &str) -> Result<(), String> {
|
||||
let pgid = -(pid as i32);
|
||||
@@ -202,7 +293,9 @@ fn sigterm_then_sigkill(pids: &[i32]) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
|
||||
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 {
|
||||
libc::kill(-pid, libc::SIGKILL);
|
||||
}
|
||||
@@ -218,16 +311,23 @@ fn sigterm_then_sigkill(pids: &[i32]) {
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32]) {
|
||||
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()
|
||||
.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)
|
||||
.collect();
|
||||
|
||||
if !orphans.is_empty() {
|
||||
sigterm_then_sigkill(&orphans);
|
||||
if !targets.is_empty() {
|
||||
sigterm_then_sigkill(&targets);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// but not tracked in the current `runtimes` map. Updates the record fields and
|
||||
/// returns `true` if any records were modified.
|
||||
@@ -738,6 +982,12 @@ pub fn spawn_agent_child(
|
||||
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
|
||||
// tree (harness + MCP servers + agent subprocesses) on shutdown.
|
||||
#[cfg(unix)]
|
||||
|
||||
Reference in New Issue
Block a user