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
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,31 @@ jobs:
fi
done
- run: cargo test
# `botloftd service` against the real systemd or launchd (spec 14.1).
# The Linux runner has no user manager until the user lingers.
- name: The daemon as a service of the system
timeout-minutes: 10
env:
BOTLOFT_SERVICE_TEST: "1"
run: |
if [ "$RUNNER_OS" = Linux ]; then
sudo loginctl enable-linger "$USER"
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus"
for i in $(seq 1 30); do
systemctl --user show-environment > /dev/null 2>&1 && break
sleep 1
done
fi
cargo test -p botloftd --test service -- --nocapture && exit 0
# What the service manager saw, when the test fails.
if [ "$RUNNER_OS" = Linux ]; then
journalctl --user --no-pager -n 60 || true
else
launchctl list | grep -i botloft || true
log show --last 5m --predicate 'subsystem == "com.apple.xpc.launchd"' --style compact 2>/dev/null | grep -i botloft | tail -40 || true
fi
exit 1

app:
name: App (Windows)
Expand Down
15 changes: 15 additions & 0 deletions crates/botloftd/src/autostart/binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ pub fn install(source: &Path, bin_dir: &Path) -> io::Result<Installed> {
}
let staged = bin_dir.join(STAGED);
fs::write(&staged, &bytes)?;
// A written file is not a program off Windows until it may run.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(&staged, fs::Permissions::from_mode(0o755))?;
}
if target.exists() {
fs::rename(&target, aside(bin_dir))?;
}
Expand Down Expand Up @@ -126,6 +132,15 @@ mod tests {
let first = install(&source, &bin).expect("install");
assert!(first.changed);
assert_eq!(fs::read(&first.path).expect("read"), b"v1");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = fs::metadata(&first.path)
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o111, 0o111, "the copy can run");
}
assert!(!install(&source, &bin).expect("again").changed);
assert!(
!install(&first.path, &bin).expect("itself").changed,
Expand Down
12 changes: 7 additions & 5 deletions crates/botloftd/src/autostart/choice.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use anyhow::{Context, bail};
use tracing::{info, warn};

use super::{config, register, stop_task, task_name};
use crate::platform::TASK_KIND;
use crate::platform::{self, Triggers};

const SIGN_IN_FILE: &str = "signin";
Expand Down Expand Up @@ -63,19 +64,20 @@ fn is_new_sign_in(home: &Path) -> bool {
pub fn scheduled_start(home: &Path, start_with_windows: bool) -> bool {
if !start_with_windows && is_new_sign_in(home) {
if let Err(err) = register(home, stopped(false)) {
warn!("cannot take the triggers off the scheduled task: {err:#}");
warn!("cannot take the triggers off the {TASK_KIND}: {err:#}");
}
return false;
}
mark_sign_in(home);
super::keep_running(home);
// Started at sign-in after the owner stopped it: the minute trigger
// comes back.
let wanted = running(start_with_windows);
let task = platform::find_task(&task_name(home));
if task.is_ok_and(|task| task.is_some_and(|task| task.triggers != wanted))
&& let Err(err) = register(home, wanted)
{
warn!("cannot update the scheduled task: {err:#}");
warn!("cannot update the {TASK_KIND}: {err:#}");
}
true
}
Expand All @@ -85,12 +87,12 @@ pub fn scheduled_start(home: &Path, start_with_windows: bool) -> bool {
pub fn stop(home: &Path) -> anyhow::Result<()> {
let name = task_name(home);
if platform::find_task(&name)?.is_none() {
bail!("the scheduled task {name} is not installed; run `botloftd service install`");
bail!("the {TASK_KIND} {name} is not installed; run `botloftd service install`");
}
let config = config(home)?;
register(home, stopped(config.start_with_windows))?;
stop_task(&name, config.port)?;
println!("Stopped the daemon of the scheduled task {name}.");
println!("Stopped the daemon of the {TASK_KIND} {name}.");
Ok(())
}

Expand All @@ -99,7 +101,7 @@ pub fn stop(home: &Path) -> anyhow::Result<()> {
pub fn set_start_with_windows(home: &Path, start: bool) -> anyhow::Result<()> {
let name = task_name(home);
let task = platform::find_task(&name)
.with_context(|| format!("cannot read the scheduled task {name}"))?;
.with_context(|| format!("cannot read the {TASK_KIND} {name}"))?;
if let Some(task) = task {
register(
home,
Expand Down
93 changes: 63 additions & 30 deletions crates/botloftd/src/autostart/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use sha2::{Digest, Sha256};

use crate::config::Config;
use crate::paths;
use crate::platform::{self, TaskDefinition, TaskState, Triggers};
use crate::platform::{self, TASK_KIND, TaskDefinition, TaskState, Triggers};

/// How long a daemon may take to stop or to answer after starting.
const STOP_TIMEOUT: Duration = Duration::from_secs(10);
Expand All @@ -38,13 +38,6 @@ pub fn task_name(home: &Path) -> String {
}
}

/// Quotes one argument for the Windows command line. Backslashes before
/// the closing quote are doubled, so `C:\` does not escape it.
fn quote(arg: &str) -> String {
let trailing = arg.len() - arg.trim_end_matches('\\').len();
format!("\"{arg}{}\"", "\\".repeat(trailing))
}

/// `--scheduled` tells the daemon the task started it (`choice`).
fn definition(home: &Path, program: &Path, triggers: Triggers) -> TaskDefinition {
let home_text = home.to_string_lossy();
Expand All @@ -53,18 +46,51 @@ fn definition(home: &Path, program: &Path, triggers: Triggers) -> TaskDefinition
"Starts the Botloft daemon at logon and keeps it running. Data folder: {home_text}"
),
program: program.to_owned(),
arguments: format!("serve --home {} --scheduled", quote(&home_text)),
arguments: vec![
"serve".to_owned(),
"--home".to_owned(),
home_text.into_owned(),
"--scheduled".to_owned(),
],
working_dir: home.to_owned(),
keep_alive: keep_alive_file(home),
triggers,
}
}

/// While it exists, launchd brings the daemon back on macOS (spec 14.1).
pub fn keep_alive_file(home: &Path) -> PathBuf {
home.join("run").join("keep-alive")
}

/// The daemon runs from its task: launchd should bring it back if it dies,
/// also when it started at sign-in (spec 14.1).
fn keep_running(home: &Path) {
let file = keep_alive_file(home);
let made = file
.parent()
.map_or(Ok(()), std::fs::create_dir_all)
.and_then(|()| std::fs::write(&file, b""));
if let Err(err) = made {
tracing::warn!("cannot write the keep-alive file: {err}");
}
}

/// The daemon stopped cleanly: nothing should bring it back (spec 14.1).
pub fn stopped_cleanly(home: &Path) {
match std::fs::remove_file(keep_alive_file(home)) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => tracing::warn!("cannot delete the keep-alive file: {err}"),
}
}

/// Registers the task with `triggers`, running the installed daemon.
fn register(home: &Path, triggers: Triggers) -> anyhow::Result<()> {
let name = task_name(home);
let program = bin_dir(home).join(binary::EXE_NAME);
platform::register_task(&name, &definition(home, &program, triggers))
.with_context(|| format!("cannot register the scheduled task {name}"))
.with_context(|| format!("cannot register the {TASK_KIND} {name}"))
}

fn config(home: &Path) -> anyhow::Result<Config> {
Expand Down Expand Up @@ -94,7 +120,7 @@ pub fn install(home: &Path) -> anyhow::Result<()> {
// The owner opened Botloft: it runs in this sign-in.
choice::mark_sign_in(home);
println!(
"Scheduled task {name} starts {} at logon.",
"The {TASK_KIND} {name} starts {} at logon.",
installed.path.display()
);

Expand All @@ -111,7 +137,7 @@ pub fn install(home: &Path) -> anyhow::Result<()> {
pub fn restart(home: &Path) -> anyhow::Result<()> {
let name = task_name(home);
if platform::find_task(&name)?.is_none() {
bail!("the scheduled task {name} is not installed; run `botloftd service install`");
bail!("the {TASK_KIND} {name} is not installed; run `botloftd service install`");
}
restart_task(home, port(home)?)
}
Expand All @@ -120,7 +146,7 @@ fn restart_task(home: &Path, port: u16) -> anyhow::Result<()> {
let name = &task_name(home);
stop_task(name, port)?;
choice::mark_sign_in(home);
platform::run_task(name).with_context(|| format!("cannot start the scheduled task {name}"))?;
platform::run_task(name).with_context(|| format!("cannot start the {TASK_KIND} {name}"))?;
let up = health::wait(port, START_TIMEOUT, |health| {
health.is_some_and(|health| health.version == VERSION)
});
Expand All @@ -144,14 +170,14 @@ fn stop_task(name: &str, port: u16) -> anyhow::Result<()> {
if !running {
return Ok(());
}
platform::stop_task(name).with_context(|| format!("cannot stop the scheduled task {name}"))?;
platform::stop_task(name).with_context(|| format!("cannot stop the {TASK_KIND} {name}"))?;
let stopped = health::wait(port, STOP_TIMEOUT, |health| health.is_none())
&& wait_until(STOP_TIMEOUT, || {
platform::find_task(name)
.is_ok_and(|task| task.is_none_or(|task| task.state != TaskState::Running))
});
if !stopped {
bail!("the daemon of the scheduled task {name} did not stop");
bail!("the daemon of the {TASK_KIND} {name} did not stop");
}
Ok(())
}
Expand All @@ -163,9 +189,9 @@ pub fn uninstall(home: &Path) -> anyhow::Result<()> {
let port = port(home).unwrap_or(Config::default().port);
stop_task(&name, port)?;
if platform::delete_task(&name)? {
println!("Deleted the scheduled task {name}.");
println!("Deleted the {TASK_KIND} {name}.");
} else {
println!("The scheduled task {name} was not installed.");
println!("The {TASK_KIND} {name} was not installed.");
}
binary::remove(&bin_dir(home));
println!("Your bots and data stay in {}.", home.display());
Expand All @@ -183,12 +209,12 @@ pub fn status(home: &Path) -> anyhow::Result<()> {
TaskState::Disabled => "disabled",
TaskState::Unknown => "in an unknown state",
};
println!("Scheduled task {name}: installed, {state}.");
println!("The {TASK_KIND} {name} is installed, {state}.");
if let Some(command) = task.command {
println!(" {command}");
}
}
None => println!("Scheduled task {name}: not installed."),
None => println!("The {TASK_KIND} {name} is not installed."),
}
let port = port(home)?;
match health::probe(port) {
Expand Down Expand Up @@ -218,19 +244,26 @@ mod tests {
use super::*;

#[test]
fn arguments_are_quoted_for_the_command_line() {
fn the_task_serves_its_data_folder() {
let home = Path::new("/data/Ana Lima");
let task = definition(home, &home.join("bin/botloftd"), choice::running(true));
assert_eq!(
quote(r"C:\Users\Ana Lima\Botloft"),
r#""C:\Users\Ana Lima\Botloft""#
);
assert_eq!(quote(r"D:\"), r#""D:\\""#);
let task = definition(
Path::new(r"C:\data"),
Path::new(r"C:\data\bin\botloftd.exe"),
choice::running(true),
task.arguments,
["serve", "--home", "/data/Ana Lima", "--scheduled"]
);
assert_eq!(task.arguments, r#"serve --home "C:\data" --scheduled"#);
assert!(task.description.ends_with(r"Data folder: C:\data"));
assert_eq!(task.keep_alive, home.join("run").join("keep-alive"));
assert!(task.description.ends_with("Data folder: /data/Ana Lima"));
}

#[test]
fn a_clean_stop_deletes_the_keep_alive_file() {
let home = tempfile::tempdir().expect("home");
let file = keep_alive_file(home.path());
std::fs::create_dir_all(file.parent().expect("run")).expect("run");
std::fs::write(&file, b"").expect("write");
stopped_cleanly(home.path());
assert!(!file.exists());
stopped_cleanly(home.path());
}

#[test]
Expand Down
3 changes: 3 additions & 0 deletions crates/botloftd/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ fn run(paths: Paths, config: Config, config_path: PathBuf, scheduled: bool) -> a
));
server::serve(Arc::clone(&daemon), listener, platform::shutdown_signal()).await?;
daemon.supervisor.shutdown();
// A stop asked for (logout, `service stop`): launchd must not
// bring the daemon back (spec 14.1).
autostart::stopped_cleanly(&daemon.paths.home);
info!("stopped");
anyhow::Ok(())
})
Expand Down
22 changes: 14 additions & 8 deletions crates/botloftd/src/platform/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,32 +12,38 @@ use std::path::{Path, PathBuf};

#[cfg(unix)]
pub use unix::{
KeepAwake, ProcessJob, 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,
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,
};
#[cfg(windows)]
pub use windows::{
KeepAwake, ProcessJob, 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,
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,
};

/// A program the system starts for the owner (spec 14).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TaskDefinition {
pub description: String,
pub program: PathBuf,
/// Already quoted for the command line.
pub arguments: String,
pub arguments: Vec<String>,
pub working_dir: PathBuf,
/// On macOS, launchd keeps the program running while this file exists
/// (spec 14.1). The program deletes it when it stops cleanly.
pub keep_alive: PathBuf,
pub triggers: Triggers,
}

/// When the system starts the task (spec 14).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Triggers {
/// When the owner signs in to Windows.
/// When the owner signs in.
pub logon: bool,
/// Every minute, which brings the program back soon after it stops.
/// Brings the program back soon after it stops: a trigger every minute
/// on Windows. On Linux and macOS the service manager restarts it while
/// it runs, so this is always on there (14.1).
pub watchdog: bool,
}

Expand Down
Loading
Loading