Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/botloftd/src/browser/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions crates/botloftd/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ fn serve(home: PathBuf, config_path: Option<PathBuf>, 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(());
Expand Down
4 changes: 2 additions & 2 deletions crates/botloftd/src/platform/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions crates/botloftd/src/platform/unix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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};

Expand Down
8 changes: 8 additions & 0 deletions crates/botloftd/src/platform/unix/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
144 changes: 144 additions & 0 deletions crates/botloftd/src/platform/unix/orphans.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> = OnceLock::new();

#[derive(Debug, Serialize, Deserialize)]
struct Entry {
group: libc::pid_t,
args: Vec<String>,
}

/// 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::<Entry>(&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);
}
}
3 changes: 3 additions & 0 deletions crates/botloftd/src/platform/windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions crates/botloftd/src/platform/windows/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions crates/botloftd/src/runtime/pipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<home>/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) |
Expand Down Expand Up @@ -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
Expand Down
Loading