Skip to content

Commit 5d99808

Browse files
committed
feat(platform): keep the computer awake while bots work on Linux and macOS
A helper process holds the request while any bot is busy: caffeinate -i on macOS, an idle lock of systemd-inhibit on Linux. Like the power request on Windows it only stops sleep after inactivity, and it ends with the daemon. A helper that stops at once (no logind) is an error in the log.
1 parent f06e11e commit 5d99808

4 files changed

Lines changed: 122 additions & 15 deletions

File tree

‎crates/botloftd/src/keep_awake.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ mod tests {
111111
assert_eq!(*calls.lock().expect("calls"), [true, false]);
112112
}
113113

114+
// Off Windows the helper may be missing (a container); its own test
115+
// covers it there.
116+
#[cfg(windows)]
114117
#[test]
115118
fn a_keep_awake_request_can_be_set_and_cleared() {
116119
let mut request = KeepAwake::new().expect("request");

‎crates/botloftd/src/platform/unix.rs‎

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ mod job;
88
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
99
mod launchd;
1010
mod orphans;
11+
mod power;
1112
#[cfg_attr(target_os = "macos", allow(dead_code))]
1213
mod systemd;
1314

@@ -23,6 +24,7 @@ pub use self::job::ProcessJob;
2324
#[cfg(target_os = "macos")]
2425
pub use self::launchd::{TASK_KIND, delete_task, find_task, register_task, run_task, stop_task};
2526
pub use self::orphans::track_groups;
27+
pub use self::power::KeepAwake;
2628
#[cfg(not(target_os = "macos"))]
2729
pub use self::systemd::{TASK_KIND, delete_task, find_task, register_task, run_task, stop_task};
2830

@@ -91,20 +93,6 @@ pub async fn shutdown_signal() {
9193
}
9294
}
9395

94-
/// Nothing keeps a Unix machine awake yet.
95-
#[derive(Debug)]
96-
pub struct KeepAwake;
97-
98-
impl KeepAwake {
99-
pub fn new() -> io::Result<Self> {
100-
Ok(Self)
101-
}
102-
103-
pub fn set(&mut self, _on: bool) -> io::Result<()> {
104-
Ok(())
105-
}
106-
}
107-
10896
/// Nothing to leave: Unix never opens a console for the daemon.
10997
pub fn leave_own_console() {}
11098

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
//! Keeps the computer from sleeping while bots work, off Windows (spec
2+
//! 14.1): `caffeinate -i` on macOS, an idle lock of `systemd-inhibit` on
3+
//! Linux, held by a helper process for as long as it is wanted. Like the
4+
//! power request on Windows, it only stops sleep after inactivity: closing
5+
//! the lid or choosing Sleep still works. The helper also ends when the
6+
//! daemon does, so a crash does not keep the computer awake.
7+
8+
use std::io;
9+
use std::process::{Child, Command, Stdio};
10+
use std::time::Duration;
11+
12+
#[cfg_attr(target_os = "macos", allow(dead_code))]
13+
const REASON: &str = "Botloft bots are working";
14+
/// A helper that fails (no logind, say) exits at once; one still running
15+
/// after this holds the lock.
16+
const SETTLE: Duration = Duration::from_millis(200);
17+
18+
#[derive(Debug, Default)]
19+
pub struct KeepAwake {
20+
helper: Option<Child>,
21+
}
22+
23+
impl KeepAwake {
24+
pub fn new() -> io::Result<Self> {
25+
Ok(Self::default())
26+
}
27+
28+
pub fn set(&mut self, on: bool) -> io::Result<()> {
29+
if on == self.running() {
30+
return Ok(());
31+
}
32+
if !on {
33+
self.stop();
34+
return Ok(());
35+
}
36+
let mut helper = helper(std::process::id())
37+
.stdin(Stdio::null())
38+
.stdout(Stdio::null())
39+
.stderr(Stdio::null())
40+
.spawn()?;
41+
std::thread::sleep(SETTLE);
42+
if let Some(status) = helper.try_wait()? {
43+
return Err(io::Error::other(format!(
44+
"the keep-awake helper stopped at once ({status})"
45+
)));
46+
}
47+
self.helper = Some(helper);
48+
Ok(())
49+
}
50+
51+
/// Whether the helper holds the lock now.
52+
fn running(&mut self) -> bool {
53+
self.helper
54+
.as_mut()
55+
.is_some_and(|helper| matches!(helper.try_wait(), Ok(None)))
56+
}
57+
58+
fn stop(&mut self) {
59+
if let Some(mut helper) = self.helper.take() {
60+
let _ = helper.kill();
61+
let _ = helper.wait();
62+
}
63+
}
64+
}
65+
66+
impl Drop for KeepAwake {
67+
fn drop(&mut self) {
68+
self.stop();
69+
}
70+
}
71+
72+
/// `-w` ends `caffeinate` with the daemon.
73+
#[cfg(target_os = "macos")]
74+
fn helper(daemon: u32) -> Command {
75+
let mut command = Command::new("caffeinate");
76+
command.args(["-i", "-w", &daemon.to_string()]);
77+
command
78+
}
79+
80+
/// The lock lasts while the shell it runs does, and the shell watches the
81+
/// daemon.
82+
#[cfg(not(target_os = "macos"))]
83+
fn helper(daemon: u32) -> Command {
84+
let mut command = Command::new("systemd-inhibit");
85+
command.args([
86+
"--what=idle",
87+
"--who=Botloft",
88+
&format!("--why={REASON}"),
89+
"--mode=block",
90+
"sh",
91+
"-c",
92+
&format!("while kill -0 {daemon} 2>/dev/null; do sleep 5; done"),
93+
]);
94+
command
95+
}
96+
97+
#[cfg(test)]
98+
mod tests {
99+
use super::*;
100+
101+
#[test]
102+
fn the_helper_runs_only_while_wanted() {
103+
let mut awake = KeepAwake::new().expect("keep awake");
104+
if let Err(err) = awake.set(true) {
105+
// No `caffeinate` or no logind here (a container, say).
106+
eprintln!("cannot keep this computer awake ({err}); skipping");
107+
return;
108+
}
109+
assert!(awake.running());
110+
awake.set(true).expect("again");
111+
assert!(awake.running(), "asking twice keeps one helper");
112+
awake.set(false).expect("off");
113+
assert!(!awake.running());
114+
}
115+
}

‎docs/spec.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -711,7 +711,8 @@ Em fatias (18, item 8). O que já vale fora do Windows:
711711
| Iniciar com o sistema (Linux) | `botloftd service install` grava a unit `~/.config/systemd/user/<nome>.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 |
712712
| Iniciar com o sistema (macOS) | Um launch agent em `~/Library/LaunchAgents/io.github.httpsphl.botloft.daemon[-<8 hex>].plist`, no domínio `gui/<uid>`. `RunAtLoad` segue `start_with_windows`. O `KeepAlive` é `PathState` com o arquivo `<home>/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`) |
713713
| 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` |
714-
| Não suspender, Lixeira | Ainda não: avisam que só existem no Windows |
714+
| 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 <pid do daemon>` 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 |
715+
| Lixeira | Ainda não: avisa que só existe no Windows |
715716

716717
## 15. App desktop
717718

0 commit comments

Comments
 (0)