ref:6eba8b7b69ccd923f7202d7d16d5102dce800bf1

Windows support (rebased): cross-compile MSVC via cargo-xwin (#47)

Rebase of #32 (Windows support for the CLI + CI runner) onto current main, with the build reworked to fit the per-ISA pipeline. ## What changed vs #32 #32 was 30 commits behind and built Windows **natively** (`runs_on: [windows]`, MSVC, bare) — but that Windows runner is offline, and there's a **bootstrap deadlock**: you can't build the runner-building-runner without a working Windows `anvil.exe`. So this **cross-compiles `x86_64-pc-windows-msvc` from the amd64 Linux worker** with cargo-xwin (fetches Microsoft's CRT/SDK, links via clang/lld — no Windows host). MSVC is the native target of #32's `windows-service`/`windows-sys` crates, so it's the low-risk ABI. Once this ships a Windows binary, `DESKTOP-01APN6V` can come online for native Windows *test* runs later. ## Kept from #32 (merges clean, compiles + tests green on linux) - `platform/` abstraction (unix/windows backends behind one API) — drops the ungated `libc::` calls I flagged in the audit. - Windows Service (SCM) support (`service_windows.rs`, dispatcher in `main.rs`). - `update.rs` Windows naming + `.exe` swap; `executor.rs` Windows-container path; `shutdown.rs`/`pid_file.rs`/`detach.rs` via the platform layer. ## Dropped from #32 (superseded by the per-ISA pipeline) - Old single-`build-runner` `.anvil.yml`, `ci/build-macos.sh`, `ci/build-windows.ps1`. ## Wiring - amd64 build job's `prepare` gains clang/lld/llvm + cargo-xwin 0.23.0 + the msvc target + a warmed xwin SDK (baked into the image). - `build-arch.sh` builds `anvil_windows_amd64` on the amd64 path; `publish-release.sh` folds it into the SHA256SUMS + fan-in. ## Validation - Linux: compiles, `fmt`, `clippy -D warnings`, 193 tests — all green locally. - **The Windows compile is validated by this PR's CI**, not locally (no MSVC SDK here). Publishing is main-only, so this PR run builds the Windows binary (proving the cross-compile + `windows-service` link under cargo-xwin) without cutting a release. First run rebuilds the amd64 prepared image (cargo-xwin + SDK), so expect it slow. ## Deferred - arm64 Windows. - Native Windows CI on a WSL host (WSL detection/bridge) — fangorn/anvil-cli#45.
SHA: 6eba8b7b69ccd923f7202d7d16d5102dce800bf1
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-07-23 17:15
Parents: dea1c88
21 files changed +1232 -192
Type
.anvil.yml +22 −4
@@ -53,12 +53,16 @@
# The cross toolchain is baked into a cached prepared image (fangorn/anvil#354).
# Version pins live in the prepare commands on purpose: the image's cache key
# hashes those command strings, so a bump must change a command to rebuild —
# bumping a value in the shell scripts would not. Both jobs use the identical
# bumping a value in the shell scripts would not.
#
# The amd64 worker additionally cross-compiles Windows (x86_64-pc-windows-msvc)
# with cargo-xwin, which fetches Microsoft's CRT/SDK and links with clang/lld —
# no Windows host needed (fangorn/anvil-cli#32). So its prepare is a superset of
# the arm64 one, and the two blocks are no longer shared.
# prepare block (same image content, built once per worker).
- name: build-arm64
timeout_seconds: 3600
runs_on: [linux, arm64]
prepare:
prepare: &runner_toolchain
- apt-get update && apt-get install -y --no-install-recommends bash curl jq xz-utils ca-certificates
# zig 0.15.2 — 0.14.x has a macho-linker regression that can't resolve
# -liconv/-lcharset for apple-darwin under rust >= 1.82.
@@ -82,10 +86,24 @@
- name: build-amd64
timeout_seconds: 3600
runs_on: [linux, amd64]
prepare: *runner_toolchain
prepare:
- apt-get update && apt-get install -y --no-install-recommends bash curl jq xz-utils ca-certificates
- curl -sSL "https://ziglang.org/download/0.15.2/zig-$(uname -m)-linux-0.15.2.tar.xz" -o /tmp/zig.tar.xz && mkdir -p /opt/zig && tar -xJf /tmp/zig.tar.xz -C /opt/zig --strip-components=1 && rm /tmp/zig.tar.xz
- cargo install --locked cargo-zigbuild --version 0.23.0
- rustup target add aarch64-unknown-linux-gnu x86_64-unknown-linux-gnu aarch64-apple-darwin x86_64-apple-darwin
- curl -sSL "https://github.com/joseluisq/macosx-sdks/releases/download/12.3/MacOSX12.3.sdk.tar.xz" -o /tmp/macos-sdk.tar.xz && mkdir -p /opt && tar -xJf /tmp/macos-sdk.tar.xz -C /opt && rm /tmp/macos-sdk.tar.xz
# Windows cross-compile (x86_64-pc-windows-msvc) via cargo-xwin: clang/lld
# link against Microsoft's CRT/SDK, which xwin downloads once into /opt/xwin
# (warmed below so it is baked into the image, not fetched every build).
- apt-get install -y --no-install-recommends clang lld llvm
- cargo install --locked cargo-xwin --version 0.23.0
- rustup target add x86_64-pc-windows-msvc
- mkdir -p /tmp/xwarm && cd /tmp/xwarm && cargo init --name xwarm -q && XWIN_ACCEPT_LICENSE=1 XWIN_CACHE_DIR=/opt/xwin cargo xwin build --release --target x86_64-pc-windows-msvc && cd / && rm -rf /tmp/xwarm
run: exec bash ci/build-arch.sh amd64
depends_on: [test, clippy, fmt]
artifacts:
- name: anvil_windows_amd64
path: runner-dist/anvil_windows_amd64
- name: anvil_linux_amd64
path: runner-dist/anvil_linux_amd64
- name: anvil_macos_amd64
.gitattributes +5 −0
@@ -1,0 +1,5 @@
# Enforce LF line endings repo-wide, on every platform. Without this, a
# checkout on Windows (core.autocrlf=true) rewrites files to CRLF in the
# working tree. `text=auto` lets git auto-detect binary files and leave them
# untouched.
* text=auto eol=lf
Cargo.lock +19 −0
@@ -93,5 +93,7 @@
"tokio",
"tokio-util",
"url",
"windows-service",
"windows-sys 0.59.0",
"wiremock",
]
@@ -2073,6 +2075,12 @@
]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2120,6 +2128,17 @@
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-service"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a"
dependencies = [
"bitflags",
"widestring",
"windows-sys 0.52.0",
]
[[package]]
Cargo.toml +13 −1
@@ -25,10 +25,22 @@
percent-encoding = "2"
thiserror = "2"
hostname = "0.4"
libc = "0.2"
futures = "0.3"
glob = "0.3"
sha2 = "0.10"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-service = "0.7"
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_System_Threading",
"Win32_System_Console",
"Win32_Security",
"Win32_Storage_FileSystem",
] }
[dev-dependencies]
wiremock = "0.6"
ci/build-arch.sh +23 −2
@@ -17,6 +17,10 @@
ARCH="${1:?usage: build-arch.sh <arm64|amd64>}"
# WIN_TARGET is set only where we ship a Windows build. Today that's amd64
# (cross-compiled via cargo-xwin); arm64 Windows is deferred.
WIN_TARGET=""
WIN_NAME=""
case "$ARCH" in
arm64)
LINUX_TARGET=aarch64-unknown-linux-gnu
@@ -29,6 +33,8 @@
MAC_TARGET=x86_64-apple-darwin
LINUX_NAME=anvil_linux_amd64
MAC_NAME=anvil_macos_amd64
WIN_TARGET=x86_64-pc-windows-msvc
WIN_NAME=anvil_windows_amd64
;;
*)
echo "ERROR: unknown arch '$ARCH' (want arm64 or amd64)" >&2
@@ -83,6 +89,16 @@
LINUX_BIN="target/${LINUX_TARGET}/release/anvil"
MAC_BIN="target/${MAC_TARGET}/release/anvil"
# Windows (amd64 only): cross-compile the MSVC target with cargo-xwin, linking
# against the SDK the prepare step cached in /opt/xwin. No Windows host needed.
WIN_BIN=""
if [ -n "$WIN_TARGET" ]; then
echo "==> Building windows $ARCH ($WIN_TARGET, cargo-xwin)..."
XWIN_ACCEPT_LICENSE=1 XWIN_CACHE_DIR=/opt/xwin \
cargo xwin build --release --target "$WIN_TARGET" 2>&1
WIN_BIN="target/${WIN_TARGET}/release/anvil.exe"
fi
# Hard-gate the glibc floor on the Linux binary.
assert_glibc_floor() {
local bin="$1" max
@@ -104,6 +120,7 @@
mkdir -p runner-dist
cp "$LINUX_BIN" "runner-dist/$LINUX_NAME"
cp "$MAC_BIN" "runner-dist/$MAC_NAME"
[ -n "$WIN_BIN" ] && cp "$WIN_BIN" "runner-dist/$WIN_NAME"
if [ "$PUBLISH" != "1" ]; then
exit 0
@@ -120,15 +137,19 @@
"$ANVIL" release create \
--tag "$VERSION" \
--title "anvil-cli $VERSION" \
--body "Runner binaries for linux/{amd64,arm64}, macos/{amd64,arm64}, windows/amd64." \
--body "Runner binaries for linux/{amd64,arm64} and macos/{amd64,arm64}." \
--draft \
--repo fangorn/anvil-cli || true
fi
# Upload this ISA's two binaries to the draft.
# Upload this ISA's binaries to the draft (the amd64 job also ships Windows).
cp "$LINUX_BIN" "runner-dist/${LINUX_NAME}_${VERSION}"
cp "$MAC_BIN" "runner-dist/${MAC_NAME}_${VERSION}"
"$ANVIL" release upload "$VERSION" "runner-dist/${LINUX_NAME}_${VERSION}" --repo fangorn/anvil-cli
"$ANVIL" release upload "$VERSION" "runner-dist/${MAC_NAME}_${VERSION}" --repo fangorn/anvil-cli
if [ -n "$WIN_BIN" ]; then
cp "$WIN_BIN" "runner-dist/${WIN_NAME}_${VERSION}"
"$ANVIL" release upload "$VERSION" "runner-dist/${WIN_NAME}_${VERSION}" --repo fangorn/anvil-cli
fi
echo "==> Uploaded $ARCH binaries to draft release $VERSION"
ci/publish-release.sh +1 −1
@@ -65,7 +65,7 @@
chmod +x ./anvil
ANVIL="$PWD/anvil"
ASSETS="anvil_linux_arm64 anvil_linux_amd64 anvil_macos_arm64 anvil_macos_amd64"
ASSETS="anvil_linux_arm64 anvil_linux_amd64 anvil_macos_arm64 anvil_macos_amd64 anvil_windows_amd64"
# Pull the four binaries the build jobs uploaded. A missing one fails the
# download and trips the rollback — so an incomplete draft never gets published.
src/commands/runner.rs +173 −69
@@ -538,56 +538,51 @@
Ok(())
}
runner::pid_file::Existing::Live(pid) => {
output::info(&format!("Sending SIGTERM to runner (PID {pid})"));
#[cfg(unix)]
unsafe {
if libc::kill(pid, libc::SIGTERM) != 0 {
return Err(format!(
"kill(SIGTERM, {pid}) failed: {}",
// Request graceful shutdown: SIGTERM on Unix; on Windows, set
// the named stop-event the runner waits on (derived from the
// PID-file path). If no runner is listening on the event we
// still fall through to the poll/force path.
output::info(&format!(
"Requesting graceful shutdown of runner (PID {pid})"
));
if let Err(e) = crate::platform::request_graceful_stop(pid, &pid_path) {
if !force {
return Err(format!("failed to signal runner (PID {pid}): {e}").into());
std::io::Error::last_os_error()
)
.into());
}
output::warn(&format!(
"graceful stop signal failed ({e}); will force-kill"
));
}
#[cfg(not(unix))]
return Err("`runner stop` requires Unix signals; not supported on this OS".into());
// Poll for PID-file removal (signals clean exit from the Guard
// Drop) OR process death (signals the process exited but
// didn't get to clean up — e.g. SIGKILL'd elsewhere).
// didn't get to clean up — e.g. killed elsewhere).
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
while std::time::Instant::now() < deadline {
if !pid_path.exists() {
output::success(&format!("Runner stopped (PID {pid})"));
return Ok(());
}
if !crate::platform::is_alive(pid) {
let _ = std::fs::remove_file(&pid_path);
output::success(&format!(
"Runner exited (PID {pid}); cleaned up stale PID file"
));
#[cfg(unix)]
{
let alive = unsafe { libc::kill(pid, 0) } == 0;
if !alive {
let _ = std::fs::remove_file(&pid_path);
output::success(&format!(
"Runner exited (PID {pid}); cleaned up stale PID file"
return Ok(());
));
return Ok(());
}
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
if force {
output::warn(&format!("Timeout reached; sending SIGKILL to PID {pid}"));
#[cfg(unix)]
unsafe {
libc::kill(pid, libc::SIGKILL);
}
output::warn(&format!("Timeout reached; force-killing PID {pid}"));
let _ = crate::platform::force_kill(pid);
let _ = std::fs::remove_file(&pid_path);
output::success(&format!("Runner force-killed (PID {pid})"));
Ok(())
} else {
Err(format!(
"Runner (PID {pid}) did not exit within {timeout_secs}s. \
Re-run with --force to send SIGKILL."
Re-run with --force to force-kill."
)
.into())
}
@@ -853,6 +848,15 @@
// If a service is installed, route by mode.
if let Some(record) = runner::service_mode::load(instance) {
// Windows: the SCM service redirects stdout/stderr to a flat log
// file; tail it directly.
#[cfg(windows)]
{
let _ = &record;
let log = runner::service_windows::service_log_path(instance);
return tail_file(&log, follow, lines);
}
#[cfg(not(windows))]
if cfg!(target_os = "macos") {
// Look up the StandardOutPath we wrote into the plist by
// instance. Pattern matches install_launchd().
@@ -914,6 +918,7 @@
.into())
}
#[cfg(not(windows))]
fn tail_file(
path: &std::path::Path,
follow: bool,
@@ -941,6 +946,45 @@
}
}
/// Windows has no `tail`. For a one-shot read we print the last `lines`
/// lines ourselves; for `--follow` we delegate to PowerShell's
/// `Get-Content -Wait`, which is the idiomatic streaming tail.
#[cfg(windows)]
fn tail_file(
path: &std::path::Path,
follow: bool,
lines: u32,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if !path.exists() {
return Err(format!("log file not found: {}", path.display()).into());
}
if follow {
let script = format!(
"Get-Content -LiteralPath '{}' -Tail {} -Wait",
path.display().to_string().replace('\'', "''"),
lines
);
let status = std::process::Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.status();
return match status {
Ok(s) if s.success() => Ok(()),
Ok(s) => Err(format!("powershell Get-Content exited {s}").into()),
Err(e) => Err(format!("failed to exec powershell: {e}").into()),
};
}
// One-shot: read the file and print the last `lines` lines.
let content = std::fs::read_to_string(path)?;
let all: Vec<&str> = content.lines().collect();
let start = all.len().saturating_sub(lines as usize);
for line in &all[start..] {
println!("{line}");
}
Ok(())
}
async fn doctor(
config_path: Option<&str>,
pid_file: Option<&str>,
@@ -1021,7 +1065,9 @@
checks.push(("Process state".into(), pid_check));
// 5. Service-mode consistency: if there's a service install, ensure
// its unit file actually exists on disk. Windows has no on-disk unit
// (the SCM holds the registration), so this check is Unix-only.
#[cfg(not(windows))]
// its unit file actually exists on disk.
if let Some(ref r) = install {
let unit_check = if r.unit_path.exists() {
Ok(format!("unit file present at {}", r.unit_path.display()))
@@ -1206,10 +1252,24 @@
fn unit_or_label_for(instance: &str) -> String {
if cfg!(target_os = "macos") {
format!("com.anvil.runner.{instance}")
} else if cfg!(windows) {
// SCM service name (no `.service` suffix).
format!("anvil-runner-{instance}")
} else {
format!("anvil-runner-{instance}.service")
}
}
/// Human-readable name of the OS service manager, for status/log messages.
fn service_kind() -> &'static str {
if cfg!(windows) {
"Windows"
} else if cfg!(target_os = "macos") {
"launchd"
} else {
"systemd"
}
}
fn service_install(opts: InstallOpts<'_>) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use runner::service_mode::{InstallRecord, Scope};
@@ -1223,11 +1283,16 @@
.or(cfg.as_ref().map(|c| c.parallel))
.unwrap_or(1);
let is_root = unix_is_root();
let is_root = crate::platform::is_elevated();
let scope = opts
.scope
.unwrap_or(if is_root { Scope::System } else { Scope::User });
// On Unix a system-scope service must declare the account it runs as
// (we refuse to run as root). On Windows, system scope maps to
// LocalSystem and user scope to an optional named logon account, so
// this check doesn't apply.
#[cfg(not(windows))]
if matches!(scope, Scope::System) && opts.user_account.is_none() {
return Err(
"--scope=system requires --user-account <name>; refusing to install a system \
@@ -1248,27 +1313,37 @@
let unit_or_label = unit_or_label_for(opts.instance);
// Exactly one of these compiles per platform.
#[cfg(windows)]
let unit_path = runner::service_windows::install(
&exe,
&config_file,
scope,
opts.user_account,
parallel,
opts.instance,
&unit_or_label,
)?;
#[cfg(target_os = "macos")]
let unit_path = install_launchd(
&exe,
&config_file,
scope,
opts.user_account,
parallel,
opts.instance,
&unit_or_label,
)?;
#[cfg(all(unix, not(target_os = "macos")))]
let unit_path = install_systemd(
&exe,
&config_file,
scope,
opts.user_account,
let unit_path = if cfg!(target_os = "macos") {
install_launchd(
&exe,
&config_file,
scope,
opts.user_account,
parallel,
opts.instance,
&unit_or_label,
)?
} else {
install_systemd(
&exe,
&config_file,
scope,
opts.user_account,
parallel,
opts.instance,
&unit_or_label,
parallel,
opts.instance,
&unit_or_label,
)?;
)?
};
runner::service_mode::save(&InstallRecord {
instance: opts.instance.to_string(),
@@ -1282,11 +1357,7 @@
output::success(&format!(
"Installed {} service '{}' ({} scope): {}",
if cfg!(target_os = "macos") {
service_kind(),
"launchd"
} else {
"systemd"
},
opts.instance,
scope.as_str(),
unit_path.display()
@@ -1303,6 +1374,7 @@
Ok(())
}
#[cfg(all(unix, not(target_os = "macos")))]
#[allow(clippy::too_many_arguments)]
fn install_systemd(
exe: &std::path::Path,
@@ -1417,6 +1489,7 @@
Ok(unit_path)
}
#[cfg(target_os = "macos")]
#[allow(clippy::too_many_arguments)]
fn install_launchd(
exe: &std::path::Path,
@@ -1522,7 +1595,29 @@
Ok(plist_path)
}
#[cfg(windows)]
fn service_uninstall(instance: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let record = runner::service_mode::load(instance).ok_or_else(|| {
format!("no service install recorded for '{instance}'; nothing to uninstall")
})?;
if let Err(e) = runner::service_windows::uninstall(&record) {
// A failed delete is usually "already gone"; warn and proceed to
// clear local state so reinstall starts clean.
output::warn(&format!("service delete: {e}"));
}
runner::service_mode::clear(instance)?;
output::success(&format!(
"Uninstalled Windows service '{}' ({} scope)",
instance,
record.scope.as_str()
));
Ok(())
}
#[cfg(not(windows))]
fn service_uninstall(instance: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use runner::service_mode::Scope;
let record = runner::service_mode::load(instance).ok_or_else(|| {
@@ -1566,10 +1661,25 @@
Ok(())
}
#[cfg(windows)]
fn service_cmd(
action: &str,
instance: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let record = runner::service_mode::load(instance).ok_or_else(|| {
format!(
"no service install recorded for '{instance}'; \
run `anvil runner service install` first"
)
})?;
runner::service_windows::control(action, &record)
}
#[cfg(not(windows))]
fn service_cmd(
action: &str,
instance: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use runner::service_mode::Scope;
let record = runner::service_mode::load(instance).ok_or_else(|| {
@@ -1764,35 +1874,29 @@
Err(format!("launchctl print failed: {stderr}").into())
}
// Stubs for the non-macOS Unix (systemd) build, where the launchctl
// helpers are referenced from the runtime `cfg!(target_os = "macos")`
#[cfg(not(target_os = "macos"))]
// branch but never actually called. Not needed on Windows, which has its
// own service control path.
#[cfg(all(unix, not(target_os = "macos")))]
fn launchctl_bootout(
_record: &runner::service_mode::InstallRecord,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Err("launchctl unavailable on this platform".into())
}
#[cfg(not(target_os = "macos"))]
#[cfg(all(unix, not(target_os = "macos")))]
fn launchctl_bootstrap(
_record: &runner::service_mode::InstallRecord,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Err("launchctl unavailable on this platform".into())
}
#[cfg(not(target_os = "macos"))]
#[cfg(all(unix, not(target_os = "macos")))]
fn launchctl_print(
_record: &runner::service_mode::InstallRecord,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Err("launchctl unavailable on this platform".into())
}
#[cfg(unix)]
fn unix_is_root() -> bool {
(unsafe { libc::getuid() }) == 0
}
#[cfg(not(unix))]
fn unix_is_root() -> bool {
false
}
// === Admin commands (PAT auth) ===
src/commands/update.rs +48 −11
@@ -82,6 +82,7 @@
let os = match std::env::consts::OS {
"linux" => "Linux",
"macos" => "Darwin",
"windows" => "Windows",
other => return Err(format!("unsupported OS: {other}").into()),
};
let arch = match std::env::consts::ARCH {
@@ -93,13 +94,16 @@
}
/// Server's release-asset naming convention: `anvil_{target}_{version}` where
/// target is one of `linux_amd64`, `linux_arm64`, `macos_amd64`, `macos_arm64`.
/// target is one of `linux_amd64`, `linux_arm64`, `macos_amd64`, `macos_arm64`,
/// `windows_amd64`, `windows_arm64`.
fn platform_target(os: &str, arch: &str) -> Result<&'static str, Box<dyn std::error::Error>> {
match (os, arch) {
("Linux", "x86_64") => Ok("linux_amd64"),
("Linux", "aarch64") => Ok("linux_arm64"),
("Darwin", "x86_64") => Ok("macos_amd64"),
("Darwin", "aarch64") => Ok("macos_arm64"),
("Windows", "x86_64") => Ok("windows_amd64"),
("Windows", "aarch64") => Ok("windows_arm64"),
_ => Err(format!("unsupported platform: {os}/{arch}").into()),
}
}
@@ -158,10 +162,16 @@
}
}
/// Write the new binary to a sibling temp path, chmod +x (Unix), then
/// swap it over the current executable.
/// Write the new binary to a sibling temp path, chmod +x, then atomically
/// rename over the current executable. On Linux, replacing a running
/// executable works: the kernel keeps the old inode alive for the running
/// process while new invocations pick up the new file.
///
/// On Unix, replacing a running executable is a plain atomic rename: the
/// kernel keeps the old inode alive for the running process while new
/// invocations pick up the new file. On Windows the running `.exe` is
/// locked against deletion/overwrite, but it *can* be renamed aside — so
/// we move the current exe to `<name>.old`, then rename the new file into
/// place. The `.old` file can't be deleted while we're running; it's
/// cleaned up best-effort on the next update.
fn swap_executable(
exe_path: &std::path::Path,
bytes: &[u8],
@@ -176,10 +186,15 @@
.to_string();
let tmp_path = parent.join(format!(".{file_name}.update"));
// Clean up a leftover `.old` from a previous Windows update (now that
// the prior process has exited, the file is deletable).
let old_path = parent.join(format!(".{file_name}.old"));
let _ = std::fs::remove_file(&old_path);
{
let mut f = std::fs::File::create(&tmp_path).map_err(|e| {
format!(
"could not write to {} — try running with sudo or reinstall via the package manager: {e}",
"could not write to {} — try running with elevated privileges or reinstall via the package manager: {e}",
tmp_path.display()
)
})?;
@@ -195,11 +210,33 @@
std::fs::set_permissions(&tmp_path, perms)?;
}
#[cfg(windows)]
{
// Move the running exe aside first; renaming over it is rejected.
std::fs::rename(&tmp_path, exe_path).map_err(|e| {
// Best-effort cleanup; if rename failed, the temp file is left around.
let _ = std::fs::remove_file(&tmp_path);
format!("could not replace {}: {e}", exe_path.display())
})?;
std::fs::rename(exe_path, &old_path).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
format!("could not move current executable aside: {e}")
})?;
std::fs::rename(&tmp_path, exe_path).map_err(|e| {
// Try to restore the original on failure so we don't leave the
// install without an executable.
let _ = std::fs::rename(&old_path, exe_path);
let _ = std::fs::remove_file(&tmp_path);
format!(
"could not install new executable {}: {e}",
exe_path.display()
)
})?;
}
#[cfg(not(windows))]
{
std::fs::rename(&tmp_path, exe_path).map_err(|e| {
// Best-effort cleanup; if rename failed, the temp file is left around.
let _ = std::fs::remove_file(&tmp_path);
format!("could not replace {}: {e}", exe_path.display())
})?;
}
Ok(())
}
src/main.rs +26 −3
@@ -2,16 +2,39 @@
mod commands;
mod config;
mod output;
mod platform;
mod runner;
use clap::Parser;
use commands::Cli;
fn main() {
// Windows Service worker fast-path: when the SCM launches us as the
// service, hand control to the dispatcher *before* building the async
// runtime (the dispatcher blocks and drives its own thread). Detected
// by a hidden flag in the service image path, so normal CLI use is
// unaffected. See `runner::service_windows`.
#[cfg(windows)]
{
if runner::service_windows::is_service_invocation() {
if let Err(e) = runner::service_windows::run_dispatcher() {
eprintln!("Error (service dispatcher): {e}");
std::process::exit(1);
}
return;
#[tokio::main]
}
}
async fn main() {
let cli = Cli::parse();
// Equivalent to `#[tokio::main]` (multi-thread, all features) but built
if let Err(e) = commands::run(cli).await {
// explicitly so the Windows service path above can opt out of it.
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("failed to build Tokio runtime");
if let Err(e) = runtime.block_on(commands::run(cli)) {
eprintln!("Error: {e}");
std::process::exit(1);
}
src/platform/mod.rs +23 −0
@@ -1,0 +1,23 @@
//! Thin OS-abstraction layer.
//!
//! The runner business logic is single-source and cross-platform; the
//! handful of primitives that genuinely differ per OS live here behind a
//! stable API so call sites stay free of `#[cfg]` noise and inline
//! `libc::*` / `windows_sys::*` calls.
//!
//! Each backend (`unix`, `windows`) provides the same set of functions;
//! `pub use` re-exports the active one. Anything that only exists on one
//! platform (e.g. the Windows named stop-event helpers) is exposed under
//! the `windows` path and called from `#[cfg(windows)]` sites directly.
#[cfg(unix)]
mod unix;
#[cfg(unix)]
pub use unix::*;
#[cfg(windows)]
pub mod windows;
#[cfg(windows)]
pub use windows::{
bare_shell, force_kill, is_alive, is_elevated, request_child_terminate, request_graceful_stop,
};
src/platform/unix.rs +62 −0
@@ -1,0 +1,62 @@
//! Unix implementation of the platform primitives. These bodies are the
//! pre-existing inline `libc` calls, relocated unchanged so the Unix code
//! paths behave exactly as before.
use std::path::Path;
/// Test whether `pid` names a live process without signalling it.
///
/// `kill(pid, 0)` returns 0 if the process exists (regardless of whether
/// we may signal it), -1/ESRCH if it's gone, -1/EPERM if it exists but we
/// lack permission — the last counts as "alive" for our purposes.
pub fn is_alive(pid: i32) -> bool {
if pid <= 0 {
return false;
}
let rc = unsafe { libc::kill(pid, 0) };
if rc == 0 {
return true;
}
let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
errno == libc::EPERM
}
/// True when running as root (uid 0).
pub fn is_elevated() -> bool {
(unsafe { libc::getuid() }) == 0
}
/// Forcibly terminate `pid` (SIGKILL).
pub fn force_kill(pid: i32) -> std::io::Result<()> {
let rc = unsafe { libc::kill(pid, libc::SIGKILL) };
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
/// Ask a runner process to shut down gracefully (SIGTERM). The `_pid_path`
/// is unused on Unix; on Windows it derives the named stop-event.
pub fn request_graceful_stop(pid: i32, _pid_path: &Path) -> std::io::Result<()> {
let rc = unsafe { libc::kill(pid, libc::SIGTERM) };
if rc == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
/// Best-effort graceful terminate of a child process we spawned (executor
/// timeout / shutdown path). On Unix this sends SIGTERM; the caller still
/// escalates to `child.kill()` (SIGKILL) after a grace window.
pub fn request_child_terminate(pid: u32) {
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
}
/// Program + leading args used to run an arbitrary shell command string.
pub fn bare_shell() -> (&'static str, Vec<&'static str>) {
("/bin/sh", vec!["-c"])
}
src/platform/windows.rs +169 −0
@@ -1,0 +1,169 @@
//! Windows implementation of the platform primitives.
//!
//! Process liveness/termination use the Win32 process APIs; the graceful
//! "stop a runner" path has no SIGTERM analogue, so it is modelled as a
//! named manual-reset event that the runner waits on (see [`wait_stop_event`])
//! and `runner stop` signals (see [`request_graceful_stop`]). The event
//! name is derived from the runner's PID-file path so both sides agree
//! without threading the instance name through every layer.
use std::ffi::c_void;
use std::hash::{Hash, Hasher};
use std::os::windows::ffi::OsStrExt;
use std::path::Path;
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, WAIT_OBJECT_0};
use windows_sys::Win32::Security::{
GetTokenInformation, TokenElevation, TOKEN_ELEVATION, TOKEN_QUERY,
};
use windows_sys::Win32::System::Threading::{
CreateEventW, GetCurrentProcess, GetExitCodeProcess, OpenEventW, OpenProcess, OpenProcessToken,
SetEvent, TerminateProcess, WaitForSingleObject, INFINITE, PROCESS_QUERY_LIMITED_INFORMATION,
PROCESS_TERMINATE,
};
/// A process that is still running reports this sentinel exit code.
const STILL_ACTIVE: u32 = 259;
/// `OpenEventW` access right to signal an event.
const EVENT_MODIFY_STATE: u32 = 0x0002;
/// Encode a Rust string as a NUL-terminated UTF-16 buffer for `*W` APIs.
fn wide(s: &str) -> Vec<u16> {
std::ffi::OsStr::new(s)
.encode_wide()
.chain(std::iter::once(0))
.collect()
}
/// Test whether `pid` names a live process. Mirrors the Unix semantics:
/// an access-denied open (process exists but we can't query it) counts as
/// alive, matching the Unix EPERM case.
pub fn is_alive(pid: i32) -> bool {
if pid <= 0 {
return false;
}
unsafe {
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid as u32);
if handle.is_null() {
// Couldn't open: ERROR_ACCESS_DENIED (5) means it exists but is
// protected; anything else (typically ERROR_INVALID_PARAMETER)
// means it's gone.
let err = windows_sys::Win32::Foundation::GetLastError();
return err == 5; // ERROR_ACCESS_DENIED
}
let mut code: u32 = 0;
let ok = GetExitCodeProcess(handle, &mut code);
CloseHandle(handle);
ok != 0 && code == STILL_ACTIVE
}
}
/// True when the current process holds an elevated (admin) token.
pub fn is_elevated() -> bool {
unsafe {
let mut token: HANDLE = std::ptr::null_mut();
if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 {
return false;
}
let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 };
let mut ret_len: u32 = 0;
let size = std::mem::size_of::<TOKEN_ELEVATION>() as u32;
let ok = GetTokenInformation(
token,
TokenElevation,
&mut elevation as *mut _ as *mut c_void,
size,
&mut ret_len,
);
CloseHandle(token);
ok != 0 && elevation.TokenIsElevated != 0
}
}
/// Forcibly terminate `pid`.
pub fn force_kill(pid: i32) -> std::io::Result<()> {
if pid <= 0 {
return Ok(());
}
unsafe {
let handle = OpenProcess(PROCESS_TERMINATE, 0, pid as u32);
if handle.is_null() {
return Err(std::io::Error::last_os_error());
}
let ok = TerminateProcess(handle, 1);
CloseHandle(handle);
if ok == 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
}
/// Ask a runner process to shut down gracefully by signalling the named
/// stop-event it waits on. `pid` is unused on Windows (the event, not the
/// PID, is the channel). Returns an error if no runner is listening on the
/// event, so the caller can fall back to force-kill.
pub fn request_graceful_stop(_pid: i32, pid_path: &Path) -> std::io::Result<()> {
let name = wide(&stop_event_name(pid_path));
unsafe {
let handle = OpenEventW(EVENT_MODIFY_STATE, 0, name.as_ptr());
if handle.is_null() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"runner is not listening for a stop event",
));
}
let ok = SetEvent(handle);
CloseHandle(handle);
if ok == 0 {
return Err(std::io::Error::last_os_error());
}
}
Ok(())
}
/// No graceful per-child signal on Windows; the executor escalates to
/// `child.kill()` after its grace window.
pub fn request_child_terminate(_pid: u32) {}
/// On Windows, run arbitrary command strings through PowerShell.
pub fn bare_shell() -> (&'static str, Vec<&'static str>) {
(
"powershell",
vec!["-NoProfile", "-NonInteractive", "-Command"],
)
}
/// Deterministic name of the stop-event for a given PID-file path. Both the
/// runner (which creates+waits) and `runner stop` (which opens+signals)
/// derive it from the same path, so they rendezvous without sharing the
/// instance name. Normalised to lowercase so trivial path-casing
/// differences don't desync the two sides.
pub fn stop_event_name(pid_path: &Path) -> String {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
pid_path.to_string_lossy().to_lowercase().hash(&mut hasher);
// `Local\` namespace keeps it per-session, which is correct: a runner
// and its `stop` invocation share a session (or the service session).
format!("Local\\anvil-runner-stop-{:016x}", hasher.finish())
}
/// Create the named stop-event and block until it is signalled. Intended to
/// be driven from a blocking task; resolves once `request_graceful_stop`
/// (or anything else) sets the event.
pub fn wait_stop_event_blocking(pid_path: &Path) {
let name = wide(&stop_event_name(pid_path));
unsafe {
// Manual-reset, initially non-signalled. Creating it (vs opening)
// guarantees the named object exists for `stop` to open even if the
// runner reaches here first.
let handle = CreateEventW(std::ptr::null(), 1, 0, name.as_ptr());
if handle.is_null() {
// Can't create the event — degrade to never resolving; ctrl_c
// and (for services) the SCM stop path still work.
return;
}
WaitForSingleObject(handle, INFINITE);
let _ = WAIT_OBJECT_0; // documents the expected return; we wait INFINITE
CloseHandle(handle);
}
}
src/runner/detach.rs +16 −7
@@ -65,6 +65,19 @@
.stdout(log_file)
.stderr(log_clone);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// Detach from the parent console and give the daemon its own
// process group so a closing terminal (and Ctrl+C/Ctrl+Break to
// the parent) doesn't reach it. CREATE_NO_WINDOW suppresses a
// popup console for the GUI-less daemon.
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS | CREATE_NO_WINDOW);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
@@ -138,13 +151,9 @@
}
// (2) Did the spawned process die?
#[cfg(unix)]
{
if !crate::platform::is_alive(spawn_pid as i32) {
let tail = log_tail(log_path).unwrap_or_else(|| "(log unavailable)".into());
return Ok(DaemonReady::DiedDuringStartup(tail));
let alive = unsafe { libc::kill(spawn_pid as i32, 0) } == 0;
if !alive {
let tail = log_tail(log_path).unwrap_or_else(|| "(log unavailable)".into());
return Ok(DaemonReady::DiedDuringStartup(tail));
}
}
std::thread::sleep(interval);
src/runner/executor.rs +68 −39
@@ -136,12 +136,13 @@
let workspace_str = workspace.to_str().unwrap_or(".");
let mut args = vec![
"run".to_string(),
"--rm".into(),
let mut args = vec!["run".to_string(), "--rm".into()];
"--add-host=host.docker.internal:host-gateway".into(),
];
// host.docker.internal is reachable by default on Docker Desktop
// (Windows/macOS); on Linux it needs an explicit host-gateway mapping.
#[cfg(unix)]
args.push("--add-host=host.docker.internal:host-gateway".into());
if let Some(net) = network {
args.push("--network".into());
args.push(net.to_string());
@@ -158,35 +159,60 @@
args.push("CI=true".into());
args.push("-e".into());
args.push("ANVIL_CI=true".into());
args.push("-e".into());
args.push("ANVIL_WORKSPACE=/workspace".into());
// Container conventions differ by OS: we run Linux containers on Unix
// Mount workspace
args.push("-v".into());
args.push(format!("{workspace_str}:/workspace"));
args.push("-w".into());
args.push("/workspace".into());
// hosts and Windows containers on Windows hosts (the step is pinned to
// the right runner via `runs_on`). Mount path, working dir, and the
// command entrypoint all follow suit.
#[cfg(not(windows))]
{
args.push("-e".into());
args.push("ANVIL_WORKSPACE=/workspace".into());
args.push("-v".into());
args.push(format!("{workspace_str}:/workspace"));
args.push("-w".into());
args.push("/workspace".into());
// Run in a subshell so `set -e` can't bypass cleanup; capture the
// exit code regardless. On Unix the bind-mounted /workspace ends up
// root-owned (the container runs as root for apt-get), so chown it
// back to the host UID/GID before exit to keep it host-writable.
#[cfg(unix)]
let wrapped_command = {
let uid = unsafe { libc::getuid() };
let gid = unsafe { libc::getgid() };
format!(
"({command})\n_exit_code=$?\nchown -R {uid}:{gid} /workspace 2>/dev/null || true\nexit $_exit_code"
)
};
#[cfg(not(unix))]
let wrapped_command = format!("({command})\n_exit_code=$?\nexit $_exit_code");
args.push(image.to_string());
args.push("/bin/sh".into());
args.push("-c".into());
args.push(wrapped_command);
}
// Windows containers: C:\workspace + PowerShell. No host-UID chown
// concept (Docker Desktop manages share ownership).
#[cfg(windows)]
// Pass host UID/GID so the command wrapper can fix file ownership.
// Docker runs as root (needed for apt-get), but files created in the
// mounted workspace end up owned by root:root on the host. Subsequent
// steps fail when parallel compilers try to write to root-owned build
// directories. We append a chown to the user's command so ownership
// is fixed before the container exits.
let uid = unsafe { libc::getuid() };
{
args.push("-e".into());
args.push("ANVIL_WORKSPACE=C:\\workspace".into());
args.push("-v".into());
args.push(format!("{workspace_str}:C:\\workspace"));
args.push("-w".into());
args.push("C:\\workspace".into());
let gid = unsafe { libc::getgid() };
// Run the user command in a subshell so `set -e` cannot bypass the
// chown cleanup. The subshell's exit code is captured regardless of
// whether it succeeds or fails.
let wrapped_command = format!(
"({command})\n_exit_code=$?\nchown -R {uid}:{gid} /workspace 2>/dev/null || true\nexit $_exit_code"
);
args.push(image.to_string());
args.push("powershell".into());
args.push("-NoProfile".into());
args.push("-ExecutionPolicy".into());
args.push("Bypass".into());
args.push("-Command".into());
// Image and command
args.push(image.to_string());
args.push("/bin/sh".into());
args.push("-c".into());
args.push(wrapped_command);
args.push(command.to_string());
}
run_and_stream(
"docker",
@@ -215,10 +241,16 @@
);
full_env.insert("CI".into(), "true".into());
full_env.insert("ANVIL_CI".into(), "true".into());
// Run via the platform's default shell: `/bin/sh -c` on Unix,
// `powershell -NoProfile -NonInteractive -Command` on Windows.
let (program, prefix) = crate::platform::bare_shell();
let mut shell_args: Vec<String> = prefix.into_iter().map(String::from).collect();
shell_args.push(command.to_string());
run_and_stream(
"/bin/sh",
&["-c".to_string(), command.to_string()],
program,
&shell_args,
workspace,
&full_env,
timeout,
@@ -398,13 +430,10 @@
/// Send SIGTERM to a running child, wait briefly, then SIGKILL if still
/// alive. Used both by the executor's shutdown path and the timeout path.
async fn terminate_child(child: &mut tokio::process::Child, log_reporter: &LogReporter) {
#[cfg(unix)]
if let Some(pid) = child.id() {
// Best-effort graceful terminate (SIGTERM on Unix; no-op on
// Windows) — we still fall through to SIGKILL via child.kill().
// Best-effort SIGTERM — if it returns an error we still fall
// through to SIGKILL via child.kill().
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
crate::platform::request_child_terminate(pid);
}
let grace = std::time::Duration::from_secs(CHILD_TERM_GRACE_SECS);
src/runner/loop_runner.rs +6 −2
@@ -63,6 +63,9 @@
Existing::None => {}
}
// Clone before the guard takes ownership; the shutdown listener uses
// it to derive the Windows named stop-event (no-op on Unix).
let shutdown_pid_path = pid_path.clone();
let _pid_guard = pid_file::Guard::acquire(pid_path, std::process::id() as i32)?;
// Validate connection
@@ -89,8 +92,9 @@
// Start heartbeat with capacity telemetry.
let heartbeat_handle = heartbeat::start(&config, slots.clone());
// Listen for SIGTERM + SIGINT (Ctrl+C); ignore SIGHUP + SIGPIPE.
let shutdown_signal = shutdown::install();
// Listen for shutdown triggers (Unix: SIGTERM/SIGINT; Windows: Ctrl+C,
// named stop-event, or SCM service stop).
let shutdown_signal = shutdown::install(shutdown_pid_path);
// Run polling slots
let mut handles = Vec::new();
src/runner/mod.rs +2 −0
@@ -11,6 +11,8 @@
pub mod prepare;
pub mod service_manager;
pub mod service_mode;
#[cfg(windows)]
pub mod service_windows;
pub mod shutdown;
pub mod slot_counter;
pub mod workspace;
src/runner/pid_file.rs +1 −27
@@ -54,38 +54,13 @@
// Unparseable junk — treat as stale so a re-start can overwrite.
return Existing::Stale(0);
};
if pid_is_alive(pid) {
if crate::platform::is_alive(pid) {
Existing::Live(pid)
} else {
Existing::Stale(pid)
}
}
#[cfg(unix)]
fn pid_is_alive(pid: i32) -> bool {
if pid <= 0 {
return false;
}
// kill(pid, 0) tests existence without sending a signal: returns 0 if
// the process exists (regardless of whether we have permission to
// signal it), -1/ESRCH if it's gone, -1/EPERM if it exists but we're
// not allowed to signal — for our purposes the last counts as "alive".
let rc = unsafe { libc::kill(pid, 0) };
if rc == 0 {
return true;
}
let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
errno == libc::EPERM
}
#[cfg(not(unix))]
fn pid_is_alive(_pid: i32) -> bool {
// Conservative on non-Unix: if a file is here, assume alive. Worst
// case is the user sees a "refused, looks like it's already running"
// and runs `anvil runner stop --force`.
true
}
/// RAII handle for the PID file. The file is written on `acquire` and
/// removed on `Drop`. If the process is killed (SIGKILL, panic in another
/// thread), the file is left on disk and treated as stale on the next
@@ -190,7 +165,6 @@
let _ = std::fs::remove_file(&path);
}
#[cfg(unix)]
#[test]
fn inspect_live_pid_returns_live() {
let path = temp_pid_path("live");
src/runner/prepare.rs +25 −11
@@ -73,8 +73,26 @@
}
}
// Join all prepare commands with && so they run in sequence and fail fast
let combined = prepare.join(" && ");
// Join prepare commands so they run in sequence and fail fast, using the
// container OS's shell. Linux: `/bin/sh -c "a && b && c"`. Windows
// containers: PowerShell, where `&&` isn't available in 5.1 — chain with
// explicit $LASTEXITCODE checks and stop on any cmdlet error.
#[cfg(not(windows))]
let (combined, entry): (String, Vec<&str>) = (prepare.join(" && "), vec!["/bin/sh", "-c"]);
#[cfg(windows)]
let (combined, entry): (String, Vec<&str>) = {
let chain = prepare.join("; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; ");
(
format!("$ErrorActionPreference = 'Stop'; {chain}"),
vec![
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
],
)
};
let container_name = format!("anvil-prepare-{}", &tag["anvil-prepared:".len()..]);
// Remove any leftover container from a previous failed attempt
@@ -87,16 +105,12 @@
// Run prepare commands in the base image, streaming output
log_reporter.append("Running prepare commands...").await;
let mut run_args: Vec<&str> = vec!["run", "--name", &container_name, image];
run_args.extend(entry.iter().copied());
run_args.push(&combined);
let mut child = tokio::process::Command::new("docker")
.args([
"run",
"--name",
&container_name,
image,
"/bin/sh",
"-c",
&combined,
])
.args(&run_args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
src/runner/service_mode.rs +7 −1
@@ -256,7 +256,13 @@
#[test]
fn record_path_uses_services_subdir() {
let p = record_path("gpu1");
assert!(p.to_string_lossy().contains("/services/gpu1.json"));
// Assert on path structure rather than a hard-coded separator so
// this holds on Windows (`\`) as well as Unix (`/`).
assert_eq!(p.file_name().unwrap().to_string_lossy(), "gpu1.json");
assert_eq!(
p.parent().unwrap().file_name().unwrap().to_string_lossy(),
"services"
);
}
#[test]
src/runner/service_windows.rs +473 −0
@@ -1,0 +1,473 @@
//! Windows Service (SCM) integration — the peer of the systemd/launchd
//! code on Unix. One file holds everything Windows-specific: installing /
//! controlling the service via the SCM, and the in-process service worker
//! that the SCM launches on boot.
//!
//! ## How it runs
//!
//! `runner service install` registers a service whose image path is
//! `anvil.exe runner service run --anvil-service-worker --service-name <i> …`.
//! On boot the SCM launches that command; `main()` spots the
//! [`SERVICE_WORKER_FLAG`] before building the async runtime and calls
//! [`run_dispatcher`], which hands control to the SCM dispatcher. The
//! dispatcher invokes [`service_main`] on a background thread, where we
//! register a control handler (translating `SERVICE_CONTROL_STOP` into the
//! same graceful-drain `Notify` the rest of the runner uses), report
//! `Running`, then run the normal poll loop until stop.
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::Duration;
use windows_service::service::{
ServiceAccess, ServiceAction, ServiceActionType, ServiceControl, ServiceControlAccept,
ServiceErrorControl, ServiceExitCode, ServiceFailureActions, ServiceFailureResetPeriod,
ServiceInfo, ServiceStartType, ServiceState, ServiceStatus, ServiceType,
};
use windows_service::service_control_handler::{self, ServiceControlHandlerResult};
use windows_service::service_dispatcher;
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
use crate::output;
use crate::runner::service_mode::{InstallRecord, Scope};
/// Hidden flag baked into the service image path so the worker invocation
/// is unambiguously distinguishable from a normal `runner service` call.
pub const SERVICE_WORKER_FLAG: &str = "--anvil-service-worker";
/// SCM service name for an instance. Mirrors `unit_or_label_for` in the
/// command layer so both agree on the name without sharing state.
pub fn service_name_for(instance: &str) -> String {
format!("anvil-runner-{instance}")
}
/// Where the service worker writes stdout/stderr (services have no
/// console). `runner logs` tails this file.
pub fn service_log_path(instance: &str) -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".anvil-runner")
.join(format!("{instance}.log"))
}
type DynErr = Box<dyn std::error::Error + Send + Sync>;
// ─────────────────────────── install / uninstall / control ──────────────
/// Create the SCM service for `instance`. Requires an elevated shell.
/// Returns a sentinel path (there is no on-disk unit file) for the install
/// record.
#[allow(clippy::too_many_arguments)]
pub fn install(
exe: &Path,
config_file: &Path,
scope: Scope,
user_account: Option<&str>,
parallel: u32,
instance: &str,
service_name: &str,
) -> Result<PathBuf, DynErr> {
if !crate::platform::is_elevated() {
return Err(
"installing a Windows service requires an elevated (Administrator) terminal".into(),
);
}
let manager = ServiceManager::local_computer(
None::<&str>,
ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE,
)?;
// Pre-create state/log dirs so the (possibly non-admin) service account
// doesn't have to.
let pid_file = crate::runner::pid_file::instance_path(instance);
let log_path = service_log_path(instance);
if let Some(p) = log_path.parent() {
let _ = std::fs::create_dir_all(p);
}
if let Some(p) = pid_file.parent() {
let _ = std::fs::create_dir_all(p);
}
let launch_arguments: Vec<OsString> = vec![
OsString::from("runner"),
OsString::from("service"),
OsString::from("run"),
OsString::from(SERVICE_WORKER_FLAG),
OsString::from("--service-name"),
OsString::from(instance),
OsString::from("--config"),
config_file.as_os_str().to_os_string(),
OsString::from("--pid-file"),
pid_file.as_os_str().to_os_string(),
OsString::from("--parallel"),
OsString::from(parallel.to_string()),
OsString::from("--shutdown-timeout"),
OsString::from("60"),
];
// Account mapping: System scope → LocalSystem (account None). User scope
// with an explicit account → that account (prompts for a password). User
// scope without an account → LocalSystem too (machine-wide SCM has no
// per-user analogue; documented behaviour).
let (account_name, account_password) = match (scope, user_account) {
(Scope::User, Some(acct)) => {
let password = dialoguer::Password::new()
.with_prompt(format!("Password for service account '{acct}'"))
.interact()
.map_err(|e| format!("could not read password: {e}"))?;
(Some(OsString::from(acct)), Some(OsString::from(password)))
}
_ => (None, None),
};
let info = ServiceInfo {
name: OsString::from(service_name),
display_name: OsString::from(format!("Anvil CI Runner ({instance})")),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: exe.to_path_buf(),
launch_arguments,
dependencies: vec![],
account_name,
account_password,
};
let service = manager.create_service(
&info,
ServiceAccess::CHANGE_CONFIG
| ServiceAccess::START
| ServiceAccess::STOP
| ServiceAccess::QUERY_STATUS
| ServiceAccess::DELETE,
)?;
let _ = service.set_description(format!(
"Anvil CI runner instance '{instance}'. Polls the Anvil server for CI jobs and executes them."
));
// Auto-restart on failure: restart after 5s, again after 5s, then 30s,
// resetting the failure counter after a day of clean running. Mirrors
// systemd's Restart=always / RestartSec=5.
let failure_actions = ServiceFailureActions {
reset_period: ServiceFailureResetPeriod::After(Duration::from_secs(86_400)),
reboot_msg: None,
command: None,
actions: Some(vec![
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(5),
},
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(5),
},
ServiceAction {
action_type: ServiceActionType::Restart,
delay: Duration::from_secs(30),
},
]),
};
if let Err(e) = service.update_failure_actions(failure_actions) {
output::warn(&format!(
"could not set restart-on-failure policy (service will still run): {e}"
));
}
// No on-disk unit; record a sentinel so status/doctor have something
// human-readable to show.
Ok(PathBuf::from(format!("SCM:{service_name}")))
}
/// Stop (if running) and delete the SCM service.
pub fn uninstall(record: &InstallRecord) -> Result<(), DynErr> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let service = manager.open_service(
&record.unit_or_label,
ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE,
)?;
if let Ok(status) = service.query_status() {
if status.current_state != ServiceState::Stopped {
let _ = service.stop();
wait_for_state(&service, ServiceState::Stopped, Duration::from_secs(30));
}
}
service.delete()?;
Ok(())
}
/// `svc-start` / `svc-stop` / `svc-restart` / `svc-status`.
pub fn control(action: &str, record: &InstallRecord) -> Result<(), DynErr> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let access = ServiceAccess::QUERY_STATUS | ServiceAccess::START | ServiceAccess::STOP;
let service = manager.open_service(&record.unit_or_label, access)?;
match action {
"start" => {
service.start::<&OsStr>(&[])?;
output::success("Service started");
}
"stop" => {
service.stop()?;
output::success("Service stopped");
}
"restart" => {
let _ = service.stop();
wait_for_state(&service, ServiceState::Stopped, Duration::from_secs(30));
service.start::<&OsStr>(&[])?;
output::success("Service restarted");
}
"status" => {
let status = service.query_status()?;
output::detail("State", &format!("{:?}", status.current_state));
if let Some(pid) = status.process_id {
output::detail("PID", &pid.to_string());
}
}
other => return Err(format!("unknown service action: {other}").into()),
}
Ok(())
}
/// Poll a service until it reaches `target` or the deadline elapses.
fn wait_for_state(
service: &windows_service::service::Service,
target: ServiceState,
timeout: Duration,
) {
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
match service.query_status() {
Ok(s) if s.current_state == target => return,
_ => std::thread::sleep(Duration::from_millis(200)),
}
}
}
// ─────────────────────────── service worker (SCM-launched) ──────────────
#[derive(Clone)]
struct ServiceRunConfig {
instance: String,
config_path: Option<String>,
pid_file: Option<String>,
parallel: Option<u32>,
shutdown_timeout: u64,
log_path: PathBuf,
}
static RUN_CONFIG: OnceLock<ServiceRunConfig> = OnceLock::new();
/// True when this process was launched by the SCM as the service worker.
pub fn is_service_invocation() -> bool {
std::env::args().any(|a| a == SERVICE_WORKER_FLAG)
}
/// Parse the worker flags from the process command line (the SCM passes
/// them as part of the service image path, so they land in `env::args`).
fn parse_run_config() -> ServiceRunConfig {
let mut instance = crate::runner::service_mode::DEFAULT_INSTANCE.to_string();
let mut config_path: Option<String> = None;
let mut pid_file: Option<String> = None;
let mut parallel: Option<u32> = None;
let mut shutdown_timeout: u64 = crate::runner::loop_runner::DEFAULT_SHUTDOWN_TIMEOUT_SECS;
let args: Vec<String> = std::env::args().collect();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--service-name" => {
if let Some(v) = args.get(i + 1) {
instance = v.clone();
i += 1;
}
}
"--config" => {
config_path = args.get(i + 1).cloned();
i += 1;
}
"--pid-file" => {
pid_file = args.get(i + 1).cloned();
i += 1;
}
"--parallel" => {
parallel = args.get(i + 1).and_then(|v| v.parse().ok());
i += 1;
}
"--shutdown-timeout" => {
if let Some(v) = args.get(i + 1).and_then(|v| v.parse().ok()) {
shutdown_timeout = v;
i += 1;
}
}
_ => {}
}
i += 1;
}
let log_path = service_log_path(&instance);
ServiceRunConfig {
instance,
config_path,
pid_file,
parallel,
shutdown_timeout,
log_path,
}
}
windows_service::define_windows_service!(ffi_service_main, service_main);
/// Hand off to the SCM dispatcher. Blocks until the service stops. Returns
/// an error if not actually launched by the SCM (e.g. run by hand).
pub fn run_dispatcher() -> Result<(), DynErr> {
let cfg = parse_run_config();
let service_name = service_name_for(&cfg.instance);
let _ = RUN_CONFIG.set(cfg);
service_dispatcher::start(service_name, ffi_service_main)?;
Ok(())
}
fn service_main(_arguments: Vec<OsString>) {
if let Err(e) = run_service_worker() {
// Last-resort: the control handler may not be registered yet, so we
// can only log. stdout/stderr are redirected to the log file by the
// time most errors occur.
eprintln!("anvil runner service worker error: {e}");
}
}
fn run_service_worker() -> Result<(), DynErr> {
let cfg = RUN_CONFIG
.get()
.cloned()
.ok_or("service worker started without a parsed run config")?;
// Redirect stdout/stderr to the log file before anything prints, so the
// runner's eprintln!-based logging is captured for `runner logs`.
let _ = redirect_stdio_to_log(&cfg.log_path);
// The SCM stop control feeds the same graceful-drain notify the rest of
// the runner waits on (see shutdown.rs). The status handle is shared
// into the handler (set just after `register` returns) so STOP can
// report `StopPending` with a wait hint — otherwise the SCM may treat a
// long in-flight-job drain as a hang.
let shutdown = crate::runner::shutdown::external_stop();
let handler_shutdown = shutdown.clone();
let status_shared: std::sync::Arc<
std::sync::OnceLock<windows_service::service_control_handler::ServiceStatusHandle>,
> = std::sync::Arc::new(std::sync::OnceLock::new());
let status_for_handler = status_shared.clone();
let event_handler = move |control| -> ServiceControlHandlerResult {
match control {
ServiceControl::Stop => {
if let Some(handle) = status_for_handler.get() {
let _ = handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::StopPending,
controls_accepted: ServiceControlAccept::empty(),
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::from_secs(90),
process_id: None,
});
}
handler_shutdown.notify_one();
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
}
};
let status_handle =
service_control_handler::register(service_name_for(&cfg.instance), event_handler)?;
let _ = status_shared.set(status_handle);
status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Running,
controls_accepted: ServiceControlAccept::STOP,
exit_code: ServiceExitCode::Win32(0),
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
})?;
// The runner loop is async; build a runtime here (the SCM thread is not
// the tokio main thread).
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let result: Result<(), DynErr> = runtime.block_on(async move {
let mut config = crate::runner::RunnerConfig::load(cfg.config_path.as_deref())?;
if let Some(p) = cfg.parallel {
config.parallel = p;
}
let pid_path = cfg
.pid_file
.clone()
.map(PathBuf::from)
.unwrap_or_else(|| crate::runner::pid_file::instance_path(&cfg.instance));
crate::runner::loop_runner::start(config, pid_path, cfg.shutdown_timeout).await
});
let exit_code = if result.is_ok() {
ServiceExitCode::Win32(0)
} else {
ServiceExitCode::ServiceSpecific(1)
};
let _ = status_handle.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: ServiceState::Stopped,
controls_accepted: ServiceControlAccept::empty(),
exit_code,
checkpoint: 0,
wait_hint: Duration::default(),
process_id: None,
});
result
}
/// Point the process's STDOUT/STDERR handles at the log file (append). Done
/// before the first `print!`/`eprintln!` so Rust's lazily-cached std
/// handles pick up the redirected target.
fn redirect_stdio_to_log(path: &Path) -> std::io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Foundation::{GENERIC_WRITE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::Storage::FileSystem::{
CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_ALWAYS,
};
use windows_sys::Win32::System::Console::{SetStdHandle, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let wide: Vec<u16> = path
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
let handle = CreateFileW(
wide.as_ptr(),
GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
std::ptr::null(),
OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
std::ptr::null_mut(),
);
if handle == INVALID_HANDLE_VALUE {
return Err(std::io::Error::last_os_error());
}
SetStdHandle(STD_OUTPUT_HANDLE, handle);
SetStdHandle(STD_ERROR_HANDLE, handle);
}
Ok(())
}
src/runner/shutdown.rs +50 −14
@@ -1,24 +1,35 @@
//! Shutdown signal handling for the runner.
//!
//! Listens for SIGINT (Ctrl+C) and SIGTERM (`systemctl stop`, `kill`,
//! `docker stop`, k8s pre-SIGKILL) — both trigger graceful drain via the
//! shared `Notify`. SIGHUP and SIGPIPE are explicitly ignored so the
//! default actions (terminate / write-error-kills-process) don't kill us.
//! On Unix we listen for SIGINT (Ctrl+C) and SIGTERM (`systemctl stop`,
//! `kill`, `docker stop`, k8s pre-SIGKILL) — both trigger graceful drain
//! via the shared `Notify`. SIGHUP and SIGPIPE are explicitly ignored so
//! the default actions (terminate / write-error-kills-process) don't kill
//! us.
//!
//! On Unix we use `tokio::signal::unix::signal`. On non-Unix we fall back
//! to ctrl_c only.
//! On Windows there are no POSIX signals. We instead select across three
//! sources, any of which triggers the same graceful drain:
//! 1. Ctrl+C (`tokio::signal::ctrl_c`) for foreground runs,
//! 2. a named manual-reset stop-event (set by `anvil runner stop`) for
//! detached runs — see [`crate::platform::windows`],
//! 3. the [`external_stop`] notify, fired by the Windows Service control
//! handler on `SERVICE_CONTROL_STOP`.
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Notify;
/// Spawn the signal listener. The returned `Notify` fires once on the
/// first SIGINT or SIGTERM; subsequent signals are absorbed silently so
/// first shutdown trigger; subsequent triggers are absorbed silently so
/// repeated Ctrl+C doesn't accidentally interrupt the drain logic.
pub fn install() -> Arc<Notify> {
///
/// `pid_path` is the runner's PID-file path; on Windows it derives the
/// named stop-event so `runner stop` can reach this process. It is unused
/// on Unix.
pub fn install(pid_path: PathBuf) -> Arc<Notify> {
let shutdown = Arc::new(Notify::new());
let trigger = shutdown.clone();
tokio::spawn(async move {
wait_for_signal().await;
wait_for_signal(pid_path).await;
trigger.notify_waiters();
});
@@ -27,7 +38,7 @@
}
#[cfg(unix)]
async fn wait_for_signal() {
async fn wait_for_signal(_pid_path: PathBuf) {
use tokio::signal::unix::{signal, SignalKind};
let mut sigint = signal(SignalKind::interrupt()).expect("failed to install SIGINT handler");
@@ -58,8 +69,33 @@
}
}
#[cfg(not(unix))]
async fn wait_for_signal() {
let _ = tokio::signal::ctrl_c().await;
eprintln!("Received Ctrl+C, shutting down...");
/// Process-global notify fired by the Windows Service control handler when
/// the SCM requests a stop. The runner's shutdown listener selects on it,
/// so an SCM stop drives the same graceful drain as Ctrl+C. Uses
/// `notify_one` semantics in the handler so a stop that arrives before the
/// listener is awaiting isn't lost.
#[cfg(windows)]
pub fn external_stop() -> Arc<Notify> {
use std::sync::OnceLock;
static SERVICE_STOP: OnceLock<Arc<Notify>> = OnceLock::new();
SERVICE_STOP.get_or_init(|| Arc::new(Notify::new())).clone()
}
#[cfg(windows)]
async fn wait_for_signal(pid_path: PathBuf) {
let ext = external_stop();
// The named stop-event wait is blocking (WaitForSingleObject); run it
// on a blocking thread. If shutdown is triggered by another source the
// thread stays parked until process exit, which is fine — we're on the
// way out.
let stop_event = tokio::task::spawn_blocking(move || {
crate::platform::windows::wait_stop_event_blocking(&pid_path);
});
tokio::select! {
_ = tokio::signal::ctrl_c() => eprintln!("\nReceived Ctrl+C, shutting down..."),
_ = stop_event => eprintln!("Received stop request, shutting down..."),
_ = ext.notified() => eprintln!("Received service stop, shutting down..."),
}
}