wsb 0.253.6349

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! Running work where the OS can account for it separately.
//!
//! # What went wrong
//!
//! A daemon started from a terminal inherits that terminal's cgroup, and so
//! does everything it starts. On a systemd desktop that cgroup is a transient
//! scope belonging to the terminal — `app-ghostty-surface-…scope` — and
//! `systemd-oomd` watches cgroups, not processes: when memory ran short during
//! a `cargo build` the scope was the worst offender at 21.8 GB, so oomd killed
//! processes in it. The build was in it. So was the daemon. So was the
//! terminal.
//!
//! The daemon did not use that memory. It was standing next to something that
//! did, and to the OS they were one thing.
//!
//! # What a terminal does differently
//!
//! Nothing — and that is the point. A build typed into a terminal is in that
//! terminal's scope too, so it can be killed the same way; it just takes down
//! a shell rather than a long-lived server that every other surface depends
//! on. And each terminal window gets its own scope, so one runaway build never
//! reaches the next window.
//!
//! # What this does
//!
//! Puts the daemon in a scope of its own, marked so that the OS's memory
//! reaper prefers anything else — a server holding no memory of its own should
//! not be collateral for a compiler that does.
//!
//! Where none of this exists — macOS, a machine without systemd, a session
//! with no bus — the command is run exactly as before. The protection is an
//! improvement where it is available, never a requirement.

use std::sync::OnceLock;

/// Properties for the daemon's own scope.
///
/// `ManagedOOMPreference=avoid` asks systemd's reaper to look elsewhere first.
/// It is a preference, not immunity — nothing can promise a process survives
/// an out-of-memory — but the daemon is exactly the process that should be
/// last, since it uses almost nothing and everything else is reachable through
/// it.
pub const DAEMON_PROPERTIES: &[&str] = &["ManagedOOMPreference=avoid"];

/// Whether this machine can put a command in its own scope.
///
/// Probed once, by running one, because every part of the answer can be false
/// on a machine that otherwise looks right: `systemd-run` present but no user
/// session, a session but no bus address in this process's environment, a
/// container with cgroups it may not write. Asking is one short-lived process
/// per daemon; guessing from `$XDG_RUNTIME_DIR` was wrong on the first machine
/// it would have been tried on.
pub fn available() -> bool {
    static AVAILABLE: OnceLock<bool> = OnceLock::new();
    *AVAILABLE.get_or_init(|| {
        if !cfg!(target_os = "linux") {
            return false;
        }
        crate::git_backend::command_without_repo_scope("systemd-run")
            .args(["--user", "--scope", "--collect", "--quiet", "--", "true"])
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .status()
            .map(|status| status.success())
            .unwrap_or(false)
    })
}

/// The argv that runs `argv` inside its own scope, or None to run it as it is.
///
/// `unit` names the scope, so `systemctl --user status` and a cgroup listing
/// say what the thing IS rather than `run-p12345`. Names are made safe here
/// rather than trusted: a unit name with a slash in it is refused by systemd
/// at spawn time, which would turn a naming mistake into a daemon that will
/// not start.
pub fn wrap(argv: &[String], unit: &str, properties: &[&str]) -> Option<Vec<String>> {
    if argv.is_empty() || !available() {
        return None;
    }
    let mut wrapped = vec![
        "systemd-run".to_string(),
        "--user".to_string(),
        "--scope".to_string(),
        // Removed as soon as it exits, so a day of restarts does not leave a
        // day of dead units behind.
        "--collect".to_string(),
        // systemd-run announces the unit it made on stderr. Under
        // `--jsonl` that line would arrive beside the records as
        // the one thing in the run that is not one.
        "--quiet".to_string(),
        format!("--unit={}", unit_name(unit)),
    ];
    for property in properties {
        wrapped.push(format!("--property={property}"));
    }
    wrapped.push("--".to_string());
    wrapped.extend(argv.iter().cloned());
    Some(wrapped)
}

/// A short, stable tag for one workspace, so its daemon's scope is named for
/// the tree it serves rather than for a pid.
///
/// `systemctl --user status` and a cgroup listing are read by somebody trying
/// to work out what is using a machine; `wsb-daemon-machinefabric` answers
/// that and `run-p31034` does not.
pub fn workspace_tag(workspace_root: &std::path::Path) -> String {
    workspace_root
        .file_name()
        .and_then(|name| name.to_str())
        .map(unit_name)
        .unwrap_or_else(|| "workspace".to_string())
}

/// A systemd unit name: letters, digits, dashes and underscores.
/// The unit systemd will know a scope by, given the tag it was wrapped with.
pub fn unit_name(name: &str) -> String {
    let mut out = String::with_capacity(name.len());
    for ch in name.chars() {
        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
            out.push(ch);
        } else if !out.ends_with('-') {
            out.push('-');
        }
    }
    let trimmed = out.trim_matches('-').to_string();
    if trimmed.is_empty() {
        "wsb".to_string()
    } else {
        trimmed
    }
}

#[cfg(test)]
#[path = "scope_tests.rs"]
mod tests;