diff --git a/crates/botloftd/src/keep_awake.rs b/crates/botloftd/src/keep_awake.rs index c0ac3879..9518ae41 100644 --- a/crates/botloftd/src/keep_awake.rs +++ b/crates/botloftd/src/keep_awake.rs @@ -111,6 +111,9 @@ mod tests { assert_eq!(*calls.lock().expect("calls"), [true, false]); } + // Off Windows the helper may be missing (a container); its own test + // covers it there. + #[cfg(windows)] #[test] fn a_keep_awake_request_can_be_set_and_cleared() { let mut request = KeepAwake::new().expect("request"); diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index 1c51889a..1efb9927 100644 --- a/crates/botloftd/src/platform/unix.rs +++ b/crates/botloftd/src/platform/unix.rs @@ -8,6 +8,7 @@ mod job; #[cfg_attr(not(target_os = "macos"), allow(dead_code))] mod launchd; mod orphans; +mod power; #[cfg_attr(target_os = "macos", allow(dead_code))] mod systemd; @@ -23,6 +24,7 @@ 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; +pub use self::power::KeepAwake; #[cfg(not(target_os = "macos"))] pub use self::systemd::{TASK_KIND, delete_task, find_task, register_task, run_task, stop_task}; @@ -91,20 +93,6 @@ pub async fn shutdown_signal() { } } -/// Nothing keeps a Unix machine awake yet. -#[derive(Debug)] -pub struct KeepAwake; - -impl KeepAwake { - pub fn new() -> io::Result { - Ok(Self) - } - - pub fn set(&mut self, _on: bool) -> io::Result<()> { - Ok(()) - } -} - /// Nothing to leave: Unix never opens a console for the daemon. pub fn leave_own_console() {} diff --git a/crates/botloftd/src/platform/unix/power.rs b/crates/botloftd/src/platform/unix/power.rs new file mode 100644 index 00000000..6d198366 --- /dev/null +++ b/crates/botloftd/src/platform/unix/power.rs @@ -0,0 +1,115 @@ +//! Keeps the computer from sleeping while bots work, off Windows (spec +//! 14.1): `caffeinate -i` on macOS, an idle lock of `systemd-inhibit` on +//! Linux, held by a helper process for as long as it is wanted. Like the +//! power request on Windows, it only stops sleep after inactivity: closing +//! the lid or choosing Sleep still works. The helper also ends when the +//! daemon does, so a crash does not keep the computer awake. + +use std::io; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +#[cfg_attr(target_os = "macos", allow(dead_code))] +const REASON: &str = "Botloft bots are working"; +/// A helper that fails (no logind, say) exits at once; one still running +/// after this holds the lock. +const SETTLE: Duration = Duration::from_millis(200); + +#[derive(Debug, Default)] +pub struct KeepAwake { + helper: Option, +} + +impl KeepAwake { + pub fn new() -> io::Result { + Ok(Self::default()) + } + + pub fn set(&mut self, on: bool) -> io::Result<()> { + if on == self.running() { + return Ok(()); + } + if !on { + self.stop(); + return Ok(()); + } + let mut helper = helper(std::process::id()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + std::thread::sleep(SETTLE); + if let Some(status) = helper.try_wait()? { + return Err(io::Error::other(format!( + "the keep-awake helper stopped at once ({status})" + ))); + } + self.helper = Some(helper); + Ok(()) + } + + /// Whether the helper holds the lock now. + fn running(&mut self) -> bool { + self.helper + .as_mut() + .is_some_and(|helper| matches!(helper.try_wait(), Ok(None))) + } + + fn stop(&mut self) { + if let Some(mut helper) = self.helper.take() { + let _ = helper.kill(); + let _ = helper.wait(); + } + } +} + +impl Drop for KeepAwake { + fn drop(&mut self) { + self.stop(); + } +} + +/// `-w` ends `caffeinate` with the daemon. +#[cfg(target_os = "macos")] +fn helper(daemon: u32) -> Command { + let mut command = Command::new("caffeinate"); + command.args(["-i", "-w", &daemon.to_string()]); + command +} + +/// The lock lasts while the shell it runs does, and the shell watches the +/// daemon. +#[cfg(not(target_os = "macos"))] +fn helper(daemon: u32) -> Command { + let mut command = Command::new("systemd-inhibit"); + command.args([ + "--what=idle", + "--who=Botloft", + &format!("--why={REASON}"), + "--mode=block", + "sh", + "-c", + &format!("while kill -0 {daemon} 2>/dev/null; do sleep 5; done"), + ]); + command +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_helper_runs_only_while_wanted() { + let mut awake = KeepAwake::new().expect("keep awake"); + if let Err(err) = awake.set(true) { + // No `caffeinate` or no logind here (a container, say). + eprintln!("cannot keep this computer awake ({err}); skipping"); + return; + } + assert!(awake.running()); + awake.set(true).expect("again"); + assert!(awake.running(), "asking twice keeps one helper"); + awake.set(false).expect("off"); + assert!(!awake.running()); + } +} diff --git a/docs/spec.md b/docs/spec.md index 826b4558..a37eac19 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -711,7 +711,8 @@ Em fatias (18, item 8). O que já vale fora do Windows: | Iniciar com o sistema (Linux) | `botloftd service install` grava a unit `~/.config/systemd/user/.service` (`botloft.service` para a pasta padrão, `botloft-<8 hex>.service` para outra, como a tarefa do Windows), com `Type=exec`, `ExecStart` com cada palavra entre aspas (`%` e `$` dobrados), `Restart=on-failure` com `RestartSec=5`, `KillMode=control-group`, `TimeoutStopSec=20` e `WantedBy=default.target`. `start_with_windows` vira `systemctl --user enable` (o gerenciador do usuário a sobe no login) ou `disable`. Depois de uma queda o systemd a traz de volta; um stop pedido (logout, `service stop`) termina com saída limpa e não volta. Por isso não há gatilho de minuto nem conferência de logon novo (`sign_in_id` é sempre vazio): sem `enable`, nada a sobe num login novo. Estado por `is-active` e `is-enabled`, cujas palavras não são traduzidas | | Iniciar com o sistema (macOS) | Um launch agent em `~/Library/LaunchAgents/io.github.httpsphl.botloft.daemon[-<8 hex>].plist`, no domínio `gui/`. `RunAtLoad` segue `start_with_windows`. O `KeepAlive` é `PathState` com o arquivo `/run/keep-alive`: o launchd traz o daemon de volta enquanto o arquivo existir. Um `KeepAlive` simples, ou com `SuccessfulExit`, implica rodar no login. O arquivo é criado ao iniciar (`service install`, `restart` e o daemon iniciado pelo agente) e apagado no stop pedido e na saída limpa do daemon (logout incluído). Iniciar recarrega o agente do arquivo (`bootout`, `bootstrap`, com novas tentativas logo depois do `bootout`) e dá `kickstart`; parar apaga o arquivo e manda `SIGTERM` (`launchctl kill`); gravar a escolha só reescreve o arquivo, que o launchd lê no próximo login. Estado por `launchctl print` (`state = running`) | | Os dois | `service install`, `status`, `restart`, `stop` e `uninstall` iguais aos do Windows; as mensagens dizem "systemd user service" ou "launch agent" no lugar de "scheduled task". Um teste no CI (`tests/service.rs`, só com `BOTLOFT_SERVICE_TEST=1`) instala com o systemd e o launchd de verdade, mata o daemon com `SIGKILL` e o vê voltar, para e confere que não volta, reinicia e desinstala. O runner Ubuntu só tem gerenciador de usuário com `loginctl enable-linger` | -| Não suspender, Lixeira | Ainda não: avisam que só existem no Windows | +| Não suspender | Como no Windows, enquanto houver bot `busy` e `keep_awake = true`, mas com um processo auxiliar que segura o pedido enquanto vive: `caffeinate -i -w ` no macOS; no Linux, `systemd-inhibit --what=idle --mode=block` com um `sh` que espera o daemon sumir (`kill -0` a cada 5 s). Os dois impedem só a suspensão por inatividade, não a pedida pelo dono (tampa, Suspender). O auxiliar sai quando o daemon morre, então uma queda não deixa o computador acordado. Um auxiliar que sai em 200 ms (sem logind, num contêiner) vira erro no log, e o daemon segue | +| Lixeira | Ainda não: avisa que só existe no Windows | ## 15. App desktop