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