From c57c58412a21961d1dc979aff68af1b52a36fa86 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:49:25 -0300 Subject: [PATCH] feat(platform): a daemon ends the bot and browser processes a crashed one left On macOS launchd ends only the daemon's own process group, and a daemon started by hand has nobody to end anything. Each bot and browser group is written down in /run/groups with its arguments; the next daemon of the same data folder kills every group whose leader still runs all of them, before any bot starts. Windows keeps its Job Objects and writes nothing down. --- crates/botloftd/src/browser/launch.rs | 1 + crates/botloftd/src/main.rs | 2 + crates/botloftd/src/platform/mod.rs | 4 +- crates/botloftd/src/platform/unix.rs | 2 + crates/botloftd/src/platform/unix/job.rs | 8 ++ crates/botloftd/src/platform/unix/orphans.rs | 144 +++++++++++++++++++ crates/botloftd/src/platform/windows.rs | 3 + crates/botloftd/src/platform/windows/job.rs | 3 + crates/botloftd/src/runtime/pipe.rs | 1 + docs/spec.md | 4 +- 10 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 crates/botloftd/src/platform/unix/orphans.rs diff --git a/crates/botloftd/src/browser/launch.rs b/crates/botloftd/src/browser/launch.rs index d21be3b2..2d25347c 100644 --- a/crates/botloftd/src/browser/launch.rs +++ b/crates/botloftd/src/browser/launch.rs @@ -81,6 +81,7 @@ pub async fn launch( let _ = child.wait(); return Err(err.into()); } + job.track(&command); let said = child.stderr.take().map(StartLog::read); let mut process = BrowserProcess { child, job }; debug!(pid = process.child.id(), "browser: started"); diff --git a/crates/botloftd/src/main.rs b/crates/botloftd/src/main.rs index 25912881..b5014a93 100644 --- a/crates/botloftd/src/main.rs +++ b/crates/botloftd/src/main.rs @@ -105,6 +105,8 @@ fn serve(home: PathBuf, config_path: Option, scheduled: bool) -> anyhow fn run(paths: Paths, config: Config, config_path: PathBuf, scheduled: bool) -> anyhow::Result<()> { let _lock = InstanceLock::acquire(&paths.lock_file())?; + // Before any bot starts: ends what a crashed run left behind (14.1). + platform::track_groups(&paths.home.join("run")); if scheduled && !autostart::scheduled_start(&paths.home, config.start_with_windows) { info!("a new sign-in, and Botloft does not start with Windows: waiting to be opened"); return Ok(()); diff --git a/crates/botloftd/src/platform/mod.rs b/crates/botloftd/src/platform/mod.rs index 6270cc5b..f7376042 100644 --- a/crates/botloftd/src/platform/mod.rs +++ b/crates/botloftd/src/platform/mod.rs @@ -14,13 +14,13 @@ use std::path::{Path, PathBuf}; pub use unix::{ KeepAwake, ProcessJob, TASK_KIND, delete_task, find_task, leave_own_console, owner_name, recycle, register_task, restrict_to_current_user, run_task, sign_in_id, stop_task, - user_environment, + track_groups, user_environment, }; #[cfg(windows)] pub use windows::{ KeepAwake, ProcessJob, TASK_KIND, delete_task, find_task, leave_own_console, owner_name, recycle, register_task, restrict_to_current_user, run_task, sign_in_id, stop_task, - user_environment, + track_groups, user_environment, }; /// A program the system starts for the owner (spec 14). diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index 802e8d8d..1c51889a 100644 --- a/crates/botloftd/src/platform/unix.rs +++ b/crates/botloftd/src/platform/unix.rs @@ -7,6 +7,7 @@ mod job; // Both build everywhere, so each one's tests run on either system. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] mod launchd; +mod orphans; #[cfg_attr(target_os = "macos", allow(dead_code))] mod systemd; @@ -21,6 +22,7 @@ pub use self::env::user_environment; pub use self::job::ProcessJob; #[cfg(target_os = "macos")] pub use self::launchd::{TASK_KIND, delete_task, find_task, register_task, run_task, stop_task}; +pub use self::orphans::track_groups; #[cfg(not(target_os = "macos"))] pub use self::systemd::{TASK_KIND, delete_task, find_task, register_task, run_task, stop_task}; diff --git a/crates/botloftd/src/platform/unix/job.rs b/crates/botloftd/src/platform/unix/job.rs index a8c75612..fc8de0d3 100644 --- a/crates/botloftd/src/platform/unix/job.rs +++ b/crates/botloftd/src/platform/unix/job.rs @@ -31,6 +31,14 @@ impl ProcessJob { Ok(()) } + /// Writes the group down with `command`'s arguments, so the next daemon + /// ends it if this one crashes (spec 14.1). + pub fn track(&self, command: &Command) { + if let Some(group) = *self.lock() { + super::orphans::remember(group, command); + } + } + /// Kills every process in the group. pub fn terminate(&self) -> io::Result<()> { let mut group = self.lock(); diff --git a/crates/botloftd/src/platform/unix/orphans.rs b/crates/botloftd/src/platform/unix/orphans.rs new file mode 100644 index 00000000..f9a4bbad --- /dev/null +++ b/crates/botloftd/src/platform/unix/orphans.rs @@ -0,0 +1,144 @@ +//! Bot and browser groups a crashed daemon leaves behind (spec 14.1). On +//! macOS, launchd ends only the daemon's own process group, and a daemon +//! started by hand has nobody to end them. Each group is written down when +//! it starts, with its arguments; the next daemon of the same data folder +//! ends the ones still running all of those arguments, so a number reused +//! by another program is never touched. + +use std::fs::OpenOptions; +use std::io::{BufRead as _, BufReader, Write as _}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; + +use serde::{Deserialize, Serialize}; +use tracing::{debug, info}; + +const FILE_NAME: &str = "groups"; + +/// Where this daemon writes its groups down, once `track_groups` ran. +static FILE: OnceLock = OnceLock::new(); + +#[derive(Debug, Serialize, Deserialize)] +struct Entry { + group: libc::pid_t, + args: Vec, +} + +/// Ends the groups the last daemon of this data folder left running, then +/// writes this daemon's groups down in `dir`. +pub fn track_groups(dir: &Path) { + let file = dir.join(FILE_NAME); + let ended = sweep(&file); + if ended > 0 { + info!(ended, "ended processes a previous run left behind"); + } + if let Err(err) = std::fs::create_dir_all(dir) { + debug!("cannot create {}: {err}", dir.display()); + } + let _ = FILE.set(file); +} + +/// Writes down the group `command` started. +pub(super) fn remember(group: libc::pid_t, command: &Command) { + if let Some(file) = FILE.get() { + write_down(file, group, command); + } +} + +fn write_down(file: &Path, group: libc::pid_t, command: &Command) { + let entry = Entry { + group, + args: command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(), + }; + let written = serde_json::to_string(&entry) + .map_err(std::io::Error::other) + .and_then(|line| { + let mut out = OpenOptions::new().create(true).append(true).open(file)?; + super::restrict_to_current_user(file)?; + out.write_all(format!("{line}\n").as_bytes()) + }); + if let Err(err) = written { + debug!("cannot write a process group down: {err}"); + } +} + +/// Ends every group in `file` still running its arguments, and empties it. +fn sweep(file: &Path) -> usize { + let Ok(opened) = std::fs::File::open(file) else { + return 0; + }; + let mut ended = 0; + for line in BufReader::new(opened).lines().map_while(Result::ok) { + let Ok(entry) = serde_json::from_str::(&line) else { + continue; + }; + // SAFETY: `kill` takes no pointers; a negative pid names the group. + if still_running(&entry) && unsafe { libc::kill(-entry.group, libc::SIGKILL) } == 0 { + ended += 1; + } + } + let _ = std::fs::remove_file(file); + ended +} + +/// Whether the group's leader still runs with every argument it had. +fn still_running(entry: &Entry) -> bool { + if entry.args.is_empty() || entry.group <= 1 { + return false; + } + let Ok(output) = Command::new("ps") + .args(["-ww", "-o", "command=", "-p", &entry.group.to_string()]) + .output() + else { + return false; + }; + let line = String::from_utf8_lossy(&output.stdout); + let line = line.trim(); + !line.is_empty() && entry.args.iter().all(|arg| line.contains(arg.as_str())) +} + +#[cfg(test)] +mod tests { + use std::os::unix::process::CommandExt as _; + use std::time::{Duration, Instant}; + + use super::*; + + fn alive(pid: libc::pid_t) -> bool { + // SAFETY: signal 0 only checks that the process exists. + unsafe { libc::kill(pid, 0) == 0 } + } + + #[test] + fn a_group_left_running_is_ended_and_others_are_not() { + let dir = tempfile::tempdir().expect("tempdir"); + let file = dir.path().join(FILE_NAME); + let marker = format!("left-behind-{}", std::process::id()); + let mut command = Command::new("sh"); + command + .args(["-c", &format!("sleep 300; : {marker}")]) + .process_group(0); + let mut child = command.spawn().expect("spawn"); + let group = libc::pid_t::try_from(child.id()).expect("pid"); + write_down(&file, group, &command); + // This test's own process, with arguments it never had. + let own = libc::pid_t::try_from(std::process::id()).expect("pid"); + let mut other = Command::new("sh"); + other.args(["-c", "not this one"]); + write_down(&file, own, &other); + + assert_eq!(sweep(&file), 1); + child.wait().expect("wait"); + let deadline = Instant::now() + Duration::from_secs(5); + while alive(group) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + assert!(!alive(group), "the group left behind is gone"); + assert!(!file.exists(), "the list starts over"); + assert_eq!(sweep(&file), 0); + } +} diff --git a/crates/botloftd/src/platform/windows.rs b/crates/botloftd/src/platform/windows.rs index 9bdbeb31..981373b6 100644 --- a/crates/botloftd/src/platform/windows.rs +++ b/crates/botloftd/src/platform/windows.rs @@ -23,6 +23,9 @@ pub use task::{delete_task, find_task, register_task, run_task, stop_task}; /// What starts the daemon for the owner here, for messages (spec 14). pub const TASK_KIND: &str = "scheduled task"; +/// Nothing to track: every Job Object dies with the daemon (spec 7.3). +pub fn track_groups(_dir: &Path) {} + use std::io; use std::os::windows::ffi::OsStrExt; use std::path::Path; diff --git a/crates/botloftd/src/platform/windows/job.rs b/crates/botloftd/src/platform/windows/job.rs index d8c69244..f80209ec 100644 --- a/crates/botloftd/src/platform/windows/job.rs +++ b/crates/botloftd/src/platform/windows/job.rs @@ -55,6 +55,9 @@ impl ProcessJob { Ok(()) } + /// Nothing to write down: the job dies with the daemon. + pub fn track(&self, _command: &Command) {} + /// Kills every process in the job. pub fn terminate(&self) -> io::Result<()> { // SAFETY: the handle is valid until drop. diff --git a/crates/botloftd/src/runtime/pipe.rs b/crates/botloftd/src/runtime/pipe.rs index f51dc254..d5470707 100644 --- a/crates/botloftd/src/runtime/pipe.rs +++ b/crates/botloftd/src/runtime/pipe.rs @@ -54,6 +54,7 @@ impl Runtime for PipeRuntime { let _ = child.kill(); return Err(err); } + job.track(&command); let pid = Some(child.id()); let stdout = child .stdout diff --git a/docs/spec.md b/docs/spec.md index c6dc8bed..5746f712 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -703,7 +703,7 @@ Em fatias (18, item 8). O que já vale fora do Windows: | Tema | Solução | |---|---| -| Processos dos bots | Cada processo de bot e de navegador lidera um **grupo de processos** próprio (`process_group(0)`), e tudo o que ele inicia entra no grupo. Parar o bot manda `SIGKILL` ao grupo inteiro (`kill(-pgid)`), como `TerminateJobObject`. Um grupo que já sumiu (`ESRCH`) é esquecido, para um número reaproveitado não atingir outro grupo. Um processo que cria sessão própria (`setsid`) sai do grupo. Quando o daemon morre, o grupo não morre junto como com `KILL_ON_JOB_CLOSE`: o `claude` sai sozinho quando o stdin fecha (7.4), mas um navegador continua. No Linux, o `systemd --user` mata todo o cgroup da unit quando o daemon para ou morre (`KillMode=control-group`). **Falta** no macOS, onde o launchd só mata o grupo do próprio daemon: uma varredura ao subir | +| Processos dos bots | Cada processo de bot e de navegador lidera um **grupo de processos** próprio (`process_group(0)`), e tudo o que ele inicia entra no grupo. Parar o bot manda `SIGKILL` ao grupo inteiro (`kill(-pgid)`), como `TerminateJobObject`. Um grupo que já sumiu (`ESRCH`) é esquecido, para um número reaproveitado não atingir outro grupo. Um processo que cria sessão própria (`setsid`) sai do grupo. Quando o daemon morre, o grupo não morre junto como com `KILL_ON_JOB_CLOSE`: o `claude` sai sozinho quando o stdin fecha (7.4), mas um navegador continua. No Linux, o `systemd --user` mata todo o cgroup da unit quando o daemon para ou morre (`KillMode=control-group`). No macOS o launchd só mata o grupo do próprio daemon, e um daemon iniciado à mão não tem quem mate nada. Por isso, nos dois sistemas, cada grupo de bot e de navegador é anotado ao subir em `/run/groups` (uma linha JSON com o id do grupo e os argumentos, arquivo `0600`). O daemon seguinte da mesma pasta de dados, logo depois de pegar o lock e antes de subir qualquer bot, mata (`SIGKILL` ao grupo) cada grupo cujo líder ainda roda com **todos** aqueles argumentos (`ps -ww -o command= -p`), e começa a lista de novo. Os argumentos incluem caminhos e ids únicos do bot (o perfil do navegador, o `mcp.json`, a sessão), então um número reaproveitado por outro programa nunca é atingido. Só o número de grupos mortos vai para o log | | Ambiente do bot | O que o **shell de login** do dono monta: `$SHELL` (senão o shell da conta, `getpwuid_r`, senão `/bin/sh`) com `-l -c`, sem terminal, imprime uma marca e `env -0`. Assim o bot acha o que o terminal do dono acha (`~/.local/bin`, Homebrew), mesmo com o daemon iniciado pelo sistema com um `PATH` mínimo. A marca separa o que o perfil imprime; `_`, `SHLVL`, `PWD` e `OLDPWD` ficam de fora. Mais de 10 s, saída sem `PATH` ou erro: vale o ambiente do daemon. A leitura vale por 20 s, menos que os 30 s em que o Botloft percebe o Claude Code instalado (15.1). As variáveis de sessão do Claude Code saem nos dois casos | | Claude Code | O `claude` no `PATH` desse ambiente, senão em `~/.local/bin` (instalador nativo), `~/.claude/local`, `/opt/homebrew/bin` e `/usr/local/bin` | | Nome do dono | O nome completo da conta (primeiro campo do GECOS), senão o login; `USER`, `LOGNAME` e o nome da pasta pessoal cobrem um uid sem entrada (contêineres) | @@ -966,7 +966,7 @@ M2 a M4 foram entregues com ConPTY, terminal com replay, inbox por named pipe e 5. Histórico de versões das instruções do bot. 6. Acesso remoto com token por dispositivo (Tailscale). 7. Sinais entre bots disparando rotinas. -8. Suporte Linux/macOS. Em fatias: (1) CI com Ubuntu e macOS para o daemon; (2) runtime do bot (grupo de processos, ambiente, `claude`, navegador); (3) iniciar com o sistema (`systemd --user`, LaunchAgent); (4) app; (5) empacotamento e release. Feitas: 1, 2 e 3 (14.1), menos a varredura de órfãos no macOS. +8. Suporte Linux/macOS. Em fatias: (1) CI com Ubuntu e macOS para o daemon; (2) runtime do bot (grupo de processos, ambiente, `claude`, navegador); (3) iniciar com o sistema (`systemd --user`, LaunchAgent); (4) app; (5) empacotamento e release. Feitas: 1, 2 e 3 (14.1). 9. Navegador dos bots, com o dono assistindo ao vivo. Desenho na seção 21. ## 19. Pontos a verificar na versão alvo do Claude Code