#[cfg(test)]
use std::collections::VecDeque;
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::fmt;
use std::fs;
use std::io::Write;
use std::num::NonZeroU16;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_trait::async_trait;
use runner_manager_domain::attempt::{
AttemptOutcome, AttemptState, FailureReason, GithubRunnerObservation, RecoveryDecision,
RecoveryObservation, RecoveryTimeouts, RunnerAttempt, authorize, recovery_decision,
};
use runner_manager_domain::model::{AttemptId, Clock, HostId, PolicyId, ScaleTarget};
use runner_manager_domain::path::LocalAbsolutePath;
use runner_manager_domain::policy::ScalePolicy;
use runner_manager_domain::store::{Store, StoreError};
use runner_manager_domain::workspace::{AttemptWorkspace, WorkspacePolicy};
use runner_manager_github::jit::{
DEFAULT_WORK_FOLDER, EncodedJitConfig, JitError, JitGateway, JitRegistration, JitRunnerRequest,
};
use runner_manager_github::rest::{CancelToken, InventoryGateway};
use runner_manager_platform::process::{
Adoption, ChildProcess, ProcessIdentity, RestrictiveHandoff, SpawnSpec, Termination,
};
use runner_manager_platform::runner_root::{
self, RootOwner, RootPreflight, RunnerRootError, default_runner_root,
};
use secrecy::SecretString;
use crate::package::{PackageCache, PackageError, RunnerVersion};
use crate::reconcile::{
AllocationGuard, EventSink, LaunchFailure, LaunchRequest, LifecycleEvent, OutcomeKind,
ReplacementIntent, RunnerLauncher,
};
const IDENTITY_FILE: &str = ".runner-process.json";
const FALLBACK_IDENTITY_FILE: &str = ".runner-process.recovery.json";
const UNRESOLVED_PROCESS_FILE: &str = ".runner-process.unresolved";
const RUNNER_ID_FILE: &str = ".github-runner-id";
const TERMINATE_INTENT_FILE: &str = ".terminate-registration-timeout";
const MAX_POST_SPAWN_STOP_ATTEMPTS: usize = 3;
const SENSITIVE_SLOT_ENTRIES: &[&str] = &[
"bin",
"externals",
"run.sh",
"run.cmd",
"config.sh",
"config.cmd",
".runner",
".credentials",
".credentials_rsaparams",
".env",
".path",
"_diag",
IDENTITY_FILE,
FALLBACK_IDENTITY_FILE,
UNRESOLVED_PROCESS_FILE,
RUNNER_ID_FILE,
TERMINATE_INTENT_FILE,
];
#[cfg(test)]
const TEST_LISTENER_READY: &str = ".test-listener-ready";
fn runner_listener_spec(program: PathBuf, runtime: &Path) -> SpawnSpec {
let tmp = runtime.join("tmp");
let _ = std::fs::create_dir_all(&tmp);
SpawnSpec::new(program)
.arg("run")
.working_dir(runtime)
.env("TMPDIR", &tmp)
.env("TEMP", &tmp)
.env("TMP", &tmp)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub initial: Duration,
pub maximum: Duration,
}
impl RetryPolicy {
#[must_use]
pub const fn bounded(max_attempts: u32, initial: Duration, maximum: Duration) -> Self {
Self {
max_attempts,
initial,
maximum,
}
}
fn delay(self, failure_index: u32) -> Duration {
let shift = failure_index.saturating_sub(1).min(31);
self.initial
.saturating_mul(1_u32 << shift)
.min(self.maximum)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttemptEvent {
State {
attempt: AttemptId,
state: AttemptState,
},
Retry {
attempt: AttemptId,
operation: &'static str,
delay: Duration,
},
Adopted {
attempt: AttemptId,
},
RemoteIdentityRecovered {
attempt: AttemptId,
runner_id: u64,
},
TerminateIntent {
attempt: AttemptId,
},
Terminated {
attempt: AttemptId,
},
Deregistered {
attempt: AttemptId,
runner_id: u64,
},
Concluded {
attempt: AttemptId,
outcome: OutcomeKind,
},
Cleaned {
attempt: AttemptId,
outcome: OutcomeKind,
},
}
pub trait AttemptEventSink: fmt::Debug + Send + Sync {
fn emit(&self, event: AttemptEvent);
}
#[derive(Debug, Default)]
pub struct AttemptEventLog(Mutex<Vec<AttemptEvent>>);
impl AttemptEventLog {
#[must_use]
pub fn events(&self) -> Vec<AttemptEvent> {
self.0
.lock()
.map(|events| events.clone())
.unwrap_or_default()
}
}
impl AttemptEventSink for AttemptEventLog {
fn emit(&self, event: AttemptEvent) {
if let Ok(mut events) = self.0.lock() {
events.push(event);
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct NoAttemptEvents;
impl AttemptEventSink for NoAttemptEvents {
fn emit(&self, _event: AttemptEvent) {}
}
#[async_trait]
pub trait DemandPersistence: fmt::Debug + Send + Sync {
async fn persists(&self, policy: PolicyId) -> bool;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct PersistentDemand;
#[async_trait]
impl DemandPersistence for PersistentDemand {
async fn persists(&self, _policy: PolicyId) -> bool {
true
}
}
#[async_trait]
pub trait RetryDelay: fmt::Debug + Send + Sync {
async fn wait(&self, duration: Duration);
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TokioRetryDelay;
#[async_trait]
impl RetryDelay for TokioRetryDelay {
async fn wait(&self, duration: Duration) {
tokio::time::sleep(duration).await;
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JitRequestFailure {
pub terminal: bool,
pub reason: FailureReason,
pub retry_after: Option<Duration>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LifecycleGithubObservation {
pub status: GithubRunnerObservation,
pub runner_id: Option<u64>,
}
impl LifecycleGithubObservation {
#[must_use]
pub const fn unreachable() -> Self {
Self {
status: GithubRunnerObservation::Unreachable,
runner_id: None,
}
}
#[must_use]
pub const fn not_registered() -> Self {
Self {
status: GithubRunnerObservation::NotRegistered,
runner_id: None,
}
}
#[must_use]
pub const fn registered(runner_id: u64, busy: bool) -> Self {
Self {
status: GithubRunnerObservation::Registered { busy },
runner_id: Some(runner_id),
}
}
}
#[async_trait]
pub trait LifecycleGithub: fmt::Debug + Send + Sync {
async fn register(
&self,
target: &ScaleTarget,
request: &JitRunnerRequest,
cancel: &CancelToken,
) -> Result<JitRegistration, JitRequestFailure>;
async fn observe(
&self,
target: &ScaleTarget,
attempt: AttemptId,
cancel: &CancelToken,
) -> LifecycleGithubObservation;
async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool;
}
#[async_trait]
impl<T> LifecycleGithub for T
where
T: JitGateway + InventoryGateway + fmt::Debug + Send + Sync,
{
async fn register(
&self,
target: &ScaleTarget,
request: &JitRunnerRequest,
cancel: &CancelToken,
) -> Result<JitRegistration, JitRequestFailure> {
self.generate_jit_config(target, request, cancel)
.await
.map_err(|error| {
let reason = if matches!(&error, JitError::Forbidden { .. }) {
FailureReason::Other(
"GitHub refused JIT registration with 403; check the App's runner permission and runner-group access"
.into(),
)
} else {
FailureReason::JitRequestFailed
};
JitRequestFailure {
terminal: error.is_terminal(),
reason,
retry_after: error
.rate_limited()
.map(|limit| limit.delay_from(self.now())),
}
})
}
async fn observe(
&self,
target: &ScaleTarget,
attempt: AttemptId,
cancel: &CancelToken,
) -> LifecycleGithubObservation {
let expected_name = runner_name(attempt);
match self.list_runners(target, cancel).await {
Ok(inventory) => inventory
.runners()
.iter()
.find(|runner| runner.name == expected_name)
.map_or(LifecycleGithubObservation::not_registered(), |runner| {
LifecycleGithubObservation::registered(runner.id, runner.busy)
}),
Err(_) => LifecycleGithubObservation::unreachable(),
}
}
async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool {
self.remove_runner(target, runner_id, cancel).await.is_ok()
}
}
#[async_trait]
pub trait RuntimePackages: fmt::Debug + Send + Sync {
async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason>;
fn release(&self, attempt: AttemptId) -> Result<(), FailureReason>;
fn prune_obsolete_guarded(
&self,
authority: PruneAuthority<'_>,
current: &RunnerVersion,
attempts: &[RunnerAttempt],
) -> Result<(), FailureReason>;
}
pub struct PruneAuthority<'a> {
_guard: &'a AllocationGuard,
}
impl fmt::Debug for PruneAuthority<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("PruneAuthority")
}
}
impl<'a> PruneAuthority<'a> {
fn from_launch_request(guard: &'a AllocationGuard) -> Self {
Self { _guard: guard }
}
}
#[derive(Debug)]
pub struct CachedRuntimePackages {
cache: Arc<PackageCache>,
}
impl CachedRuntimePackages {
#[must_use]
pub fn new(cache: Arc<PackageCache>) -> Self {
Self { cache }
}
}
#[async_trait]
impl RuntimePackages for CachedRuntimePackages {
async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason> {
let installed = self
.cache
.ensure_installed()
.await
.map_err(package_failure)?;
copy_package_tree(installed.root(), attempt.runtime_path())
.map_err(|_| FailureReason::ProcessStartFailed)?;
if let Err(error) = self.cache.lease(attempt, installed.version()) {
let _ = remove_materialized_package(attempt);
return Err(package_failure(error));
}
Ok(installed.version().clone())
}
fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
self.cache.release(attempt).map_err(package_failure)
}
fn prune_obsolete_guarded(
&self,
_authority: PruneAuthority<'_>,
current: &RunnerVersion,
attempts: &[RunnerAttempt],
) -> Result<(), FailureReason> {
for installed in self.cache.installed().map_err(package_failure)? {
if installed.version() != current {
match self.cache.prune(installed.version(), attempts) {
Ok(()) | Err(PackageError::VersionInUse { .. }) => {}
Err(error) => return Err(package_failure(error)),
}
}
}
Ok(())
}
}
fn package_failure(error: PackageError) -> FailureReason {
error.failure_reason().unwrap_or(FailureReason::Other(
"runner package cache operation failed".into(),
))
}
fn package_failure_is_terminal(reason: &FailureReason) -> bool {
matches!(
reason,
FailureReason::RunnerPackageUnverified | FailureReason::RunnerVersionRejected
)
}
const WORKSPACE_NAME_LEN: usize = 12;
fn workspace_name(id: AttemptId) -> String {
let full = id.to_string();
full.chars()
.filter(|c| *c != '-')
.take(WORKSPACE_NAME_LEN)
.collect()
}
#[derive(Debug, Clone)]
struct Placement {
runtime: PathBuf,
workspace: AttemptWorkspace,
}
fn root_failure(error: RunnerRootError) -> LifecycleError {
tracing::warn!(
error_kind = error.kind(),
"the runner root refused this launch, so no attempt was created; the host will \
retry every poll until the cause is resolved. Re-running `host set-runtime-root` \
with the same path re-runs this check and prints the directory and the \
remediation in full"
);
LifecycleError::Failed(FailureReason::Other(error.to_string()))
}
fn lowest_free_slot(leases: &[RunnerAttempt], ceiling: NonZeroU16) -> Option<NonZeroU16> {
let held: BTreeSet<u16> = leases
.iter()
.filter_map(|attempt| attempt.workspace().slot_number())
.collect();
(1..=ceiling.get())
.find(|slot| !held.contains(slot))
.and_then(NonZeroU16::new)
}
fn create_or_validate_slot(slot: &Path) -> Result<(), LifecycleError> {
match fs::symlink_metadata(slot) {
Ok(metadata) if is_link_like(&metadata) => Err(slot_refusal(
slot,
"is a symbolic link, junction or other reparse point, which could place runner \
files outside the configured root",
)),
Ok(metadata) if !metadata.is_dir() => Err(slot_refusal(slot, "is not a directory")),
Ok(_) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(slot)
.map_err(|source| slot_refusal(slot, format!("could not be created: {source}"))),
Err(source) => Err(slot_refusal(
slot,
format!("could not be inspected: {source}"),
)),
}
}
fn accept_reusable_slot(slot: &Path) -> Result<(), LifecycleError> {
let unreadable =
|source: std::io::Error| slot_refusal(slot, format!("could not be read: {source}"));
let entries = fs::read_dir(slot).map_err(unreadable)?;
let mut refused: Vec<String> = Vec::new();
for entry in entries {
let entry = entry.map_err(unreadable)?;
let name = entry.file_name();
let metadata = fs::symlink_metadata(entry.path()).map_err(|source| {
slot_refusal(
slot,
format!("entry {name:?} could not be inspected: {source}"),
)
})?;
if is_retainable_work_folder(&name, &metadata) {
continue;
}
refused.push(name.to_string_lossy().into_owned());
}
if refused.is_empty() {
return Ok(());
}
refused.sort();
Err(slot_refusal(
slot,
format!(
"holds {} that this attempt may not reuse: [{}]. A reusable slot is empty or holds \
one real `{DEFAULT_WORK_FOLDER}` directory and nothing else; remove or move the \
entries listed, or let cleanup and recovery resolve them",
if refused.len() == 1 {
"an entry"
} else {
"entries"
},
refused.join(", ")
),
))
}
fn slot_refusal(slot: &Path, detail: impl fmt::Display) -> LifecycleError {
LifecycleError::Failed(FailureReason::Other(format!(
"the persistent slot {} {detail}",
slot.display()
)))
}
fn is_work_folder(name: &OsStr) -> bool {
if cfg!(windows) {
name.eq_ignore_ascii_case(DEFAULT_WORK_FOLDER)
} else {
name == OsStr::new(DEFAULT_WORK_FOLDER)
}
}
fn is_link_like(metadata: &fs::Metadata) -> bool {
if metadata.file_type().is_symlink() {
return true;
}
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
false
}
fn is_retainable_work_folder(name: &OsStr, metadata: &fs::Metadata) -> bool {
is_work_folder(name) && metadata.is_dir() && !is_link_like(metadata)
}
fn remove_materialized_package(attempt: &RunnerAttempt) -> std::io::Result<()> {
match attempt.workspace() {
AttemptWorkspace::Ephemeral => fs::remove_dir_all(attempt.runtime_path()),
AttemptWorkspace::PersistentSlot { .. } => scrub_slot_entries(attempt.runtime_path())
.map_err(|quarantine| std::io::Error::other(quarantine.to_string())),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SlotRefusal {
NotTheJournalledSlot,
PolicyRootDisagrees,
Containment,
SlotNotADirectory,
Enumeration,
WorkNotADirectory,
Deletion,
Residue,
}
impl SlotRefusal {
const fn class(self) -> &'static str {
match self {
Self::NotTheJournalledSlot => "slot_path_is_not_the_journalled_slot",
Self::PolicyRootDisagrees => "slot_root_disagrees_with_policy",
Self::Containment => "slot_escapes_its_root",
Self::SlotNotADirectory => "slot_is_not_a_directory",
Self::Enumeration => "slot_could_not_be_enumerated",
Self::WorkNotADirectory => "retained_work_is_not_a_directory",
Self::Deletion => "slot_entry_could_not_be_removed",
Self::Residue => "slot_still_holds_runner_state",
}
}
const fn remediation(self) -> &'static str {
match self {
Self::NotTheJournalledSlot | Self::PolicyRootDisagrees | Self::Containment => {
"the attempt keeps its slot lease and nothing was removed; correct the \
repository's persistent workspace path, or remove the slot directory by hand \
once you have confirmed what is in it"
}
Self::SlotNotADirectory | Self::WorkNotADirectory => {
"the attempt keeps its slot lease and nothing was removed; a job replaced the \
slot or its `_work` with a link, so inspect it before deleting anything and \
treat the retained workspace as untrusted"
}
Self::Enumeration | Self::Deletion | Self::Residue => {
"the attempt keeps its slot lease and will be cleaned again on the next pass; \
release whatever is holding the files open, or remove the slot's contents by \
hand leaving only `_work`"
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SlotQuarantine {
refusal: SlotRefusal,
detail: String,
}
impl SlotQuarantine {
fn new(refusal: SlotRefusal, detail: impl Into<String>) -> Self {
Self {
refusal,
detail: detail.into(),
}
}
}
impl fmt::Display for SlotQuarantine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}; {}", self.detail, self.refusal.remediation())
}
}
fn verify_journalled_slot(
runtime: &Path,
slot: NonZeroU16,
configured: Option<&LocalAbsolutePath>,
) -> Result<(), SlotQuarantine> {
let mislaid = || {
SlotQuarantine::new(
SlotRefusal::NotTheJournalledSlot,
format!(
"the journalled runtime {} is not the slot s{slot} this attempt was allocated as",
runtime.display()
),
)
};
let local = |path: &Path| {
path.to_str()
.and_then(|raw| LocalAbsolutePath::new(raw).ok())
.ok_or_else(mislaid)
};
let runtime_path = local(runtime)?;
let root = local(runtime.parent().ok_or_else(mislaid)?)?;
let name = AttemptWorkspace::persistent_slot(slot)
.slot_directory_name()
.expect("a persistent workspace names its slot directory");
let derived = runner_root::derive_child(&root, &name).map_err(|_| mislaid())?;
if derived != runtime_path {
return Err(mislaid());
}
if let Some(configured) = configured
&& configured != &root
{
return Err(SlotQuarantine::new(
SlotRefusal::PolicyRootDisagrees,
format!(
"the journalled slot {} is not under the repository's configured persistent root \
{}",
runtime.display(),
configured.as_str()
),
));
}
runner_root::verify_containment(&root, &derived).map_err(|source| {
SlotQuarantine::new(
SlotRefusal::Containment,
format!("the journalled slot is not inside the root it was allocated from: {source}"),
)
})
}
fn slot_is_present(slot: &Path) -> Result<bool, SlotQuarantine> {
match fs::symlink_metadata(slot) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(source) => Err(SlotQuarantine::new(
SlotRefusal::SlotNotADirectory,
format!(
"the slot {} could not be inspected: {:?}",
slot.display(),
source.kind()
),
)),
Ok(metadata) if !metadata.is_dir() || is_link_like(&metadata) => Err(SlotQuarantine::new(
SlotRefusal::SlotNotADirectory,
format!(
"the slot {} is a link or a file rather than a real directory",
slot.display()
),
)),
Ok(_) => Ok(true),
}
}
fn scrub_slot_entries(slot: &Path) -> Result<(), SlotQuarantine> {
let unreadable = |source: std::io::Error| {
SlotQuarantine::new(
SlotRefusal::Enumeration,
format!(
"the entries of {} could not be listed: {:?}",
slot.display(),
source.kind()
),
)
};
for entry in fs::read_dir(slot).map_err(unreadable)? {
let name = entry.map_err(unreadable)?.file_name();
let path = slot.join(&name);
let Some(metadata) = listed_entry_metadata(&path).map_err(unreadable)? else {
continue;
};
if is_work_folder(&name) {
if is_retainable_work_folder(&name, &metadata) {
continue;
}
return Err(SlotQuarantine::new(
SlotRefusal::WorkNotADirectory,
format!(
"the retained `{DEFAULT_WORK_FOLDER}` in {} is a link or a file rather than a \
real directory",
slot.display()
),
));
}
remove_slot_entry(&path, &metadata).map_err(|source| {
SlotQuarantine::new(
SlotRefusal::Deletion,
format!(
"an entry of {} could not be removed: {:?}",
slot.display(),
source.kind()
),
)
})?;
}
Ok(())
}
fn listed_entry_metadata(path: &Path) -> std::io::Result<Option<fs::Metadata>> {
match fs::symlink_metadata(path) {
Ok(metadata) => Ok(Some(metadata)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(source),
}
}
fn remove_slot_entry(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
let removed = if is_link_like(metadata) {
fs::remove_file(path).or_else(|_| fs::remove_dir(path))
} else if metadata.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
};
match removed {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
other => other,
}
}
fn verify_slot_scrubbed(slot: &Path) -> Result<(), SlotQuarantine> {
let unreadable = |source: std::io::Error| {
SlotQuarantine::new(
SlotRefusal::Enumeration,
format!(
"the entries of {} could not be listed to verify the scrub: {:?}",
slot.display(),
source.kind()
),
)
};
let mut residue = 0_usize;
let mut named: Vec<String> = Vec::new();
for entry in fs::read_dir(slot).map_err(unreadable)? {
let name = entry.map_err(unreadable)?.file_name();
let Some(metadata) = listed_entry_metadata(&slot.join(&name)).map_err(unreadable)? else {
continue;
};
if is_retainable_work_folder(&name, &metadata) {
continue;
}
residue = residue.saturating_add(1);
if name
.to_string_lossy()
.starts_with(RestrictiveHandoff::NAME_PREFIX)
{
named.push("an encoded JIT handoff".to_owned());
}
}
named.extend(
SENSITIVE_SLOT_ENTRIES
.iter()
.filter(|entry| fs::symlink_metadata(slot.join(entry)).is_ok())
.map(|entry| format!("`{entry}`")),
);
if residue == 0 && named.is_empty() {
return Ok(());
}
named.sort_unstable();
named.dedup();
Err(SlotQuarantine::new(
SlotRefusal::Residue,
residue_detail(slot, residue, &named),
))
}
fn residue_detail(slot: &Path, residue: usize, named: &[String]) -> String {
if residue == 0 {
format!(
"the listing of {} reported nothing but `{DEFAULT_WORK_FOLDER}`, yet {} survived \
cleanup",
slot.display(),
named.join(", ")
)
} else {
format!(
"{residue} entr{} other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}{}",
if residue == 1 { "y" } else { "ies" },
slot.display(),
if named.is_empty() {
String::new()
} else {
format!(", including {}", named.join(", "))
}
)
}
}
fn replacement_operation(outcome: &AttemptOutcome) -> Option<&'static str> {
match outcome {
AttemptOutcome::Failed {
reason: FailureReason::JitExpired,
} => Some("jit_expired_replacement"),
AttemptOutcome::Failed {
reason: FailureReason::ProcessExitedUnexpectedly,
} => Some("exit_before_acceptance_replacement"),
_ => None,
}
}
fn copy_package_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
copy_package_entries(source, destination, true)
}
fn copy_package_entries(source: &Path, destination: &Path, top_level: bool) -> std::io::Result<()> {
fs::create_dir_all(destination)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
if top_level && is_work_folder(&entry.file_name()) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"the runner package holds a top-level `{DEFAULT_WORK_FOLDER}`; copying \
it would overwrite the job workspace a persistent slot retains"
),
));
}
let target = destination.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_package_entries(&entry.path(), &target, false)?;
} else {
fs::copy(entry.path(), target)?;
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProcessStartFailure {
pub reason: FailureReason,
pub retryable: bool,
pub live_pid: Option<u32>,
}
impl ProcessStartFailure {
fn before_spawn(reason: FailureReason) -> Self {
Self {
reason,
retryable: true,
live_pid: None,
}
}
fn after_spawn_stopped() -> Self {
Self {
reason: FailureReason::ProcessStartFailed,
retryable: false,
live_pid: None,
}
}
fn after_spawn_live(pid: u32) -> Self {
Self::after_spawn_live_with_reason(pid, FailureReason::ProcessStartFailed)
}
fn after_spawn_live_with_reason(pid: u32, reason: FailureReason) -> Self {
Self {
reason,
retryable: false,
live_pid: Some(pid),
}
}
}
pub trait ProcessSupervisor: fmt::Debug + Send + Sync {
fn spawn(
&self,
attempt: &RunnerAttempt,
config: &EncodedJitConfig,
) -> Result<u32, ProcessStartFailure>;
fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason>;
fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason>;
fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool;
fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool;
fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
}
#[derive(Debug, Default)]
pub struct NativeProcesses {
children: Mutex<BTreeMap<AttemptId, ChildProcess>>,
successful_exits: Mutex<BTreeMap<AttemptId, bool>>,
#[cfg(test)]
post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
#[cfg(test)]
post_spawn_reaps: std::sync::atomic::AtomicUsize,
#[cfg(test)]
post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
#[cfg(test)]
use_long_lived_test_listener: std::sync::atomic::AtomicBool,
}
#[cfg(test)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PostSpawnBoundary {
HandoffDelete,
IdentitySerialize,
IdentityWrite,
ChildMapInsert,
}
impl NativeProcesses {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[cfg(test)]
fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
self.post_spawn_faults.lock().unwrap().push_back(boundary);
}
#[cfg(test)]
fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
let mut faults = self.post_spawn_faults.lock().unwrap();
if faults.front() == Some(&boundary) {
faults.pop_front();
true
} else {
false
}
}
#[cfg(test)]
fn fail_post_spawn_stops(&self, count: usize) {
self.post_spawn_stop_failures
.fetch_add(count, std::sync::atomic::Ordering::SeqCst);
}
#[cfg(test)]
fn fail_next_post_spawn_stop(&self) {
self.fail_post_spawn_stops(1);
}
#[cfg(test)]
fn use_long_lived_test_listener(&self) {
self.use_long_lived_test_listener
.store(true, std::sync::atomic::Ordering::SeqCst);
}
fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
#[cfg(test)]
if self
.post_spawn_stop_failures
.fetch_update(
std::sync::atomic::Ordering::SeqCst,
std::sync::atomic::Ordering::SeqCst,
|left| if left > 0 { Some(left - 1) } else { None },
)
.is_ok()
{
return Err(FailureReason::Other("injected runner stop failure".into()));
}
child
.stop(Duration::from_secs(1))
.map(|_| ())
.map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
}
fn abort_spawned_child(
&self,
mut child: ChildProcess,
attempt: &RunnerAttempt,
remove_identity: bool,
) -> ProcessStartFailure {
let mut reaped = self.stop_spawned_child(&mut child).is_ok();
if reaped {
if remove_identity {
Self::remove_identity_files(attempt);
}
} else {
let identity_durable =
serde_json::to_vec(child.identity())
.ok()
.is_some_and(|identity| {
self.persist_identity(attempt, &identity).is_ok()
|| self.persist_fallback_identity(attempt, &identity).is_ok()
});
if !identity_durable {
for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
if self.stop_spawned_child(&mut child).is_ok() {
reaped = true;
break;
}
}
if !reaped {
let pid = child.pid();
let marker = write_durable_file(
&Self::unresolved_process_path(attempt),
pid.to_string().as_bytes(),
);
self.children
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(attempt.id, child);
let reason = if marker.is_ok() {
FailureReason::Other(
"spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
.into(),
)
} else {
FailureReason::Other(
"spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
.into(),
)
};
return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
}
} else {
let pid = child.pid();
self.children
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(attempt.id, child);
return ProcessStartFailure::after_spawn_live(pid);
}
}
#[cfg(test)]
if reaped {
self.post_spawn_reaps
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
#[cfg(not(test))]
let _ = reaped;
ProcessStartFailure::after_spawn_stopped()
}
fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
attempt.runtime_path().join(IDENTITY_FILE)
}
fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
}
fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
}
fn remove_identity_files(attempt: &RunnerAttempt) {
let _ = fs::remove_file(Self::identity_path(attempt));
let _ = fs::remove_file(Self::fallback_identity_path(attempt));
let _ = fs::remove_file(Self::unresolved_process_path(attempt));
}
fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
self.persist_identity_at(&Self::identity_path(attempt), bytes)
}
fn persist_fallback_identity(
&self,
attempt: &RunnerAttempt,
bytes: &[u8],
) -> std::io::Result<()> {
self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
}
fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
#[cfg(test)]
if self.faults_at(PostSpawnBoundary::IdentityWrite) {
return Err(std::io::Error::other("injected identity write failure"));
}
write_durable_file(path, bytes)
}
fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
attempt.runtime_path().join(TERMINATE_INTENT_FILE)
}
fn read_identity(attempt: &RunnerAttempt) -> Result<Option<ProcessIdentity>, FailureReason> {
match Self::read_identity_at(&Self::identity_path(attempt))? {
Some(identity) => Ok(Some(identity)),
None => Self::read_identity_at(&Self::fallback_identity_path(attempt)),
}
}
fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
match fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map(Some)
.map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(_) => Err(FailureReason::Other(
"process identity journal could not be read".into(),
)),
}
}
}
impl ProcessSupervisor for NativeProcesses {
fn spawn(
&self,
attempt: &RunnerAttempt,
config: &EncodedJitConfig,
) -> Result<u32, ProcessStartFailure> {
let handoff = RestrictiveHandoff::create(
attempt.runtime_path(),
SecretString::from(config.expose().to_owned()),
)
.map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
#[cfg(windows)]
let program = attempt
.runtime_path()
.join("bin")
.join("Runner.Listener.exe");
#[cfg(not(windows))]
let program = attempt.runtime_path().join("bin").join("Runner.Listener");
if !program.is_file() {
return Err(ProcessStartFailure::before_spawn(
FailureReason::ProcessStartFailed,
));
}
#[cfg(test)]
let spec = if self
.use_long_lived_test_listener
.load(std::sync::atomic::Ordering::SeqCst)
{
SpawnSpec::new(program)
.args([
"--ignored",
"--exact",
"lifecycle::tests::long_lived_native_listener_helper",
"--nocapture",
])
.env(
"RUNNER_MANAGER_TEST_LISTENER_READY",
attempt.runtime_path().join(TEST_LISTENER_READY),
)
.working_dir(attempt.runtime_path())
} else {
runner_listener_spec(program, attempt.runtime_path())
};
#[cfg(not(test))]
let spec = runner_listener_spec(program, attempt.runtime_path());
let child = spec
.spawn_runner_with_handoff(&handoff)
.map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
#[cfg(test)]
if self.faults_at(PostSpawnBoundary::HandoffDelete) {
drop(handoff);
return Err(self.abort_spawned_child(child, attempt, false));
}
if handoff.delete().is_err() {
return Err(self.abort_spawned_child(child, attempt, false));
}
#[cfg(test)]
if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
return Err(self.abort_spawned_child(child, attempt, false));
}
let identity = match serde_json::to_vec(child.identity()) {
Ok(identity) => identity,
Err(_) => {
return Err(self.abort_spawned_child(child, attempt, false));
}
};
if self.persist_identity(attempt, &identity).is_err() {
return Err(self.abort_spawned_child(child, attempt, true));
}
let pid = child.pid();
#[cfg(test)]
if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
return Err(self.abort_spawned_child(child, attempt, true));
}
let mut children = self
.children
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
children.insert(attempt.id, child);
Ok(pid)
}
fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
let mut children = self
.children
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(child) = children.get_mut(&attempt.id) {
return match child
.try_exit_status()
.map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
{
None => Ok(true),
Some(status) => {
if let Ok(mut exits) = self.successful_exits.lock() {
exits.insert(attempt.id, status.success());
}
Ok(false)
}
};
}
let Some(identity) = Self::read_identity(attempt)? else {
if attempt.process_id().is_some() || Self::unresolved_process_path(attempt).is_file() {
return Err(FailureReason::Other(
"runner process identity is missing; refusing recovery until the process is resolved"
.into(),
));
}
return Ok(false);
};
match identity.recheck() {
Ok(Adoption::Live) => Ok(true),
Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
Err(_) => Ok(false),
}
}
fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
Ok(Self::read_identity(attempt)?.map(|identity| identity.pid()))
}
fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
self.successful_exits
.lock()
.ok()
.and_then(|exits| exits.get(&attempt.id).copied())
.unwrap_or(false)
}
fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
let path = Self::intent_path(attempt);
write_durable_file(&path, b"registration-timeout\n")
.map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
}
fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
Self::intent_path(attempt).is_file()
}
fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
let mut children = self
.children
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(child) = children.get_mut(&attempt.id) {
child
.stop(Duration::from_secs(10))
.map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
return Ok(());
}
let Some(identity) = Self::read_identity(attempt)? else {
return Ok(());
};
match identity
.terminate(Duration::from_secs(10))
.map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
{
Termination::Terminated | Termination::AlreadyGone => Ok(()),
Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
"runner PID was recycled; refusing to signal it".into(),
)),
}
}
}
pub struct LifecyclePorts {
pub store: Arc<dyn Store>,
pub github: Arc<dyn LifecycleGithub>,
pub packages: Arc<dyn RuntimePackages>,
pub processes: Arc<dyn ProcessSupervisor>,
pub clock: Arc<dyn Clock>,
pub demand: Arc<dyn DemandPersistence>,
pub delay: Arc<dyn RetryDelay>,
pub events: Arc<dyn AttemptEventSink>,
pub reconcile_events: Arc<dyn EventSink>,
}
impl fmt::Debug for LifecyclePorts {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LifecyclePorts")
.field("store", &self.store)
.field("github", &self.github)
.field("packages", &self.packages)
.field("processes", &self.processes)
.finish_non_exhaustive()
}
}
#[derive(Debug, thiserror::Error)]
pub enum LifecycleError {
#[error("attempt journal operation failed")]
Journal,
#[error("attempt {0} is not in the journal")]
Missing(AttemptId),
#[error("attempt lifecycle transition was refused")]
Transition,
#[error("startup recovery has not completed")]
RecoveryIncomplete,
#[error("the persistent slot was not cleaned: {detail}")]
SlotQuarantined {
class: &'static str,
detail: String,
},
#[error("runner lifecycle failed: {0}")]
Failed(FailureReason),
}
impl LifecycleError {
fn reason(&self) -> FailureReason {
match self {
Self::Failed(reason) => reason.clone(),
Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
Self::Transition => FailureReason::Other("attempt transition was refused".into()),
Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
}
}
}
#[derive(Debug)]
pub struct LifecycleLauncher {
host_id: HostId,
app_paths: runner_manager_platform::paths::AppPaths,
diagnostics_root: PathBuf,
runner_group_id: u64,
timeouts: RecoveryTimeouts,
retry: RetryPolicy,
cancel: CancelToken,
ports: LifecyclePorts,
recovery_complete: Mutex<bool>,
versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReconcileProgress {
Reconciled,
Deferred,
Replacement {
attempt: AttemptId,
operation: &'static str,
},
}
impl LifecycleLauncher {
#[must_use]
pub fn new(
host_id: HostId,
app_paths: runner_manager_platform::paths::AppPaths,
diagnostics_root: impl Into<PathBuf>,
runner_group_id: u64,
timeouts: RecoveryTimeouts,
retry: RetryPolicy,
ports: LifecyclePorts,
) -> Self {
Self {
host_id,
app_paths,
diagnostics_root: diagnostics_root.into(),
runner_group_id,
timeouts,
retry,
cancel: CancelToken::new(),
ports,
recovery_complete: Mutex::new(false),
versions: Mutex::new(BTreeMap::new()),
pending_replacements: Mutex::new(BTreeMap::new()),
}
}
pub async fn recover_startup(
&self,
policies: &[ScalePolicy],
) -> Result<Vec<ReplacementIntent>, LifecycleError> {
let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
let attempts = self
.ports
.store
.attempts()
.map_err(|_| LifecycleError::Journal)?;
let mut unresolved = false;
for attempt in attempts {
let Some(policy) = by_id.get(&attempt.policy_id) else {
if !attempt.is_terminal() && attempt.state() != AttemptState::Cleaned {
unresolved = true;
}
continue;
};
authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
match self.reconcile_one(policy, attempt).await? {
ReconcileProgress::Deferred => unresolved = true,
ReconcileProgress::Replacement { attempt, operation } => {
self.pending_replacements
.lock()
.map_err(|_| LifecycleError::Journal)?
.insert(
attempt,
ReplacementIntent {
policy: policy.id,
previous_attempt: attempt,
operation,
},
);
}
ReconcileProgress::Reconciled => {}
}
}
if unresolved {
return Err(LifecycleError::RecoveryIncomplete);
}
*self
.recovery_complete
.lock()
.map_err(|_| LifecycleError::Journal)? = true;
Ok(self
.pending_replacements
.lock()
.map_err(|_| LifecycleError::Journal)?
.values()
.copied()
.collect())
}
pub async fn supervise(
&self,
policy: &ScalePolicy,
) -> Result<Vec<ReplacementIntent>, LifecycleError> {
let mut replacements = Vec::new();
self.pending_replacements
.lock()
.map_err(|_| LifecycleError::Journal)?
.retain(|_, intent| {
if intent.policy == policy.id {
replacements.push(*intent);
false
} else {
true
}
});
let attempts = self
.ports
.store
.attempts_for_policy(policy.id)
.map_err(|_| LifecycleError::Journal)?;
for attempt in attempts {
authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
if let ReconcileProgress::Replacement { attempt, operation } =
self.reconcile_one(policy, attempt).await?
{
replacements.push(ReplacementIntent {
policy: policy.id,
previous_attempt: attempt,
operation,
});
}
}
Ok(replacements)
}
async fn reconcile_one(
&self,
policy: &ScalePolicy,
mut attempt: RunnerAttempt,
) -> Result<ReconcileProgress, LifecycleError> {
if attempt.state() == AttemptState::Cleaned {
return Ok(ReconcileProgress::Reconciled);
}
if attempt.is_terminal() {
self.clean_or_quarantine(&mut attempt)?;
return Ok(ReconcileProgress::Reconciled);
}
let process_alive = self
.ports
.processes
.is_alive(&attempt)
.map_err(LifecycleError::Failed)?;
let github = self
.ports
.github
.observe(&policy.target, attempt.id, &self.cancel)
.await;
if let Some(runner_id) = github.runner_id
&& read_runner_id(attempt.runtime_path()).is_none()
{
write_runner_id(attempt.runtime_path(), runner_id)?;
self.ports
.events
.emit(AttemptEvent::RemoteIdentityRecovered {
attempt: attempt.id,
runner_id,
});
}
if attempt.state() == AttemptState::JitReceived
&& process_alive
&& let Some(pid) = self
.ports
.processes
.recovered_pid(&attempt)
.map_err(LifecycleError::Failed)?
{
attempt
.started(pid, self.ports.clock.now())
.map_err(|_| LifecycleError::Transition)?;
self.record(&attempt)?;
}
if attempt.state() == AttemptState::Busy
&& !process_alive
&& github.status == GithubRunnerObservation::NotRegistered
&& self.ports.processes.completed_successfully(&attempt)
{
self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
self.clean_or_quarantine(&mut attempt)?;
return Ok(ReconcileProgress::Reconciled);
}
if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
self.deregister_runner(policy, &attempt).await;
self.conclude(
&mut attempt,
AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
)?;
self.clean_or_quarantine(&mut attempt)?;
return Ok(ReconcileProgress::Replacement {
attempt: attempt.id,
operation: "registration_timeout_replacement",
});
}
if matches!(
attempt.state(),
AttemptState::Allocated | AttemptState::JitReceived
) && !process_alive
&& matches!(github.status, GithubRunnerObservation::Registered { .. })
{
if attempt.state() == AttemptState::Allocated {
attempt
.jit_received(self.ports.clock.now())
.map_err(|_| LifecycleError::Transition)?;
self.record(&attempt)?;
}
self.deregister_runner(policy, &attempt).await;
self.conclude(
&mut attempt,
AttemptOutcome::failed(FailureReason::JitExpired),
)?;
self.clean_or_quarantine(&mut attempt)?;
return Ok(ReconcileProgress::Replacement {
attempt: attempt.id,
operation: "jit_expired_replacement",
});
}
match recovery_decision(
&attempt,
RecoveryObservation {
process_alive,
github: github.status,
},
self.timeouts,
self.ports.clock.as_ref(),
) {
RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
RecoveryDecision::Adopt => {
self.ports.events.emit(AttemptEvent::Adopted {
attempt: attempt.id,
});
Ok(ReconcileProgress::Reconciled)
}
RecoveryDecision::Clean => {
self.clean_or_quarantine(&mut attempt)?;
Ok(ReconcileProgress::Reconciled)
}
RecoveryDecision::Observe(state) => {
let runner_id = attempt
.github_runner_id()
.or(github.runner_id)
.or_else(|| read_runner_id(attempt.runtime_path()))
.ok_or(LifecycleError::Transition)?;
match state {
AttemptState::JitReceived => attempt
.jit_received(self.ports.clock.now())
.map_err(|_| LifecycleError::Transition)?,
AttemptState::Starting => {
let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
attempt
.started(pid, self.ports.clock.now())
.map_err(|_| LifecycleError::Transition)?;
}
AttemptState::Idle => attempt
.registered_idle(runner_id, self.ports.clock.now())
.map_err(|_| LifecycleError::Transition)?,
AttemptState::Busy => attempt
.assigned_job(runner_id, self.ports.clock.now())
.map_err(|_| LifecycleError::Transition)?,
_ => return Err(LifecycleError::Transition),
}
self.record(&attempt)?;
Ok(ReconcileProgress::Reconciled)
}
RecoveryDecision::Conclude(outcome) => {
let replacement = replacement_operation(&outcome);
if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
self.deregister_runner(policy, &attempt).await;
}
self.conclude(&mut attempt, outcome)?;
self.clean_or_quarantine(&mut attempt)?;
Ok(
replacement.map_or(ReconcileProgress::Reconciled, |operation| {
ReconcileProgress::Replacement {
attempt: attempt.id,
operation,
}
}),
)
}
RecoveryDecision::Terminate(payload) => {
let idle_exit = payload.is_idle_exit();
self.ports
.processes
.record_terminate_intent(&attempt)
.map_err(LifecycleError::Failed)?;
self.ports.events.emit(AttemptEvent::TerminateIntent {
attempt: attempt.id,
});
self.ports
.processes
.terminate(&attempt)
.map_err(LifecycleError::Failed)?;
if self
.ports
.processes
.is_alive(&attempt)
.map_err(LifecycleError::Failed)?
{
return Ok(ReconcileProgress::Deferred);
}
self.ports.events.emit(AttemptEvent::Terminated {
attempt: attempt.id,
});
let outcome = if idle_exit {
AttemptOutcome::ExitedIdleWithoutWork
} else {
AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
};
self.deregister_runner(policy, &attempt).await;
self.conclude(&mut attempt, outcome)?;
self.clean_or_quarantine(&mut attempt)?;
if idle_exit {
Ok(ReconcileProgress::Reconciled)
} else {
Ok(ReconcileProgress::Replacement {
attempt: attempt.id,
operation: "registration_timeout_replacement",
})
}
}
}
}
fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
self.ports
.store
.record_attempt(attempt)
.map_err(|_| LifecycleError::Journal)?;
self.ports.events.emit(AttemptEvent::State {
attempt: attempt.id,
state: attempt.state(),
});
Ok(())
}
async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
let Some(runner_id) = attempt
.github_runner_id()
.or_else(|| read_runner_id(attempt.runtime_path()))
else {
return;
};
if self
.ports
.github
.deregister(&policy.target, runner_id, &self.cancel)
.await
{
self.ports.events.emit(AttemptEvent::Deregistered {
attempt: attempt.id,
runner_id,
});
} else {
tracing::warn!(
attempt = %attempt.id,
runner_id,
"the runner registration could not be removed from GitHub; it will show in the \
target's runner settings until GitHub retires it or a later pass removes it"
);
}
}
fn conclude(
&self,
attempt: &mut RunnerAttempt,
outcome: AttemptOutcome,
) -> Result<(), LifecycleError> {
attempt
.conclude(outcome.clone(), self.ports.clock.now())
.map_err(|_| LifecycleError::Transition)?;
self.record(attempt)?;
self.ports.events.emit(AttemptEvent::Concluded {
attempt: attempt.id,
outcome: OutcomeKind::of(&outcome),
});
Ok(())
}
fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
match self.clean_attempt(attempt) {
Err(LifecycleError::SlotQuarantined { class, .. }) => {
self.ports
.reconcile_events
.emit(LifecycleEvent::AttemptCleanFailed {
policy: attempt.policy_id,
attempt: attempt.id,
reason: class,
});
Ok(())
}
other => other,
}
}
fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
let outcome = attempt
.outcome()
.cloned()
.ok_or(LifecycleError::Transition)?;
self.preserve_diagnostics(attempt, &outcome)?;
self.scrub_workspace(attempt)?;
self.ports
.packages
.release(attempt.id)
.map_err(LifecycleError::Failed)?;
attempt
.clean(self.ports.clock.now())
.map_err(|_| LifecycleError::Transition)?;
self.record(attempt)?;
let kind = OutcomeKind::of(&outcome);
self.ports.events.emit(AttemptEvent::Cleaned {
attempt: attempt.id,
outcome: kind,
});
self.ports
.reconcile_events
.emit(LifecycleEvent::AttemptCleaned {
policy: attempt.policy_id,
attempt: attempt.id,
outcome: kind,
});
Ok(())
}
fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
#[cfg(test)]
{
if matches!(
std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
Ok("skip_workspace_cleanup" | "reuse_job_workspace")
) {
return Ok(());
}
}
match attempt.workspace() {
AttemptWorkspace::Ephemeral => match fs::remove_dir_all(attempt.runtime_path()) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(_) => Err(LifecycleError::Failed(FailureReason::Other(
"attempt workspace could not be removed".into(),
))),
},
AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
}
}
fn scrub_persistent_slot(
&self,
attempt: &RunnerAttempt,
slot: NonZeroU16,
) -> Result<(), LifecycleError> {
let configured = self
.ports
.store
.policy(attempt.policy_id)
.map_err(|_| LifecycleError::Journal)?
.and_then(|policy| match policy.workspace_policy() {
WorkspacePolicy::Persistent { root } => Some(root.clone()),
WorkspacePolicy::Ephemeral => None,
});
let runtime = attempt.runtime_path();
self.quarantine_on_refusal(
attempt,
verify_journalled_slot(runtime, slot, configured.as_ref())
.and_then(|()| slot_is_present(runtime))
.and_then(|present| {
if present {
scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
} else {
Ok(())
}
}),
)
}
fn quarantine_on_refusal(
&self,
attempt: &RunnerAttempt,
outcome: Result<(), SlotQuarantine>,
) -> Result<(), LifecycleError> {
let Err(quarantine) = outcome else {
return Ok(());
};
let detail = quarantine.to_string();
tracing::warn!(
attempt = %attempt.id,
policy = %attempt.policy_id,
slot = attempt.workspace().slot_number(),
refusal = quarantine.refusal.class(),
"{detail}"
);
Err(LifecycleError::SlotQuarantined {
class: quarantine.refusal.class(),
detail,
})
}
fn preserve_diagnostics(
&self,
attempt: &RunnerAttempt,
outcome: &AttemptOutcome,
) -> Result<(), LifecycleError> {
fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
LifecycleError::Failed(FailureReason::Other(
"diagnostics directory could not be created".into(),
))
})?;
let diagnostic = format!(
"attempt_id={}\npolicy_id={}\noutcome={}\n",
attempt.id,
attempt.policy_id,
OutcomeKind::of(outcome).as_str()
);
fs::write(
self.diagnostics_root.join(format!("{}.log", attempt.id)),
diagnostic,
)
.map_err(|_| {
LifecycleError::Failed(FailureReason::Other(
"redacted diagnostics could not be preserved".into(),
))
})
}
async fn materialize_with_retry(
&self,
policy: &ScalePolicy,
attempt: &RunnerAttempt,
) -> Result<RunnerVersion, FailureReason> {
let mut issued = 0_u32;
loop {
issued = issued.saturating_add(1);
match self.ports.packages.materialize(attempt).await {
Ok(version) => return Ok(version),
Err(reason)
if package_failure_is_terminal(&reason)
|| issued >= self.retry.max_attempts.max(1) =>
{
return Err(reason);
}
Err(reason) => {
if !self.ports.demand.persists(policy.id).await {
return Err(reason);
}
let delay = self.retry.delay(issued);
self.ports.events.emit(AttemptEvent::Retry {
attempt: attempt.id,
operation: "package_materialization",
delay,
});
self.ports.delay.wait(delay).await;
if !self.ports.demand.persists(policy.id).await {
return Err(reason);
}
}
}
}
}
async fn register_with_retry(
&self,
policy: &ScalePolicy,
attempt: AttemptId,
request: &JitRunnerRequest,
) -> Result<JitRegistration, LifecycleError> {
let mut issued = 0_u32;
loop {
issued = issued.saturating_add(1);
match self
.ports
.github
.register(&policy.target, request, &self.cancel)
.await
{
Ok(registration) => return Ok(registration),
Err(error) if error.terminal => {
return Err(LifecycleError::Failed(error.reason));
}
Err(error) => {
if issued >= self.retry.max_attempts.max(1)
|| !self.ports.demand.persists(policy.id).await
{
return Err(LifecycleError::Failed(error.reason));
}
let delay = error
.retry_after
.unwrap_or_else(|| self.retry.delay(issued));
self.ports.events.emit(AttemptEvent::Retry {
attempt,
operation: "jit_request",
delay,
});
self.ports.delay.wait(delay).await;
if !self.ports.demand.persists(policy.id).await {
return Err(LifecycleError::Failed(error.reason));
}
}
}
}
}
fn allocate_workspace(
&self,
policy: &ScalePolicy,
id: AttemptId,
) -> Result<Placement, LifecycleError> {
let placement = match policy.workspace_policy() {
WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
};
if placement.is_ok() {
self.root_accepted(policy.id);
}
placement
}
fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
let host = self
.ports
.store
.host(self.host_id)
.map_err(|_| LifecycleError::Journal)?
.ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
Ok(host.runner_root_override.clone())
}
fn effective_host_root(
&self,
policy: &ScalePolicy,
) -> Result<LocalAbsolutePath, LifecycleError> {
match self.configured_host_root()? {
Some(configured) => Ok(configured),
None => default_runner_root(&self.app_paths).map_err(|error| {
self.root_refused(policy.id, "the platform default runner root", error)
}),
}
}
fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
let _ = runner_manager_platform::service::record_runner_root_refusal(
&self.app_paths,
&policy.to_string(),
self.ports.clock.now(),
error.kind(),
root,
&error.to_string(),
);
root_failure(error)
}
fn root_accepted(&self, policy: PolicyId) {
let _ = runner_manager_platform::service::clear_runner_root_refusal(
&self.app_paths,
&policy.to_string(),
);
}
fn allocate_disposable(
&self,
policy: &ScalePolicy,
id: AttemptId,
) -> Result<Placement, LifecycleError> {
let effective_root = self.effective_host_root(policy)?;
RootPreflight::new(&self.app_paths)
.check(&RootOwner::Host, &effective_root)
.map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
let runtime = effective_root.as_path().join({
#[cfg(test)]
{
if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
== Ok("reuse_job_workspace")
{
"mutant-shared-workspace".to_owned()
} else {
workspace_name(id)
}
}
#[cfg(not(test))]
{
workspace_name(id)
}
});
fs::create_dir_all(&runtime)
.map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
Ok(Placement {
runtime,
workspace: AttemptWorkspace::Ephemeral,
})
}
fn allocate_persistent_slot(
&self,
policy: &ScalePolicy,
root: &LocalAbsolutePath,
) -> Result<Placement, LifecycleError> {
let ceiling = policy.max_capacity().ok_or_else(|| {
LifecycleError::Failed(FailureReason::Other(
"a persistent workspace needs the policy's max_capacity to bound its slots"
.to_string(),
))
})?;
let leases = self
.ports
.store
.slot_leases_for_policy(policy.id)
.map_err(|_| LifecycleError::Journal)?;
let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
LifecycleError::Failed(FailureReason::Other(format!(
"every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
not been cleaned, so no slot is free; raise the repository's max capacity, or \
finish cleaning a concluded attempt",
policy.target
)))
})?;
let workspace = AttemptWorkspace::persistent_slot(slot);
let name = workspace
.slot_directory_name()
.expect("a persistent allocation names its slot directory");
let host_root = self
.configured_host_root()?
.or_else(|| default_runner_root(&self.app_paths).ok());
let mut preflight = RootPreflight::new(&self.app_paths);
if let Some(host_root) = host_root {
preflight = preflight.against(RootOwner::Host, host_root);
}
let checked = preflight
.check(&RootOwner::Repository(policy.target.to_string()), root)
.map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
if let Some(leaf) = checked.leaf_to_create() {
fs::create_dir(leaf).map_err(|source| {
LifecycleError::Failed(FailureReason::Other(format!(
"the persistent workspace root {} could not be created: {source}",
leaf.display()
)))
})?;
}
let slot_path = runner_root::derive_child(root, &name)
.map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
create_or_validate_slot(slot_path.as_path())?;
runner_root::verify_containment(root, &slot_path)
.map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
accept_reusable_slot(slot_path.as_path())?;
Ok(Placement {
runtime: slot_path.as_path().to_path_buf(),
workspace,
})
}
fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
match self.ports.store.record_attempt(attempt) {
Ok(()) => {
self.ports.events.emit(AttemptEvent::State {
attempt: attempt.id,
state: attempt.state(),
});
Ok(())
}
Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
FailureReason::Other(error.to_string()),
)),
Err(_) => Err(LifecycleError::Journal),
}
}
async fn launch_attempt(
&self,
policy: &ScalePolicy,
allocation_guard: &AllocationGuard,
) -> Result<RunnerAttempt, LifecycleError> {
if !*self
.recovery_complete
.lock()
.map_err(|_| LifecycleError::Journal)?
{
return Err(LifecycleError::RecoveryIncomplete);
}
let labels = policy
.routing_labels()
.ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
let id = AttemptId::new_random();
let placement = self.allocate_workspace(policy, id)?;
let mut attempt = RunnerAttempt::allocate_in(
id,
policy.id,
placement.runtime,
placement.workspace,
self.ports.clock.now(),
);
self.record_allocation(&attempt)?;
let version = match self.materialize_with_retry(policy, &attempt).await {
Ok(version) => version,
Err(reason) => return self.fail_launch(&mut attempt, reason),
};
self.prune_under_allocation_lock(allocation_guard, &version)?;
self.versions
.lock()
.map_err(|_| LifecycleError::Journal)?
.insert(id, version);
let jit_request =
JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
let registration = match self.register_with_retry(policy, id, &jit_request).await {
Ok(registration) => registration,
Err(error) => return self.fail_launch(&mut attempt, error.reason()),
};
let runner_id = registration.runner().id;
write_runner_id(attempt.runtime_path(), runner_id)?;
attempt
.jit_received(self.ports.clock.now())
.map_err(|_| LifecycleError::Transition)?;
self.record(&attempt)?;
let config = registration.into_config();
let mut issued = 0_u32;
let pid = loop {
issued = issued.saturating_add(1);
match self.ports.processes.spawn(&attempt, &config) {
Ok(pid) => break pid,
Err(error) => {
if let Some(pid) = error.live_pid {
attempt
.started(pid, self.ports.clock.now())
.map_err(|_| LifecycleError::Transition)?;
self.record(&attempt)?;
return Err(LifecycleError::Failed(error.reason));
}
if !error.retryable
|| issued >= self.retry.max_attempts.max(1)
|| !self.ports.demand.persists(policy.id).await
{
return self.fail_launch(&mut attempt, error.reason);
}
let delay = self.retry.delay(issued);
self.ports.events.emit(AttemptEvent::Retry {
attempt: attempt.id,
operation: "process_start",
delay,
});
self.ports.delay.wait(delay).await;
if !self.ports.demand.persists(policy.id).await {
return self.fail_launch(&mut attempt, error.reason);
}
}
}
};
attempt
.started(pid, self.ports.clock.now())
.map_err(|_| LifecycleError::Transition)?;
self.record(&attempt)?;
Ok(attempt)
}
fn fail_launch<T>(
&self,
attempt: &mut RunnerAttempt,
reason: FailureReason,
) -> Result<T, LifecycleError> {
self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
Err(LifecycleError::Failed(reason))
}
fn prune_under_allocation_lock(
&self,
guard: &AllocationGuard,
version: &RunnerVersion,
) -> Result<(), LifecycleError> {
let attempts = self
.ports
.store
.attempts()
.map_err(|_| LifecycleError::Journal)?;
self.ports
.packages
.prune_obsolete_guarded(
PruneAuthority::from_launch_request(guard),
version,
&attempts,
)
.map_err(LifecycleError::Failed)
}
}
#[async_trait]
impl RunnerLauncher for LifecycleLauncher {
async fn supervise(
&self,
policy: &ScalePolicy,
) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
LifecycleLauncher::supervise(self, policy)
.await
.map_err(|error| LaunchFailure::new(error.reason()))
}
async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
self.ports.store.attempts().map_err(|_| {
LaunchFailure::new(FailureReason::Other(
"attempt journal could not be read".into(),
))
})
}
async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
self.launch_attempt(request.policy, request.allocation_guard)
.await
.map_err(|error| LaunchFailure::new(error.reason()))
}
async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
let mut attempt = self
.ports
.store
.attempt(id)
.map_err(|_| {
LaunchFailure::new(FailureReason::Other(
"attempt journal could not be read".into(),
))
})?
.ok_or_else(|| {
LaunchFailure::new(FailureReason::Other(
"attempt disappeared from the journal".into(),
))
})?;
self.clean_attempt(&mut attempt)
.map_err(|error| LaunchFailure::new(error.reason()))
}
}
fn runner_name(attempt: AttemptId) -> String {
format!("runner-manager-{attempt}")
}
fn read_runner_id(runtime: &Path) -> Option<u64> {
fs::read_to_string(runtime.join(RUNNER_ID_FILE))
.ok()?
.trim()
.parse()
.ok()
}
fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
let target = runtime.join(RUNNER_ID_FILE);
if let Some(existing) = read_runner_id(runtime) {
return (existing == runner_id)
.then_some(())
.ok_or(LifecycleError::Journal);
}
let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
write_durable_file(&temporary, runner_id.to_string().as_bytes())
.map_err(|_| LifecycleError::Journal)?;
match fs::rename(&temporary, &target) {
Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
let _ = fs::remove_file(&temporary);
(read_runner_id(runtime) == Some(runner_id))
.then_some(())
.ok_or(LifecycleError::Journal)
}
Err(_) => {
let _ = fs::remove_file(&temporary);
Err(LifecycleError::Journal)
}
}
}
fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(path)?;
file.write_all(bytes)?;
file.sync_all()?;
let parent = path.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"file has no parent directory",
)
})?;
sync_directory(parent)
}
#[cfg(unix)]
fn sync_directory(path: &Path) -> std::io::Result<()> {
fs::File::open(path)?.sync_all()
}
#[cfg(windows)]
fn sync_directory(path: &Path) -> std::io::Result<()> {
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
const FILE_SHARE_ALL: u32 = 0x0000_0007;
const GENERIC_WRITE: u32 = 0x4000_0000;
fs::OpenOptions::new()
.access_mode(GENERIC_WRITE)
.share_mode(FILE_SHARE_ALL)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
.open(path)?
.sync_all()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use crate::reconcile::{AllocationLock, InProcessAllocationLock};
use runner_manager_domain::model::{Elapsed, TargetScope};
use runner_manager_domain::store::SqliteStore;
use runner_manager_github::jit::JitRunner;
use runner_manager_testkit::clock::FakeClock;
use runner_manager_testkit::fixtures;
type CapturedFields = Vec<(String, String)>;
#[derive(Clone, Default)]
struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
fn on_event(
&self,
event: &tracing::Event<'_>,
_context: tracing_subscriber::layer::Context<'_, S>,
) {
struct Collect(Vec<(String, String)>);
impl tracing::field::Visit for Collect {
fn record_debug(
&mut self,
field: &tracing::field::Field,
value: &dyn std::fmt::Debug,
) {
self.0.push((
field.name().to_owned(),
format!("{value:?}").trim_matches('"').to_owned(),
));
}
}
let mut collected = Collect(Vec::new());
event.record(&mut collected);
self.0
.lock()
.expect("the capture mutex is not poisoned")
.push(collected.0);
}
}
#[test]
fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
use runner_manager_platform::logging;
use tracing_subscriber::layer::SubscriberExt as _;
let captured = CapturedEvents::default();
let error = RunnerRootError::DeniedByPrivacyPolicy {
requested: PathBuf::from("/Volumes/NVME/runners"),
refused: PathBuf::from("/Volumes/NVME"),
remediation: RootOwner::Host.remediation(),
};
let kind = error.kind();
let failure = tracing::subscriber::with_default(
tracing_subscriber::registry().with(captured.clone()),
|| root_failure(error),
);
assert!(
matches!(
&failure,
LifecycleError::Failed(FailureReason::Other(detail))
if detail.contains("/Volumes/NVME/runners")
&& detail.contains("Full Disk Access")
),
"the reason must still carry the detail: {failure:?}"
);
let events = captured
.0
.lock()
.expect("the capture mutex is not poisoned")
.clone();
let event = events
.iter()
.find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
.unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
for (name, value) in event {
assert!(
logging::is_field_allowed(name),
"`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
logging::REDACTION
);
assert_eq!(
&logging::redact(value),
value,
"`{name}` does not survive value-shape scrubbing: {event:?}"
);
}
}
fn nz(slot: u16) -> NonZeroU16 {
NonZeroU16::new(slot).expect("a positive slot")
}
const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
#[derive(Debug, Default)]
struct FakeGithubLifecycle {
registration_failures: Mutex<VecDeque<bool>>,
observations: Mutex<VecDeque<LifecycleGithubObservation>>,
registrations: AtomicUsize,
remaining_runners: AtomicUsize,
deregistrations: Mutex<Vec<u64>>,
deregistration_fails: AtomicBool,
journal: Mutex<Option<Arc<SqliteStore>>>,
registration_facts: Mutex<Vec<RegistrationFact>>,
}
#[derive(Debug, Clone)]
struct RegistrationFact {
leased_slots: Vec<u16>,
work_folder: String,
runner_name: String,
}
impl FakeGithubLifecycle {
fn fail(mut self, terminal: bool) -> Self {
self.registration_failures
.get_mut()
.expect("unpoisoned")
.push_back(terminal);
self
}
fn watch_journal(&self, store: Arc<SqliteStore>) {
*self.journal.lock().unwrap() = Some(store);
}
fn registration_facts(&self) -> Vec<RegistrationFact> {
self.registration_facts.lock().unwrap().clone()
}
fn observe(&self, observation: GithubRunnerObservation) {
let observation = match observation {
GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
GithubRunnerObservation::NotRegistered => {
LifecycleGithubObservation::not_registered()
}
GithubRunnerObservation::Registered { busy } => {
LifecycleGithubObservation::registered(73, busy)
}
};
self.observations.lock().unwrap().push_back(observation);
}
}
#[async_trait]
impl LifecycleGithub for FakeGithubLifecycle {
async fn register(
&self,
_target: &ScaleTarget,
request: &JitRunnerRequest,
_cancel: &CancelToken,
) -> Result<JitRegistration, JitRequestFailure> {
self.registrations.fetch_add(1, Ordering::SeqCst);
if let Some(store) = self.journal.lock().unwrap().as_ref() {
let slots = store
.attempts()
.expect("the journal is readable")
.iter()
.filter_map(|attempt| attempt.workspace().slot_number())
.collect();
self.registration_facts
.lock()
.unwrap()
.push(RegistrationFact {
leased_slots: slots,
work_folder: request.work_folder().to_string(),
runner_name: request.name().to_string(),
});
}
if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
return Err(JitRequestFailure {
terminal,
reason: if terminal {
FailureReason::Other("GitHub refused JIT registration with 403".into())
} else {
FailureReason::JitRequestFailed
},
retry_after: None,
});
}
self.remaining_runners.store(1, Ordering::SeqCst);
Ok(JitRegistration::new(
EncodedJitConfig::new(JIT),
JitRunner {
id: 73,
name: request.name().to_string(),
os: "windows".into(),
status: "offline".into(),
busy: false,
runner_group_id: Some(1),
labels: request.labels().to_vec(),
},
))
}
async fn observe(
&self,
_target: &ScaleTarget,
_attempt: AttemptId,
_cancel: &CancelToken,
) -> LifecycleGithubObservation {
let observation = self
.observations
.lock()
.unwrap()
.pop_front()
.unwrap_or(LifecycleGithubObservation::not_registered());
if observation.status == GithubRunnerObservation::NotRegistered {
self.remaining_runners.store(0, Ordering::SeqCst);
}
observation
}
async fn deregister(
&self,
_target: &ScaleTarget,
runner_id: u64,
_cancel: &CancelToken,
) -> bool {
self.deregistrations.lock().unwrap().push(runner_id);
if self.deregistration_fails.load(Ordering::SeqCst) {
return false;
}
self.remaining_runners.store(0, Ordering::SeqCst);
true
}
}
#[derive(Debug)]
struct FakePackages {
version: RunnerVersion,
leases: Mutex<BTreeSet<AttemptId>>,
materializations: AtomicUsize,
materialization_failures: AtomicUsize,
releases: AtomicUsize,
prunes: AtomicUsize,
prune_currents: Mutex<Vec<RunnerVersion>>,
}
impl Default for FakePackages {
fn default() -> Self {
Self {
version: RunnerVersion::parse("2.330.0").unwrap(),
leases: Mutex::new(BTreeSet::new()),
materializations: AtomicUsize::new(0),
materialization_failures: AtomicUsize::new(0),
releases: AtomicUsize::new(0),
prunes: AtomicUsize::new(0),
prune_currents: Mutex::new(Vec::new()),
}
}
}
impl FakePackages {
fn fail_materializations(&self, count: usize) {
self.materialization_failures.store(count, Ordering::SeqCst);
}
}
#[async_trait]
impl RuntimePackages for FakePackages {
async fn materialize(
&self,
attempt: &RunnerAttempt,
) -> Result<RunnerVersion, FailureReason> {
self.materializations.fetch_add(1, Ordering::SeqCst);
if self
.materialization_failures
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
if left > 0 { Some(left - 1) } else { None }
})
.is_ok()
{
return Err(FailureReason::Other(
"runner package materialization failed transiently".into(),
));
}
fs::create_dir_all(attempt.runtime_path()).unwrap();
fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
self.leases.lock().unwrap().insert(attempt.id);
Ok(self.version.clone())
}
fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
self.leases.lock().unwrap().remove(&attempt);
self.releases.fetch_add(1, Ordering::SeqCst);
Ok(())
}
fn prune_obsolete_guarded(
&self,
_authority: PruneAuthority<'_>,
current: &RunnerVersion,
_attempts: &[RunnerAttempt],
) -> Result<(), FailureReason> {
self.prunes.fetch_add(1, Ordering::SeqCst);
self.prune_currents.lock().unwrap().push(current.clone());
Ok(())
}
}
#[derive(Debug, Default)]
struct FakeProcesses {
alive: AtomicBool,
completed_successfully: AtomicBool,
spawns: AtomicUsize,
spawn_failures: AtomicUsize,
live_spawn_failure: AtomicBool,
terminations: AtomicUsize,
intent: AtomicBool,
intent_failure: AtomicBool,
actions: Mutex<Vec<&'static str>>,
saw_secret: AtomicBool,
}
impl FakeProcesses {
fn fail_spawns(&self, count: usize) {
self.spawn_failures.store(count, Ordering::SeqCst);
}
fn fail_spawn_with_live_child(&self) {
self.live_spawn_failure.store(true, Ordering::SeqCst);
}
fn set_alive(&self, alive: bool) {
self.alive.store(alive, Ordering::SeqCst);
}
fn finish_successfully(&self) {
self.completed_successfully.store(true, Ordering::SeqCst);
self.alive.store(false, Ordering::SeqCst);
}
fn fail_intent(&self) {
self.intent_failure.store(true, Ordering::SeqCst);
}
}
impl ProcessSupervisor for FakeProcesses {
fn spawn(
&self,
attempt: &RunnerAttempt,
config: &EncodedJitConfig,
) -> Result<u32, ProcessStartFailure> {
self.spawns.fetch_add(1, Ordering::SeqCst);
let handoff = RestrictiveHandoff::create(
attempt.runtime_path(),
SecretString::from(config.expose().to_owned()),
)
.unwrap();
self.saw_secret
.store(config.expose() == JIT, Ordering::SeqCst);
let handoff_path = handoff.path().to_path_buf();
let failing = self
.spawn_failures
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
if left > 0 { Some(left - 1) } else { None }
})
.is_ok();
drop(handoff);
assert!(!handoff_path.exists(), "handoff must be absent on return");
if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
self.alive.store(true, Ordering::SeqCst);
return Err(ProcessStartFailure::after_spawn_live(4242));
}
if failing {
return Err(ProcessStartFailure::before_spawn(
FailureReason::ProcessStartFailed,
));
}
self.alive.store(true, Ordering::SeqCst);
Ok(4242)
}
fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
self.actions.lock().unwrap().push("observe_process");
Ok(self.alive.load(Ordering::SeqCst))
}
fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
}
fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
self.completed_successfully.load(Ordering::SeqCst)
}
fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
self.actions.lock().unwrap().push("terminate_intent");
if self.intent_failure.load(Ordering::SeqCst) {
return Err(FailureReason::Other(
"terminate intent directory sync failed".into(),
));
}
self.intent.store(true, Ordering::SeqCst);
Ok(())
}
fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
self.intent.load(Ordering::SeqCst)
}
fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
assert!(
self.intent.load(Ordering::SeqCst),
"the durable intent must exist before signalling"
);
self.actions.lock().unwrap().push("terminate");
self.terminations.fetch_add(1, Ordering::SeqCst);
self.alive.store(false, Ordering::SeqCst);
Ok(())
}
}
#[derive(Debug, Default)]
struct FakeDemand {
answers: Mutex<VecDeque<bool>>,
}
impl FakeDemand {
fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
Self {
answers: Mutex::new(answers.into_iter().collect()),
}
}
}
#[async_trait]
impl DemandPersistence for FakeDemand {
async fn persists(&self, _policy: PolicyId) -> bool {
self.answers.lock().unwrap().pop_front().unwrap_or(true)
}
}
#[derive(Debug, Default)]
struct FakeDelay(Mutex<Vec<Duration>>);
#[async_trait]
impl RetryDelay for FakeDelay {
async fn wait(&self, duration: Duration) {
self.0.lock().unwrap().push(duration);
}
}
struct Harness {
_root: tempfile::TempDir,
app_paths: runner_manager_platform::paths::AppPaths,
launcher: LifecycleLauncher,
demand: Arc<dyn DemandPersistence>,
store: Arc<SqliteStore>,
github: Arc<FakeGithubLifecycle>,
packages: Arc<FakePackages>,
processes: Arc<FakeProcesses>,
clock: Arc<FakeClock>,
events: Arc<AttemptEventLog>,
reconcile_events: Arc<crate::reconcile::EventLog>,
delay: Arc<FakeDelay>,
host: runner_manager_domain::model::Host,
policy: ScalePolicy,
allocation_lock: InProcessAllocationLock,
workspace_root: Option<LocalAbsolutePath>,
}
impl Harness {
fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
let root = tempfile::tempdir().unwrap();
let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
paths.create_all().unwrap();
let policy = fixtures::policy()
.repository("octo/repo")
.autoscale("home", 2)
.active()
.build();
let host = fixtures::host().build();
let store = Arc::new(SqliteStore::open_in_memory().unwrap());
store.put_host(&host).unwrap();
let github = Arc::new(github);
let packages = Arc::new(FakePackages::default());
let processes = Arc::new(FakeProcesses::default());
let clock = Arc::new(FakeClock::default());
let events = Arc::new(AttemptEventLog::default());
let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
let delay = Arc::new(FakeDelay::default());
let ports = LifecyclePorts {
store: Arc::clone(&store) as Arc<dyn Store>,
github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
clock: Arc::clone(&clock) as Arc<dyn Clock>,
demand: Arc::clone(&demand),
delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
};
let launcher = Self::launcher_over(policy.host_id, &paths, ports);
Self {
_root: root,
app_paths: paths,
launcher,
demand,
store,
github,
packages,
processes,
clock,
events,
reconcile_events,
delay,
host,
policy,
allocation_lock: InProcessAllocationLock::new(),
workspace_root: None,
}
}
fn with_host_runner_root(mut self) -> Self {
let host_root = self.host_root();
fs::create_dir_all(&host_root).unwrap();
self.host.runner_root_override = Some(
LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
.expect("a local absolute host root"),
);
self.store.put_host(&self.host).unwrap();
self
}
fn with_persistent_workspace(mut self, capacity: u16) -> Self {
self = self.with_host_runner_root();
let root = self._root.path().join("persist");
let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
.expect("a local absolute workspace root");
self.policy = fixtures::policy()
.repository("octo/repo")
.autoscale("home", capacity)
.active()
.build();
self.policy
.set_workspace_policy(
WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
.expect("a repository may be persistent"),
)
.expect("a repository may be persistent");
self.workspace_root = Some(root);
self.store.insert_policy(&self.policy).unwrap();
self
}
fn workspace_root(&self) -> &LocalAbsolutePath {
self.workspace_root
.as_ref()
.expect("this harness configured a persistent workspace")
}
fn slot_path(&self, slot: u16) -> PathBuf {
self.workspace_root().as_path().join(format!("s{slot}"))
}
fn host_root(&self) -> PathBuf {
self._root.path().join("host-root")
}
fn attempt(&self, id: AttemptId) -> RunnerAttempt {
self.store
.attempt(id)
.unwrap()
.expect("the attempt is journalled")
}
fn conclude(&self, id: AttemptId) -> RunnerAttempt {
let mut attempt = self.attempt(id);
attempt
.conclude(
AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
self.clock.now(),
)
.unwrap();
self.store.record_attempt(&attempt).unwrap();
attempt
}
async fn cleanup_retaining_work(&self, id: AttemptId) {
self.conclude(id);
self.launcher
.clean(id)
.await
.expect("the slot is scrubbed and the lease released");
}
fn launcher_over(
host: HostId,
paths: &runner_manager_platform::paths::AppPaths,
ports: LifecyclePorts,
) -> LifecycleLauncher {
LifecycleLauncher::new(
host,
paths.clone(),
paths.logs_dir(),
1,
RecoveryTimeouts::new(
Elapsed::seconds(10),
Elapsed::seconds(10),
Elapsed::seconds(10),
),
RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
ports,
)
}
fn restart(&self) -> LifecycleLauncher {
Self::launcher_over(
self.policy.host_id,
&self.app_paths,
LifecyclePorts {
store: Arc::clone(&self.store) as Arc<dyn Store>,
github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
demand: Arc::clone(&self.demand),
delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
},
)
}
async fn ready(&self) {
self.launcher
.recover_startup(std::slice::from_ref(&self.policy))
.await
.unwrap();
}
async fn launch(&self) -> RunnerAttempt {
self.launch_result().await.unwrap()
}
async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
let guard = self.allocation_lock.acquire().await.unwrap();
self.launcher
.launch(LaunchRequest {
host: &self.host,
policy: &self.policy,
allocation_guard: &guard,
})
.await
}
fn only_attempt(&self) -> RunnerAttempt {
self.store.attempts().unwrap().into_iter().next().unwrap()
}
}
#[tokio::test]
async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
.with_host_runner_root();
harness.ready().await;
let unusable = harness
._root
.path()
.join("absent")
.join("deeper")
.join("runners");
let mut host = harness.host.clone();
host.runner_root_override = Some(
LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
.expect("a local absolute host root"),
);
harness.store.put_host(&host).unwrap();
let failure = harness
.launch_result()
.await
.expect_err("a root whose parents are missing cannot hold a runner");
assert!(
matches!(failure.reason, FailureReason::Other(_)),
"{failure:?}"
);
let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
let refusal = refusals
.first()
.expect("the refusal reached the one surface that can hold it");
assert_eq!(refusal.policy, harness.policy.id.to_string());
assert_eq!(refusal.kind, "missing_parents");
assert!(
refusal.root.contains("runners") && refusal.detail.contains("runners"),
"the directory must be named in full: {refusal:?}"
);
harness.store.put_host(&harness.host).unwrap();
harness.launch().await;
assert!(
runner_root_refusals(&harness.app_paths)
.expect("readable")
.is_empty(),
"a successful placement clears that policy's record"
);
clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
.expect("cleanup");
}
#[tokio::test]
async fn a_job_walks_every_state_and_cleans_every_artifact() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
harness.ready().await;
let started = harness.launch().await;
assert_eq!(started.state(), AttemptState::Starting);
assert_eq!(read_runner_id(started.runtime_path()), Some(73));
harness
.github
.observe(GithubRunnerObservation::Registered { busy: false });
harness.launcher.supervise(&harness.policy).await.unwrap();
assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
harness
.github
.observe(GithubRunnerObservation::Registered { busy: true });
harness.launcher.supervise(&harness.policy).await.unwrap();
assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
harness.processes.finish_successfully();
harness
.github
.observe(GithubRunnerObservation::NotRegistered);
harness.launcher.supervise(&harness.policy).await.unwrap();
let cleaned = harness.only_attempt();
assert_eq!(cleaned.state(), AttemptState::Cleaned);
assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
assert!(!started.runtime_path().exists());
assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
let states: Vec<_> = harness
.events
.events()
.into_iter()
.filter_map(|event| match event {
AttemptEvent::State { state, .. } => Some(state),
_ => None,
})
.collect();
assert_eq!(
states,
vec![
AttemptState::Allocated,
AttemptState::JitReceived,
AttemptState::Starting,
AttemptState::Idle,
AttemptState::Busy,
AttemptState::Finished,
AttemptState::Cleaned,
]
);
}
#[tokio::test]
async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
harness.ready().await;
let started = harness.launch().await;
harness
.github
.observe(GithubRunnerObservation::Registered { busy: false });
harness.launcher.supervise(&harness.policy).await.unwrap();
harness.clock.advance_secs(11);
harness.processes.set_alive(false);
harness
.github
.observe(GithubRunnerObservation::NotRegistered);
harness.launcher.supervise(&harness.policy).await.unwrap();
let cleaned = harness.only_attempt();
assert!(cleaned.outcome().unwrap().is_idle_exit());
assert!(!cleaned.outcome().unwrap().is_failure());
assert!(!started.runtime_path().exists());
assert!(
harness
.reconcile_events
.events()
.iter()
.any(|event| matches!(
event,
LifecycleEvent::AttemptCleaned {
outcome: OutcomeKind::IdleExit,
..
}
))
);
assert!(!harness.events.events().iter().any(|event| matches!(
event,
AttemptEvent::Concluded {
outcome: OutcomeKind::Failed,
..
}
)));
}
#[tokio::test]
async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
harness.processes.fail_spawns(2);
harness.ready().await;
let attempt = harness.launch().await;
assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
let names: Vec<_> = fs::read_dir(attempt.runtime_path())
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
assert!(
names.iter().all(|name| {
!name
.to_string_lossy()
.starts_with(RestrictiveHandoff::NAME_PREFIX)
}),
"JIT artifact survived: {names:?}"
);
assert_eq!(
*harness.delay.0.lock().unwrap(),
vec![Duration::from_millis(10), Duration::from_millis(20)]
);
}
#[tokio::test]
async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
let gone = Harness::new(
FakeGithubLifecycle::default().fail(false),
Arc::new(FakeDemand::answering([false])),
);
gone.ready().await;
assert!(gone.launch_result().await.is_err());
assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
assert!(gone.delay.0.lock().unwrap().is_empty());
let forbidden = Harness::new(
FakeGithubLifecycle::default().fail(true),
Arc::new(PersistentDemand),
);
forbidden.ready().await;
assert!(forbidden.launch_result().await.is_err());
assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
assert!(forbidden.delay.0.lock().unwrap().is_empty());
assert!(matches!(
forbidden.only_attempt().outcome(),
Some(AttemptOutcome::Failed {
reason: FailureReason::Other(action)
}) if action.contains("403")
));
let transient = Harness::new(
FakeGithubLifecycle::default().fail(false).fail(false),
Arc::new(PersistentDemand),
);
transient.ready().await;
transient.launch().await;
assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
assert_eq!(
*transient.delay.0.lock().unwrap(),
vec![Duration::from_millis(10), Duration::from_millis(20)]
);
}
#[test]
fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
const MAX_PATH: usize = 260;
let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
let repo = "GitHub-Runner-Scaler-UI";
let deepest = format!(
r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
"0".repeat(40)
);
let name = workspace_name(AttemptId::new_random());
assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
assert!(
name.chars().all(|c| c.is_ascii_hexdigit()),
"a directory name must not carry the identifier's dashes: {name}"
);
let full = format!(r"{root}\{name}\{deepest}");
assert!(
full.len() < MAX_PATH,
"the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
full.len()
);
let old = format!(
r"{root}\{}\{}\{deepest}",
PolicyId::new_random(),
AttemptId::new_random()
);
assert!(
old.len() > MAX_PATH,
"the old layout is supposed to be the thing that did not fit: {} characters",
old.len()
);
}
#[tokio::test]
async fn two_attempts_never_share_a_workspace_even_after_failure() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
harness.ready().await;
let first = harness.launch().await;
fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
harness
.github
.observe(GithubRunnerObservation::Registered { busy: false });
harness.launcher.supervise(&harness.policy).await.unwrap();
harness.clock.advance_secs(11);
harness.processes.set_alive(false);
harness
.github
.observe(GithubRunnerObservation::NotRegistered);
harness.launcher.supervise(&harness.policy).await.unwrap();
assert!(!first.runtime_path().exists());
let second = harness.launch().await;
assert_ne!(first.runtime_path(), second.runtime_path());
assert!(!second.runtime_path().join("hostile-leftover").exists());
fs::write(
second.runtime_path().join("hostile-on-failure"),
b"second job",
)
.unwrap();
harness.processes.set_alive(false);
harness
.github
.observe(GithubRunnerObservation::NotRegistered);
harness.launcher.supervise(&harness.policy).await.unwrap();
assert!(
!second.runtime_path().exists(),
"failed workspace was retained"
);
}
#[tokio::test]
async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
harness.ready().await;
let attempt = harness.launch().await;
harness
.github
.observe(GithubRunnerObservation::Registered { busy: false });
harness.launcher.supervise(&harness.policy).await.unwrap();
assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
harness.clock.advance_secs(9);
harness
.github
.observe(GithubRunnerObservation::Registered { busy: false });
let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
assert!(none_yet.is_empty());
assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
harness.clock.advance_secs(1);
harness
.github
.observe(GithubRunnerObservation::Registered { busy: false });
let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
assert_eq!(
concluded.outcome(),
Some(&AttemptOutcome::ExitedIdleWithoutWork),
"a surplus runner did not fail; recording one as a failure sends an operator \
hunting a fault that does not exist"
);
assert_eq!(concluded.state(), AttemptState::Cleaned);
assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
assert!(!attempt.runtime_path().exists());
assert_eq!(
*harness.github.deregistrations.lock().unwrap(),
vec![73],
"the attempt's own runner id, deleted exactly once"
);
assert!(
replacements.is_empty(),
"a surplus exit must not request a replacement"
);
}
#[tokio::test]
async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
harness.ready().await;
let attempt = harness.launch().await;
harness
.github
.observe(GithubRunnerObservation::Registered { busy: false });
harness.launcher.supervise(&harness.policy).await.unwrap();
harness
.github
.deregistration_fails
.store(true, Ordering::SeqCst);
harness.clock.advance_secs(11);
harness
.github
.observe(GithubRunnerObservation::Registered { busy: false });
harness.launcher.supervise(&harness.policy).await.unwrap();
assert_eq!(
*harness.github.deregistrations.lock().unwrap(),
vec![73],
"the delete was attempted"
);
let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
assert_eq!(
concluded.outcome(),
Some(&AttemptOutcome::ExitedIdleWithoutWork),
"the attempt concluded anyway"
);
assert_eq!(concluded.state(), AttemptState::Cleaned);
assert!(!attempt.runtime_path().exists());
}
#[tokio::test]
async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
harness.ready().await;
let first = harness.launch().await;
harness.processes.set_alive(false);
harness
.github
.observe(GithubRunnerObservation::NotRegistered);
let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
let failed = harness.store.attempt(first.id).unwrap().unwrap();
assert!(matches!(
failed.outcome(),
Some(AttemptOutcome::Failed {
reason: FailureReason::ProcessExitedUnexpectedly
})
));
assert!(!first.runtime_path().exists());
assert_eq!(
replacements,
vec![ReplacementIntent {
policy: harness.policy.id,
previous_attempt: first.id,
operation: "exit_before_acceptance_replacement",
}]
);
assert_eq!(harness.store.attempts().unwrap().len(), 1);
assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
assert!(harness.delay.0.lock().unwrap().is_empty());
}
#[tokio::test]
async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
let harness = Harness::new(
FakeGithubLifecycle::default(),
Arc::new(FakeDemand::answering([false])),
);
let id = AttemptId::new_random();
let runtime = harness
.launcher
.app_paths
.runtime_dir()
.join(harness.policy.id.to_string())
.join(id.to_string());
fs::create_dir_all(&runtime).unwrap();
let mut attempt =
RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
attempt.jit_received(harness.clock.now()).unwrap();
harness.store.record_attempt(&attempt).unwrap();
harness.clock.advance_secs(11);
let replacements = harness
.launcher
.recover_startup(std::slice::from_ref(&harness.policy))
.await
.unwrap();
assert_eq!(
replacements,
vec![ReplacementIntent {
policy: harness.policy.id,
previous_attempt: id,
operation: "jit_expired_replacement",
}]
);
let cleaned = harness.store.attempt(id).unwrap().unwrap();
assert_eq!(cleaned.state(), AttemptState::Cleaned);
assert!(matches!(
cleaned.outcome(),
Some(AttemptOutcome::Failed {
reason: FailureReason::JitExpired
})
));
assert!(!runtime.exists());
assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
assert!(harness.delay.0.lock().unwrap().is_empty());
}
#[tokio::test]
async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
let id = AttemptId::new_random();
let runtime = harness
.launcher
.app_paths
.runtime_dir()
.join("expired-with-demand");
fs::create_dir_all(&runtime).unwrap();
let mut attempt =
RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
attempt.jit_received(harness.clock.now()).unwrap();
harness.store.record_attempt(&attempt).unwrap();
harness.clock.advance_secs(11);
let replacements = harness
.launcher
.recover_startup(std::slice::from_ref(&harness.policy))
.await
.unwrap();
let attempts = harness.store.attempts().unwrap();
assert_eq!(attempts.len(), 1);
assert_eq!(
attempts
.iter()
.find(|attempt| attempt.id == id)
.unwrap()
.state(),
AttemptState::Cleaned
);
assert_eq!(
replacements,
vec![ReplacementIntent {
policy: harness.policy.id,
previous_attempt: id,
operation: "jit_expired_replacement",
}]
);
assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
assert!(harness.delay.0.lock().unwrap().is_empty());
}
#[tokio::test]
async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
persistent.packages.fail_materializations(2);
persistent.ready().await;
persistent.launch().await;
assert_eq!(
persistent.packages.materializations.load(Ordering::SeqCst),
3
);
assert_eq!(
*persistent.delay.0.lock().unwrap(),
vec![Duration::from_millis(10), Duration::from_millis(20)]
);
let gone_before_wait = Harness::new(
FakeGithubLifecycle::default(),
Arc::new(FakeDemand::answering([false])),
);
gone_before_wait.packages.fail_materializations(3);
gone_before_wait.ready().await;
assert!(gone_before_wait.launch_result().await.is_err());
assert_eq!(
gone_before_wait
.packages
.materializations
.load(Ordering::SeqCst),
1
);
assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
let gone_during_wait = Harness::new(
FakeGithubLifecycle::default(),
Arc::new(FakeDemand::answering([true, false])),
);
gone_during_wait.packages.fail_materializations(3);
gone_during_wait.ready().await;
assert!(gone_during_wait.launch_result().await.is_err());
assert_eq!(
gone_during_wait
.packages
.materializations
.load(Ordering::SeqCst),
1
);
assert_eq!(
*gone_during_wait.delay.0.lock().unwrap(),
vec![Duration::from_millis(10)]
);
}
#[tokio::test]
async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
let harness = Harness::new(
FakeGithubLifecycle::default(),
Arc::new(FakeDemand::answering([true, false])),
);
harness.ready().await;
let first = harness.launch().await;
harness.processes.set_alive(false);
harness
.github
.observe(GithubRunnerObservation::NotRegistered);
let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
assert_eq!(harness.store.attempts().unwrap().len(), 1);
assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
assert!(harness.delay.0.lock().unwrap().is_empty());
assert_eq!(
replacements,
vec![ReplacementIntent {
policy: harness.policy.id,
previous_attempt: first.id,
operation: "exit_before_acceptance_replacement",
}]
);
assert_eq!(
harness.store.attempt(first.id).unwrap().unwrap().state(),
AttemptState::Cleaned
);
}
#[tokio::test]
async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
let before = harness.launch_result().await;
assert!(before.is_err());
assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
let id = AttemptId::new_random();
let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
fs::create_dir_all(&runtime).unwrap();
let mut attempt =
RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
attempt.jit_received(harness.clock.now()).unwrap();
attempt.started(4242, harness.clock.now()).unwrap();
harness.store.record_attempt(&attempt).unwrap();
harness.processes.set_alive(true);
harness
.github
.observe(GithubRunnerObservation::NotRegistered);
let replacements = harness
.launcher
.recover_startup(std::slice::from_ref(&harness.policy))
.await
.unwrap();
assert!(replacements.is_empty());
assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
assert!(
harness
.events
.events()
.contains(&AttemptEvent::Adopted { attempt: id })
);
}
#[tokio::test]
async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
let id = AttemptId::new_random();
let runtime = harness
.launcher
.app_paths
.runtime_dir()
.join("spawn-before-starting");
fs::create_dir_all(&runtime).unwrap();
let mut attempt =
RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
attempt.jit_received(harness.clock.now()).unwrap();
harness.store.record_attempt(&attempt).unwrap();
harness.processes.set_alive(true);
harness
.github
.observe(GithubRunnerObservation::Registered { busy: true });
let replacements = harness
.launcher
.recover_startup(std::slice::from_ref(&harness.policy))
.await
.unwrap();
assert!(replacements.is_empty());
let recovered = harness.store.attempt(id).unwrap().unwrap();
assert_eq!(recovered.state(), AttemptState::Busy);
assert_eq!(recovered.process_id(), Some(4242));
assert_eq!(recovered.github_runner_id(), Some(73));
let events = harness.events.events();
let starting = events
.iter()
.position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
.unwrap();
let busy = events
.iter()
.position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
.unwrap();
assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
harness.processes.finish_successfully();
harness
.github
.observe(GithubRunnerObservation::NotRegistered);
assert!(
harness
.launcher
.supervise(&harness.policy)
.await
.unwrap()
.is_empty()
);
let cleaned = harness.store.attempt(id).unwrap().unwrap();
assert_eq!(cleaned.state(), AttemptState::Cleaned);
assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
assert!(!runtime.exists());
}
#[tokio::test]
async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
harness.processes.fail_spawn_with_live_child();
harness.ready().await;
assert!(harness.launch_result().await.is_err());
let attempt = harness.only_attempt();
assert_eq!(attempt.state(), AttemptState::Starting);
assert_eq!(attempt.process_id(), Some(4242));
assert!(attempt.outcome().is_none());
assert!(attempt.state().counts_against_capacity());
assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
assert!(harness.delay.0.lock().unwrap().is_empty());
harness.processes.set_alive(false);
harness
.github
.observe(GithubRunnerObservation::NotRegistered);
let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
assert_eq!(replacements.len(), 1);
assert_eq!(
harness.store.attempt(attempt.id).unwrap().unwrap().state(),
AttemptState::Cleaned
);
}
#[tokio::test]
async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
for sidecar_already_present in [false, true] {
let harness = Harness::new(
FakeGithubLifecycle::default(),
Arc::new(FakeDemand::answering([false])),
);
let id = AttemptId::new_random();
let runtime =
harness
.launcher
.app_paths
.runtime_dir()
.join(if sidecar_already_present {
"after-id-sidecar"
} else {
"before-id-sidecar"
});
fs::create_dir_all(&runtime).unwrap();
let mut attempt =
RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
if sidecar_already_present {
write_runner_id(&runtime, 73).unwrap();
attempt.jit_received(harness.clock.now()).unwrap();
}
harness.store.record_attempt(&attempt).unwrap();
harness.processes.set_alive(true);
harness
.github
.observe(GithubRunnerObservation::Registered { busy: false });
harness
.launcher
.recover_startup(std::slice::from_ref(&harness.policy))
.await
.unwrap();
assert_eq!(read_runner_id(&runtime), Some(73));
assert!(
harness
.store
.attempt(id)
.unwrap()
.unwrap()
.outcome()
.is_none()
);
let events = harness.events.events();
let recovered = events.iter().position(|event| {
matches!(
event,
AttemptEvent::RemoteIdentityRecovered {
attempt,
runner_id: 73
} if *attempt == id
)
});
assert_eq!(recovered.is_some(), !sidecar_already_present);
if let Some(recovered) = recovered {
let adopted = events
.iter()
.position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
.unwrap();
assert!(
recovered < adopted,
"identity was not durable before adoption: {events:?}"
);
}
assert!(runtime.exists());
}
}
#[tokio::test]
async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
let unknown_attempt = RunnerAttempt::allocate(
AttemptId::new_random(),
PolicyId::from_u128(0xfeed),
unknown
.launcher
.app_paths
.runtime_dir()
.join("unknown-policy"),
unknown.clock.now(),
);
unknown.store.record_attempt(&unknown_attempt).unwrap();
let expired_id = AttemptId::new_random();
let expired_runtime = unknown
.launcher
.app_paths
.runtime_dir()
.join("expired-beside-unknown");
fs::create_dir_all(&expired_runtime).unwrap();
let mut expired = RunnerAttempt::allocate(
expired_id,
unknown.policy.id,
expired_runtime,
unknown.clock.now(),
);
expired.jit_received(unknown.clock.now()).unwrap();
unknown.store.record_attempt(&expired).unwrap();
unknown.clock.advance_secs(11);
assert!(matches!(
unknown
.launcher
.recover_startup(std::slice::from_ref(&unknown.policy))
.await,
Err(LifecycleError::RecoveryIncomplete)
));
assert!(unknown.launch_result().await.is_err());
assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
let recovered_policy = fixtures::policy()
.id(PolicyId::from_u128(0xfeed))
.repository("octo/repo")
.autoscale("home", 2)
.active()
.build();
let pending = unknown
.launcher
.recover_startup(&[unknown.policy.clone(), recovered_policy])
.await
.unwrap();
assert_eq!(
pending,
vec![ReplacementIntent {
policy: unknown.policy.id,
previous_attempt: expired_id,
operation: "jit_expired_replacement",
}]
);
assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
let id = AttemptId::new_random();
let runtime = unreachable
.launcher
.app_paths
.runtime_dir()
.join("unreachable");
fs::create_dir_all(&runtime).unwrap();
unreachable
.store
.record_attempt(&RunnerAttempt::allocate(
id,
unreachable.policy.id,
runtime,
unreachable.clock.now(),
))
.unwrap();
unreachable
.github
.observe(GithubRunnerObservation::Unreachable);
assert!(matches!(
unreachable
.launcher
.recover_startup(std::slice::from_ref(&unreachable.policy))
.await,
Err(LifecycleError::RecoveryIncomplete)
));
assert!(unreachable.launch_result().await.is_err());
assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
let id = AttemptId::new_random();
let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
fs::create_dir_all(&runtime).unwrap();
let mut attempt =
RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
attempt.jit_received(harness.clock.now()).unwrap();
attempt.started(4242, harness.clock.now()).unwrap();
attempt.assigned_job(73, harness.clock.now()).unwrap();
harness.store.record_attempt(&attempt).unwrap();
harness.processes.set_alive(false);
harness
.github
.observe(GithubRunnerObservation::NotRegistered);
harness
.launcher
.recover_startup(std::slice::from_ref(&harness.policy))
.await
.unwrap();
let cleaned = harness.store.attempt(id).unwrap().unwrap();
assert_eq!(cleaned.state(), AttemptState::Cleaned);
assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
assert!(!runtime.exists());
}
#[tokio::test]
async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
harness.ready().await;
let id = AttemptId::new_random();
let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
fs::create_dir_all(&runtime).unwrap();
let mut attempt =
RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
attempt.jit_received(harness.clock.now()).unwrap();
attempt.started(4242, harness.clock.now()).unwrap();
harness.store.record_attempt(&attempt).unwrap();
harness.clock.advance_secs(11);
harness.processes.set_alive(true);
harness
.github
.observe(GithubRunnerObservation::NotRegistered);
let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
assert_eq!(
replacements,
vec![ReplacementIntent {
policy: harness.policy.id,
previous_attempt: id,
operation: "registration_timeout_replacement",
}]
);
assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
assert!(!harness.processes.alive.load(Ordering::SeqCst));
let actions = harness.processes.actions.lock().unwrap().clone();
let intent = actions
.iter()
.position(|action| *action == "terminate_intent")
.unwrap();
let signal = actions
.iter()
.position(|action| *action == "terminate")
.unwrap();
assert!(
intent < signal,
"intent was not durable before signal: {actions:?}"
);
let cleaned = harness.store.attempt(id).unwrap().unwrap();
assert!(matches!(
cleaned.outcome(),
Some(AttemptOutcome::Failed {
reason: FailureReason::TerminatedAfterRegistrationTimeout
})
));
let events = harness.events.events();
let intent = events
.iter()
.position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
.unwrap();
let stopped = events
.iter()
.position(|event| matches!(event, AttemptEvent::Terminated { .. }))
.unwrap();
let concluded = events
.iter()
.position(|event| matches!(event, AttemptEvent::Concluded { .. }))
.unwrap();
assert!(intent < stopped && stopped < concluded, "{events:?}");
}
#[tokio::test]
async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
let id = AttemptId::new_random();
let runtime = harness
.launcher
.app_paths
.runtime_dir()
.join("timeout-after-crash");
fs::create_dir_all(&runtime).unwrap();
let mut attempt =
RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
attempt.jit_received(harness.clock.now()).unwrap();
attempt.started(4242, harness.clock.now()).unwrap();
harness.store.record_attempt(&attempt).unwrap();
harness.processes.intent.store(true, Ordering::SeqCst);
harness.processes.set_alive(false);
harness
.github
.observe(GithubRunnerObservation::NotRegistered);
let replacements = harness
.launcher
.recover_startup(std::slice::from_ref(&harness.policy))
.await
.unwrap();
assert_eq!(
replacements,
vec![ReplacementIntent {
policy: harness.policy.id,
previous_attempt: id,
operation: "registration_timeout_replacement",
}]
);
let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
.await
.unwrap();
assert_eq!(consumed, replacements);
assert!(
RunnerLauncher::supervise(&harness.launcher, &harness.policy)
.await
.unwrap()
.is_empty(),
"startup replacement evidence must be consumed exactly once by e1"
);
assert!(matches!(
harness.store.attempt(id).unwrap().unwrap().outcome(),
Some(AttemptOutcome::Failed {
reason: FailureReason::TerminatedAfterRegistrationTimeout
})
));
}
#[tokio::test]
async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
let id = AttemptId::new_random();
let runtime = harness
.launcher
.app_paths
.runtime_dir()
.join("timeout-sync-failure");
fs::create_dir_all(&runtime).unwrap();
let mut attempt =
RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
attempt.jit_received(harness.clock.now()).unwrap();
attempt.started(4242, harness.clock.now()).unwrap();
harness.store.record_attempt(&attempt).unwrap();
harness.clock.advance_secs(11);
harness.processes.set_alive(true);
harness.processes.fail_intent();
harness
.github
.observe(GithubRunnerObservation::NotRegistered);
assert!(
harness
.launcher
.recover_startup(std::slice::from_ref(&harness.policy))
.await
.is_err()
);
assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
assert!(harness.processes.alive.load(Ordering::SeqCst));
assert_eq!(
harness.store.attempt(id).unwrap().unwrap().state(),
AttemptState::Starting
);
assert!(!harness.events.events().iter().any(|event| matches!(
event,
AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
if *attempt == id
)));
}
#[tokio::test]
async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
harness.ready().await;
let attempt = harness.launch().await;
harness
.github
.observe(GithubRunnerObservation::Registered { busy: false });
harness.launcher.supervise(&harness.policy).await.unwrap();
harness.clock.advance_secs(11);
harness.processes.set_alive(false);
harness
.github
.observe(GithubRunnerObservation::NotRegistered);
harness.launcher.supervise(&harness.policy).await.unwrap();
let diagnostic = fs::read_to_string(
harness
.launcher
.diagnostics_root
.join(format!("{}.log", attempt.id)),
)
.unwrap();
assert!(diagnostic.contains("exited_idle_without_work"));
assert!(!diagnostic.contains(JIT));
assert!(!diagnostic.contains("ghp_"));
assert!(!attempt.runtime_path().exists());
}
#[test]
fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
let root = tempfile::tempdir().unwrap();
let policy = fixtures::policy()
.repository("octo/repo")
.autoscale("home", 1)
.active()
.build();
let runtime = root.path().join("successful");
fs::create_dir_all(&runtime).unwrap();
let processes = NativeProcesses::new();
let config = EncodedJitConfig::new(JIT);
let handoff =
RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
.unwrap();
let mut child = native_inspection_spec()
.spawn_runner_with_handoff(&handoff)
.expect("native child starts");
let pid = child.pid();
handoff.delete().unwrap();
let command_line = native_command_line(pid);
assert!(
!command_line.contains(JIT),
"the encoded JIT configuration appeared in the native process listing"
);
assert_no_jit_file(&runtime);
child
.stop(Duration::from_secs(1))
.expect("native child stops");
let failed_runtime = root.path().join("failed");
fs::create_dir_all(&failed_runtime).unwrap();
let failed = RunnerAttempt::allocate(
AttemptId::new_random(),
policy.id,
&failed_runtime,
FakeClock::default().now(),
);
assert!(
processes
.spawn(&failed, &EncodedJitConfig::new(JIT))
.is_err(),
"a runtime with no runner executable must fail"
);
assert_no_jit_file(&failed_runtime);
processes
.record_terminate_intent(&failed)
.expect("the intent file and its directory entry are durably synced");
assert_eq!(
fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
b"registration-timeout\n"
);
}
#[test]
fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
let root = tempfile::tempdir().unwrap();
let policy = fixtures::policy()
.repository("octo/repo")
.autoscale("home", 1)
.active()
.build();
let processes = NativeProcesses::new();
processes.use_long_lived_test_listener();
for (index, boundary) in [
PostSpawnBoundary::HandoffDelete,
PostSpawnBoundary::IdentitySerialize,
PostSpawnBoundary::IdentityWrite,
PostSpawnBoundary::ChildMapInsert,
]
.into_iter()
.enumerate()
{
let runtime = root.path().join(format!("post-spawn-{index}"));
let bin = runtime.join("bin");
fs::create_dir_all(&bin).unwrap();
#[cfg(windows)]
let listener = bin.join("Runner.Listener.exe");
#[cfg(not(windows))]
let listener = bin.join("Runner.Listener");
fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
let attempt = RunnerAttempt::allocate(
AttemptId::new_random(),
policy.id,
&runtime,
FakeClock::default().now(),
);
processes.fail_post_spawn_at(boundary);
let failure = processes
.spawn(&attempt, &EncodedJitConfig::new(JIT))
.expect_err("fault must cross the post-spawn cleanup path");
assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
assert!(
!processes.is_alive(&attempt).unwrap(),
"{boundary:?} left a child"
);
assert!(!NativeProcesses::identity_path(&attempt).exists());
assert_no_jit_file(&runtime);
}
assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
let runtime = root.path().join("identity-and-stop-fail");
let bin = runtime.join("bin");
fs::create_dir_all(&bin).unwrap();
#[cfg(windows)]
let listener = bin.join("Runner.Listener.exe");
#[cfg(not(windows))]
let listener = bin.join("Runner.Listener");
fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
let attempt = RunnerAttempt::allocate(
AttemptId::new_random(),
policy.id,
&runtime,
FakeClock::default().now(),
);
processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
processes.fail_next_post_spawn_stop();
let failure = processes
.spawn(&attempt, &EncodedJitConfig::new(JIT))
.expect_err("the identity boundary must fail closed");
assert!(failure.live_pid.is_some());
assert_long_lived_listener_ready(&processes, &attempt);
assert!(processes.is_alive(&attempt).unwrap());
assert!(!NativeProcesses::identity_path(&attempt).exists());
assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
processes.terminate(&attempt).unwrap();
let runtime = root.path().join("persistent-stop-and-identity-failures");
let bin = runtime.join("bin");
fs::create_dir_all(&bin).unwrap();
#[cfg(windows)]
let listener = bin.join("Runner.Listener.exe");
#[cfg(not(windows))]
let listener = bin.join("Runner.Listener");
fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
let mut unresolved = RunnerAttempt::allocate(
AttemptId::new_random(),
policy.id,
&runtime,
FakeClock::default().now(),
);
for _ in 0..3 {
processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
}
processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
let failure = processes
.spawn(&unresolved, &EncodedJitConfig::new(JIT))
.expect_err("bounded cleanup must return even when every stop errors");
let pid = failure
.live_pid
.expect("the owned child remains supervised in this invocation");
assert!(matches!(failure.reason, FailureReason::Other(_)));
assert_long_lived_listener_ready(&processes, &unresolved);
unresolved.jit_received(FakeClock::default().now()).unwrap();
unresolved.started(pid, FakeClock::default().now()).unwrap();
let journal = SqliteStore::open_in_memory().unwrap();
journal.record_attempt(&unresolved).unwrap();
let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
assert_eq!(recovered.process_id(), Some(pid));
assert_eq!(recovered.state(), AttemptState::Starting);
assert!(processes.is_alive(&unresolved).unwrap());
assert!(!NativeProcesses::identity_path(&unresolved).exists());
assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
assert_eq!(
fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
pid.to_string(),
"bounded cleanup must leave durable unresolved-process evidence before returning"
);
assert!(
NativeProcesses::new().is_alive(&recovered).is_err(),
"restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
);
processes.terminate(&unresolved).unwrap();
let runtime = root.path().join("post-spawn-stop-failed");
let bin = runtime.join("bin");
fs::create_dir_all(&bin).unwrap();
#[cfg(windows)]
let listener = bin.join("Runner.Listener.exe");
#[cfg(not(windows))]
let listener = bin.join("Runner.Listener");
fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
let attempt = RunnerAttempt::allocate(
AttemptId::new_random(),
policy.id,
&runtime,
FakeClock::default().now(),
);
processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
processes.fail_next_post_spawn_stop();
let failure = processes
.spawn(&attempt, &EncodedJitConfig::new(JIT))
.expect_err("the injected stop failure must preserve supervision");
let live_pid = failure
.live_pid
.expect("live PID is returned to the journal");
assert!(!failure.retryable);
assert_long_lived_listener_ready(&processes, &attempt);
assert!(NativeProcesses::identity_path(&attempt).is_file());
assert_eq!(
NativeProcesses::read_identity(&attempt)
.unwrap()
.unwrap()
.pid(),
live_pid
);
assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
processes.terminate(&attempt).unwrap();
}
#[test]
#[ignore = "spawned only as the platform-stable native listener fixture"]
fn long_lived_native_listener_helper() {
let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
.map(PathBuf::from)
.expect("the parent supplies the readiness path");
fs::write(ready, b"ready\n").expect("the listener publishes readiness");
std::thread::sleep(Duration::from_secs(30));
}
fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
if ready.is_file() {
assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
return;
}
assert!(
processes.is_alive(attempt).unwrap(),
"the native listener exited before publishing readiness"
);
assert!(
std::time::Instant::now() < deadline,
"the native listener stayed alive but never published readiness"
);
std::thread::sleep(Duration::from_millis(10));
}
}
#[tokio::test]
async fn every_production_launch_prunes_under_the_same_allocation_guard() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
harness.ready().await;
assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
harness.launch().await;
assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
assert_eq!(
*harness.packages.prune_currents.lock().unwrap(),
vec![harness.packages.version.clone()],
"the leased current version is an exclusion, never the prune target"
);
}
fn assert_no_jit_file(runtime: &Path) {
for entry in fs::read_dir(runtime).unwrap() {
let path = entry.unwrap().path();
if path.is_file() {
let bytes = fs::read(&path).unwrap();
assert!(
!bytes
.windows(JIT.len())
.any(|window| window == JIT.as_bytes()),
"a JIT payload survived in a runtime file"
);
}
}
}
#[test]
fn production_listener_command_uses_the_supported_jit_contract() {
let runtime = Path::new("runtime");
let spec = runner_listener_spec(PathBuf::from("Runner.Listener"), runtime);
let arguments: Vec<_> = spec
.arguments()
.iter()
.map(|argument| argument.to_string_lossy().into_owned())
.collect();
assert_eq!(arguments, ["run"]);
assert!(
!arguments
.iter()
.any(|argument| argument == "--jit-config-file"),
"the obsolete file option would be rejected by Runner.Listener 2.336.0"
);
}
#[cfg(windows)]
fn native_inspection_spec() -> SpawnSpec {
SpawnSpec::new("powershell.exe").args([
"-NoProfile",
"-NonInteractive",
"-Command",
"Start-Sleep -Seconds 30",
])
}
#[cfg(unix)]
fn native_inspection_spec() -> SpawnSpec {
SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
}
#[cfg(windows)]
fn native_command_line(pid: u32) -> String {
let output = std::process::Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-Command",
&format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
])
.output()
.expect("PowerShell can inspect the native child");
assert!(output.status.success(), "native process inspection failed");
String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
}
#[cfg(target_os = "linux")]
fn native_command_line(pid: u32) -> String {
fs::read(format!("/proc/{pid}/cmdline"))
.map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
.expect("/proc exposes the native child command line")
}
#[cfg(target_os = "macos")]
fn native_command_line(pid: u32) -> String {
let output = std::process::Command::new("ps")
.args(["-o", "command=", "-p", &pid.to_string()])
.output()
.expect("ps can inspect the native child");
assert!(output.status.success(), "native process inspection failed");
String::from_utf8(output.stdout).expect("the command line is UTF-8")
}
#[tokio::test]
async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
.with_persistent_workspace(2);
harness.github.watch_journal(Arc::clone(&harness.store));
harness.ready().await;
let attempt = harness.launch().await;
assert_eq!(
attempt.workspace(),
AttemptWorkspace::persistent_slot(nz(1)),
"the lowest free slot is leased"
);
assert_eq!(attempt.runtime_path(), harness.slot_path(1));
assert!(attempt.holds_slot_lease());
assert_eq!(
harness.attempt(attempt.id).runtime_path(),
harness.slot_path(1)
);
let facts = harness.github.registration_facts();
assert_eq!(facts.len(), 1);
assert_eq!(
facts[0].leased_slots,
vec![1],
"the lease was journalled first"
);
assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
}
#[tokio::test]
async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
let harness = Harness::new(
FakeGithubLifecycle::default().fail(true),
Arc::new(PersistentDemand),
)
.with_persistent_workspace(2);
harness.ready().await;
harness.launch_result().await.unwrap_err();
let first = harness.store.attempts().unwrap().remove(0);
assert_eq!(first.state(), AttemptState::Failed);
assert!(
!first.state().counts_against_capacity(),
"a concluded attempt is invisible to host capacity"
);
assert!(
first.holds_slot_lease(),
"and still owns its directory, so its slot is not free"
);
let second = harness.launch().await;
assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
assert_eq!(second.runtime_path(), harness.slot_path(2));
assert_eq!(
harness
.store
.slot_leases_for_policy(harness.policy.id)
.unwrap()
.len(),
2
);
}
#[tokio::test]
async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
.with_persistent_workspace(1);
harness.ready().await;
let first = harness.launch().await;
assert_eq!(first.runtime_path(), harness.slot_path(1));
let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
fs::create_dir_all(&checkout).unwrap();
fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
harness.cleanup_retaining_work(first.id).await;
let second = harness.launch().await;
assert_ne!(second.id, first.id);
assert_eq!(
second.workspace(),
AttemptWorkspace::persistent_slot(nz(1)),
"a released slot is leased again rather than skipped"
);
assert_eq!(
second.runtime_path(),
first.runtime_path(),
"the same slot is the same exact path"
);
assert_eq!(
fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
"from the first job",
"the retained job workspace survived the second allocation"
);
assert!(harness.slot_path(1).join("runner-package").exists());
}
#[tokio::test]
async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
.with_persistent_workspace(2);
harness.ready().await;
let first = harness.launch().await;
let second = harness.launch().await;
assert_eq!(second.runtime_path(), harness.slot_path(2));
let kept = harness
.slot_path(2)
.join(DEFAULT_WORK_FOLDER)
.join("kept.txt");
fs::create_dir_all(kept.parent().unwrap()).unwrap();
fs::write(&kept, b"s2 was here").unwrap();
harness.cleanup_retaining_work(second.id).await;
harness.policy.set_max_capacity(nz(1)).unwrap();
let refusal = harness.launch_result().await.unwrap_err().to_string();
assert!(
refusal.contains("s1 to s1"),
"the refusal names the ceiling it reached: {refusal}"
);
assert!(
harness.slot_path(2).exists() && kept.exists(),
"lowering capacity deletes nothing; the higher slot is merely unusable"
);
harness.policy.set_max_capacity(nz(2)).unwrap();
let third = harness.launch().await;
assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
assert_eq!(third.runtime_path(), harness.slot_path(2));
assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
assert!(first.holds_slot_lease(), "s1 was never disturbed");
}
#[tokio::test]
async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
for policy in [
fixtures::policy()
.organization("octo")
.autoscale("home", 2)
.active()
.build(),
fixtures::policy()
.repository("octo/repo")
.autoscale("home", 2)
.active()
.build(),
] {
let mut harness =
Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
.with_host_runner_root();
assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
harness.policy = policy;
harness.ready().await;
let attempt = harness.launch().await;
assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
assert_eq!(attempt.workspace().slot_number(), None);
assert!(!attempt.holds_slot_lease());
assert_eq!(
attempt.runtime_path().parent().unwrap(),
harness.host_root(),
"a disposable attempt is a child of the host root, never of a slot"
);
assert!(
harness
.store
.slot_leases_for_policy(harness.policy.id)
.unwrap()
.is_empty()
);
}
}
#[tokio::test]
async fn two_concurrent_allocations_never_share_a_slot() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
.with_persistent_workspace(2);
harness.github.watch_journal(Arc::clone(&harness.store));
harness.ready().await;
let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
let first = first.unwrap();
let second = second.unwrap();
let slots: BTreeSet<u16> = [&first, &second]
.iter()
.map(|attempt| {
attempt
.workspace()
.slot_number()
.expect("a persistent attempt leases a slot")
})
.collect();
assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
assert_ne!(first.runtime_path(), second.runtime_path());
assert_eq!(
harness
.store
.slot_leases_for_policy(harness.policy.id)
.unwrap()
.len(),
2
);
let facts = harness.github.registration_facts();
assert_eq!(facts.len(), 2);
for fact in facts {
let attempt = [&first, &second]
.into_iter()
.find(|attempt| runner_name(attempt.id) == fact.runner_name)
.expect("every registration belongs to one of the two attempts");
let slot = attempt
.workspace()
.slot_number()
.expect("a persistent attempt leases a slot");
assert!(
fact.leased_slots.contains(&slot),
"a JIT request never precedes its own lease: s{slot} not in {:?}",
fact.leased_slots
);
assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
}
}
#[tokio::test]
async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
.with_persistent_workspace(2);
harness.ready().await;
let first = harness.launch().await;
let clash = RunnerAttempt::allocate_in(
AttemptId::new_random(),
harness.policy.id,
first.runtime_path(),
AttemptWorkspace::persistent_slot(nz(1)),
harness.clock.now(),
);
assert!(matches!(
harness.store.record_attempt(&clash).unwrap_err(),
StoreError::SlotAlreadyLeased { slot: 1, .. }
));
let error = harness.launcher.record_allocation(&clash).unwrap_err();
let rendered = error.to_string();
assert!(rendered.contains("slot s1"), "{rendered}");
assert!(rendered.contains("nothing was written"), "{rendered}");
assert_eq!(
harness.store.attempts().unwrap().len(),
1,
"the losing allocator journalled nothing"
);
}
#[test]
fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
slots
.iter()
.map(|slot| {
RunnerAttempt::allocate_in(
AttemptId::new_random(),
fixtures::POLICY_ID,
format!("/srv/rman/acme/s{slot}"),
AttemptWorkspace::persistent_slot(nz(*slot)),
fixtures::created_at(),
)
})
.collect()
};
assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
let ephemeral = vec![RunnerAttempt::allocate(
AttemptId::new_random(),
fixtures::POLICY_ID,
"/srv/rman/host/abc",
fixtures::created_at(),
)];
assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
}
#[test]
fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
let root = tempfile::tempdir().unwrap();
let slot = root.path().join("s1");
fs::create_dir(&slot).unwrap();
accept_reusable_slot(&slot).expect("an empty slot is reusable");
fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
fs::create_dir(slot.join("bin")).unwrap();
fs::write(slot.join(".github-runner-id"), b"73").unwrap();
let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
assert!(refusal.contains("bin"), "{refusal}");
assert!(refusal.contains(".github-runner-id"), "{refusal}");
let file_work = root.path().join("s2");
fs::create_dir(&file_work).unwrap();
fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
assert!(accept_reusable_slot(&file_work).is_err());
}
#[cfg(unix)]
#[test]
fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
let root = tempfile::tempdir().unwrap();
let elsewhere = root.path().join("elsewhere");
fs::create_dir(&elsewhere).unwrap();
let slot = root.path().join("s1");
fs::create_dir(&slot).unwrap();
std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
assert!(accept_reusable_slot(&slot).is_err());
let linked_slot = root.path().join("s2");
std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
assert!(create_or_validate_slot(&linked_slot).is_err());
}
#[test]
fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
let root = tempfile::tempdir().unwrap();
let occupied = root.path().join("s1");
fs::write(&occupied, b"an operator's file").unwrap();
let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
assert!(refusal.contains("is not a directory"), "{refusal}");
assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
let fresh = root.path().join("s2");
create_or_validate_slot(&fresh).expect("a missing slot is created");
assert!(fresh.is_dir());
create_or_validate_slot(&fresh).expect("an existing directory is accepted");
}
#[test]
fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
assert!(!is_work_folder(OsStr::new("_work2")));
assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
}
#[test]
fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
let root = tempfile::tempdir().unwrap();
let package = root.path().join("package");
fs::create_dir_all(package.join("bin")).unwrap();
fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
let slot = root.path().join("s1");
let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
fs::create_dir_all(&retained).unwrap();
fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
copy_package_tree(&package, &slot).expect("the package lays out around `_work`");
assert!(slot.join("bin").join("Runner.Listener").exists());
assert!(
slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
"the guard is top-level only"
);
assert_eq!(
fs::read_to_string(retained.join("checkout.txt")).unwrap(),
"from the first job"
);
fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
let error = copy_package_tree(&package, &slot).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert_eq!(
fs::read_to_string(retained.join("checkout.txt")).unwrap(),
"from the first job"
);
}
#[test]
fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
let root = tempfile::tempdir().unwrap();
let slot = root.path().join("s1");
let retained = slot.join(DEFAULT_WORK_FOLDER);
fs::create_dir_all(retained.join("repo")).unwrap();
fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
fs::create_dir_all(slot.join("bin")).unwrap();
fs::write(slot.join(".github-runner-id"), b"73").unwrap();
let persistent = RunnerAttempt::allocate_in(
AttemptId::new_random(),
fixtures::POLICY_ID,
&slot,
AttemptWorkspace::persistent_slot(nz(1)),
fixtures::created_at(),
);
remove_materialized_package(&persistent).unwrap();
assert!(slot.is_dir(), "the slot itself is not removed");
assert!(!slot.join("bin").exists());
assert!(!slot.join(".github-runner-id").exists());
assert_eq!(
fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
"kept"
);
let disposable_path = root.path().join("abcdef012345");
fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
let disposable = RunnerAttempt::allocate(
AttemptId::new_random(),
fixtures::POLICY_ID,
&disposable_path,
fixtures::created_at(),
);
remove_materialized_package(&disposable).unwrap();
assert!(
!disposable_path.exists(),
"a disposable directory is still removed whole"
);
}
fn litter_the_slot(slot: &Path) {
for directory in ["bin", "externals", "_diag"] {
fs::create_dir_all(slot.join(directory)).unwrap();
}
fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
for file in SENSITIVE_SLOT_ENTRIES
.iter()
.filter(|entry| !slot.join(entry).is_dir())
{
fs::write(slot.join(file), b"runner state").unwrap();
}
fs::write(
slot.join(format!(
"{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
RestrictiveHandoff::NAME_PREFIX
)),
JIT.as_bytes(),
)
.unwrap();
}
fn retain_under_work(slot: &Path) -> PathBuf {
let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
fs::create_dir_all(&checkout).unwrap();
let marker = checkout.join("build-output.bin");
fs::write(&marker, RETAINED).unwrap();
marker
}
const RETAINED: &str = "a Git-ignored build output the next job reuses";
fn entries_of(directory: &Path) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(directory)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
fn only_the_job_workspace() -> Vec<String> {
vec![DEFAULT_WORK_FOLDER.to_owned()]
}
struct BlockedDeletion {
directory: PathBuf,
#[cfg(windows)]
_handle: fs::File,
}
impl BlockedDeletion {
const HELD: &'static str = "held-open";
fn inject(directory: &Path) -> Option<Self> {
#[cfg(unix)]
if !Self::refusal_is_possible() {
return None;
}
fs::create_dir_all(directory).unwrap();
fs::write(
directory.join(Self::HELD),
b"a file the scrub cannot remove",
)
.unwrap();
#[cfg(windows)]
let handle = {
use std::os::windows::fs::OpenOptionsExt;
fs::OpenOptions::new()
.read(true)
.share_mode(0)
.open(directory.join(Self::HELD))
.expect("the blocking handle opens")
};
#[cfg(unix)]
Self::set_mode(directory, 0o555);
Some(Self {
directory: directory.to_path_buf(),
#[cfg(windows)]
_handle: handle,
})
}
fn release(self) {
drop(self);
}
#[cfg(unix)]
fn refusal_is_possible() -> bool {
let probe = tempfile::tempdir().unwrap();
let directory = probe.path().join("probe");
fs::create_dir(&directory).unwrap();
fs::write(directory.join("file"), b"probe").unwrap();
Self::set_mode(&directory, 0o555);
let refused = fs::remove_dir_all(&directory).is_err();
Self::set_mode(&directory, 0o755);
refused
}
#[cfg(unix)]
fn set_mode(directory: &Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(directory).unwrap().permissions();
permissions.set_mode(mode);
fs::set_permissions(directory, permissions).unwrap();
}
}
impl Drop for BlockedDeletion {
fn drop(&mut self) {
#[cfg(unix)]
Self::set_mode(&self.directory, 0o755);
#[cfg(not(unix))]
let _ = &self.directory;
}
}
#[tokio::test]
async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
.with_persistent_workspace(1);
harness.ready().await;
let first = harness.launch().await;
let slot = harness.slot_path(1);
assert_eq!(first.runtime_path(), slot);
assert_eq!(
read_runner_id(&slot),
Some(73),
"the attempt registered, so its identity is on disk"
);
let marker = retain_under_work(&slot);
litter_the_slot(&slot);
harness.cleanup_retaining_work(first.id).await;
assert_eq!(entries_of(&slot), only_the_job_workspace());
assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
assert_eq!(
read_runner_id(&slot),
None,
"the first attempt's registration identity is gone before the second starts"
);
assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
assert!(!harness.attempt(first.id).holds_slot_lease());
let second = harness.launch().await;
assert_ne!(second.id, first.id);
assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
assert_eq!(
second.runtime_path(),
slot,
"the same slot, so the same retained `_work`"
);
assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
}
#[tokio::test]
async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
.with_persistent_workspace(1);
harness.ready().await;
let attempt = harness.launch().await;
let slot = harness.slot_path(1);
let marker = retain_under_work(&slot);
litter_the_slot(&slot);
harness.conclude(attempt.id);
harness
.store
.remove_policy(harness.policy.id, harness.policy.revision())
.unwrap();
assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
harness
.launcher
.clean(attempt.id)
.await
.expect("journal facts alone are enough to clean the slot");
assert_eq!(entries_of(&slot), only_the_job_workspace());
assert!(marker.exists());
assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
}
#[tokio::test]
async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
.with_persistent_workspace(2);
harness.ready().await;
let first = harness.launch().await;
let slot = harness.slot_path(1);
let marker = retain_under_work(&slot);
litter_the_slot(&slot);
harness.conclude(first.id);
let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
eprintln!(
"skipped: this account cannot be refused a deletion, so no partial deletion can \
be injected"
);
return;
};
let refusal = harness
.launcher
.clean(first.id)
.await
.expect_err("a deletion that failed may not report a cleaned slot");
let rendered = refusal.reason.to_string();
assert!(rendered.contains("could not be removed"), "{rendered}");
let held = harness.attempt(first.id);
assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
assert!(held.holds_slot_lease(), "so the slot is still leased");
assert!(
!held.state().counts_against_capacity(),
"and a concluded attempt still costs the host no capacity"
);
let restarted = harness.restart();
restarted
.recover_startup(std::slice::from_ref(&harness.policy))
.await
.expect("one quarantined slot does not stop the host recovering");
assert_eq!(
harness.attempt(first.id).state(),
AttemptState::Failed,
"the quarantine survived the restart"
);
assert!(
harness
.reconcile_events
.events()
.iter()
.any(|event| matches!(
event,
LifecycleEvent::AttemptCleanFailed {
reason: "slot_entry_could_not_be_removed",
..
}
)),
"the refusal is reported rather than retried in silence"
);
let guard = harness.allocation_lock.acquire().await.unwrap();
let second = restarted
.launch(LaunchRequest {
host: &harness.host,
policy: &harness.policy,
allocation_guard: &guard,
})
.await
.expect("the host can still launch");
assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
drop(guard);
block.release();
restarted
.clean(first.id)
.await
.expect("the retried cleanup completes");
assert_eq!(entries_of(&slot), only_the_job_workspace());
assert!(marker.exists());
assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
}
#[tokio::test]
async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
harness.ready().await;
let attempt = harness.launch().await;
let runtime = attempt.runtime_path().to_path_buf();
assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
harness.conclude(attempt.id);
let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
eprintln!(
"skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
);
return;
};
let refusal = harness
.launcher
.clean(attempt.id)
.await
.expect_err("a deletion that failed may not report a removed workspace");
let rendered = refusal.reason.to_string();
assert!(
rendered.contains("could not be removed"),
"the refusal names what happened: {rendered}"
);
assert_ne!(
harness.attempt(attempt.id).state(),
AttemptState::Cleaned,
"an attempt whose directory is still on disk is not cleaned"
);
assert!(
runtime.is_dir(),
"the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
);
block.release();
harness
.launcher
.clean(attempt.id)
.await
.expect("the retried cleanup completes");
assert!(!runtime.exists(), "the whole attempt directory goes");
assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
}
#[tokio::test]
async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
.with_persistent_workspace(1);
harness.ready().await;
let first = harness.launch().await;
let slot = harness.slot_path(1);
let marker = retain_under_work(&slot);
harness.cleanup_retaining_work(first.id).await;
harness
.policy
.set_workspace_policy(WorkspacePolicy::Ephemeral)
.unwrap();
let second = harness.launch().await;
assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
assert_eq!(
second.runtime_path().parent().unwrap(),
harness.host_root(),
"a disposable attempt is a child of the host root"
);
assert!(slot.is_dir(), "the old slot is left where it stands");
assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
harness.conclude(second.id);
harness.launcher.clean(second.id).await.unwrap();
assert!(!second.runtime_path().exists());
assert!(marker.exists());
}
#[tokio::test]
async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
.with_persistent_workspace(1);
harness.ready().await;
let slot = harness.slot_path(1);
fs::create_dir_all(&slot).unwrap();
let marker = retain_under_work(&slot);
litter_the_slot(&slot);
let id = AttemptId::new_random();
let mut attempt = RunnerAttempt::allocate_in(
id,
harness.policy.id,
&slot,
AttemptWorkspace::persistent_slot(nz(1)),
harness.clock.now(),
);
attempt.jit_received(harness.clock.now()).unwrap();
attempt.started(4242, harness.clock.now()).unwrap();
harness.store.record_attempt(&attempt).unwrap();
harness.clock.advance_secs(11);
harness.processes.set_alive(true);
harness
.github
.observe(GithubRunnerObservation::NotRegistered);
harness.launcher.supervise(&harness.policy).await.unwrap();
let actions = harness.processes.actions.lock().unwrap().clone();
let intent = actions
.iter()
.position(|action| *action == "terminate_intent")
.unwrap();
let signal = actions
.iter()
.position(|action| *action == "terminate")
.unwrap();
assert!(intent < signal, "{actions:?}");
assert!(!harness.processes.alive.load(Ordering::SeqCst));
let cleaned = harness.attempt(id);
assert_eq!(cleaned.state(), AttemptState::Cleaned);
assert!(matches!(
cleaned.outcome(),
Some(AttemptOutcome::Failed {
reason: FailureReason::TerminatedAfterRegistrationTimeout
})
));
assert_eq!(entries_of(&slot), only_the_job_workspace());
assert!(marker.exists());
}
#[test]
fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
let root = tempfile::tempdir().unwrap();
let slot = root.path().join("s1");
fs::create_dir(&slot).unwrap();
let marker = retain_under_work(&slot);
litter_the_slot(&slot);
fs::write(slot.join("runner-package"), b"verified").unwrap();
scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
verify_slot_scrubbed(&slot).expect("and proves it afterwards");
assert_eq!(entries_of(&slot), only_the_job_workspace());
assert!(marker.exists());
}
#[test]
fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
let slot = Path::new("/runners/s1");
let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
assert!(counted.contains("2 entries other than"), "{counted}");
assert!(counted.contains("including `bin`"), "{counted}");
assert_eq!(
residue_detail(slot, 1, &[]),
format!(
"1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
slot.display()
)
);
let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
assert!(!raced.contains('0'), "{raced}");
assert!(raced.contains("reported nothing but"), "{raced}");
assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
}
#[test]
fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
let root = tempfile::tempdir().unwrap();
let slot = root.path().join("s1");
fs::create_dir(&slot).unwrap();
fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
fs::write(slot.join(survivor), b"left behind").unwrap();
let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
assert_eq!(quarantine.refusal, SlotRefusal::Residue);
assert!(
quarantine.detail.contains(&format!("`{survivor}`")),
"{quarantine}"
);
fs::remove_file(slot.join(survivor)).unwrap();
}
let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
fs::write(&handoff, JIT.as_bytes()).unwrap();
let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
assert!(
quarantine.detail.contains("an encoded JIT handoff"),
"{quarantine}"
);
assert!(!quarantine.detail.contains(JIT), "{quarantine}");
fs::remove_file(&handoff).unwrap();
fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
assert!(
quarantine.detail.contains("1 entry other than"),
"{quarantine}"
);
assert!(
!quarantine.detail.contains("ghp_DO_NOT_LEAK"),
"{quarantine}"
);
}
#[test]
fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
let root = tempfile::tempdir().unwrap();
let configured =
LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
let slot = configured.as_path().join("s1");
fs::create_dir(&slot).unwrap();
verify_journalled_slot(&slot, nz(1), Some(&configured))
.expect("the journalled slot agrees");
verify_journalled_slot(&slot, nz(1), None)
.expect("and a policy that is gone removes a check, not the ability to clean");
assert_eq!(
verify_journalled_slot(&slot, nz(2), None)
.unwrap_err()
.refusal,
SlotRefusal::NotTheJournalledSlot
);
for stray in ["s1/nested", "not-a-slot", "s01"] {
let path = configured.as_path().join(stray);
assert_eq!(
verify_journalled_slot(&path, nz(1), None)
.unwrap_err()
.refusal,
SlotRefusal::NotTheJournalledSlot,
"{}",
path.display()
);
}
let elsewhere = tempfile::tempdir().unwrap();
let other =
LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
assert_eq!(
verify_journalled_slot(&slot, nz(1), Some(&other))
.unwrap_err()
.refusal,
SlotRefusal::PolicyRootDisagrees
);
}
#[cfg(unix)]
#[test]
fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
let root = tempfile::tempdir().unwrap();
let outside = root.path().join("operator-data");
fs::create_dir(&outside).unwrap();
let sentinel = outside.join("do-not-delete.txt");
fs::write(
&sentinel,
b"an operator's data, outside every approved root",
)
.unwrap();
let slot = root.path().join("s1");
fs::create_dir(&slot).unwrap();
fs::create_dir(slot.join("bin")).unwrap();
std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
let quarantine = scrub_slot_entries(&slot).unwrap_err();
assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
assert!(
sentinel.exists(),
"the deletion followed the link out of the slot"
);
assert!(outside.is_dir());
assert!(
slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
"the substituted link is left for the operator, never unlinked as if it were ours"
);
let file_work = root.path().join("s2");
fs::create_dir(&file_work).unwrap();
fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
assert_eq!(
scrub_slot_entries(&file_work).unwrap_err().refusal,
SlotRefusal::WorkNotADirectory
);
}
#[cfg(unix)]
#[test]
fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
let root = tempfile::tempdir().unwrap();
let outside = root.path().join("operator-data");
fs::create_dir(&outside).unwrap();
let sentinel = outside.join("do-not-delete.txt");
fs::write(
&sentinel,
b"an operator's data, outside every approved root",
)
.unwrap();
let inside = root.path().join("inside");
fs::create_dir(&inside).unwrap();
let slot = inside.join("s1");
std::os::unix::fs::symlink(&outside, &slot).unwrap();
assert_eq!(
verify_journalled_slot(&slot, nz(1), None)
.unwrap_err()
.refusal,
SlotRefusal::Containment
);
assert!(sentinel.exists());
assert!(
slot.symlink_metadata().is_ok(),
"the link is left for the operator rather than removed as if it were ours"
);
}
#[cfg(windows)]
#[test]
fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
let root = tempfile::tempdir().unwrap();
let outside = root.path().join("operator-data");
fs::create_dir(&outside).unwrap();
let sentinel = outside.join("do-not-delete.txt");
fs::write(
&sentinel,
b"an operator's data, outside every approved root",
)
.unwrap();
let inside = root.path().join("inside");
fs::create_dir(&inside).unwrap();
let slot = inside.join("s1");
let Some(()) = plant_junction(&slot, &outside) else {
eprintln!("skipped: this machine would not create a directory junction");
return;
};
assert_eq!(
verify_journalled_slot(&slot, nz(1), None)
.unwrap_err()
.refusal,
SlotRefusal::Containment
);
assert!(
sentinel.exists(),
"the refusal resolved the junction and reached the operator's data"
);
assert!(
slot.symlink_metadata().is_ok(),
"the junction is left for the operator rather than removed as if it were ours"
);
}
#[cfg(windows)]
fn plant_junction(link: &Path, target: &Path) -> Option<()> {
let made = std::process::Command::new("cmd")
.arg("/C")
.arg("mklink")
.arg("/J")
.arg(link)
.arg(target)
.output()
.ok()?;
(made.status.success() && link.symlink_metadata().is_ok()).then_some(())
}
#[cfg(windows)]
#[test]
fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
let root = tempfile::tempdir().unwrap();
let outside = root.path().join("operator-data");
fs::create_dir(&outside).unwrap();
let sentinel = outside.join("do-not-delete.txt");
fs::write(
&sentinel,
b"an operator's data, outside every approved root",
)
.unwrap();
let slot = root.path().join("s1");
fs::create_dir(&slot).unwrap();
fs::create_dir(slot.join("bin")).unwrap();
let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
eprintln!("skipped: this machine would not create a directory junction");
return;
};
let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
assert!(is_link_like(&work), "a junction is a reparse point");
let quarantine = scrub_slot_entries(&slot).unwrap_err();
assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
assert!(
sentinel.exists(),
"the deletion followed the junction out of the slot"
);
assert!(outside.is_dir());
let elsewhere = root.path().join("s2");
fs::create_dir(&elsewhere).unwrap();
fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
assert!(sentinel.exists(), "the junction was followed, not unlinked");
assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
}
}
#[cfg(unix)]
#[tokio::test]
async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
.with_persistent_workspace(2);
harness.ready().await;
let first = harness.launch().await;
let slot = harness.slot_path(1);
harness.conclude(first.id);
let outside = harness._root.path().join("operator-data");
fs::create_dir_all(&outside).unwrap();
let sentinel = outside.join("do-not-delete.txt");
fs::write(&sentinel, b"outside every approved root").unwrap();
std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
harness
.launcher
.clean(first.id)
.await
.expect_err("a slot whose `_work` was substituted is quarantined");
assert!(sentinel.exists());
let held = harness.attempt(first.id);
assert_eq!(held.state(), AttemptState::Failed);
assert!(held.holds_slot_lease());
let second = harness.launch().await;
assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
}
#[test]
fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
let root = tempfile::tempdir().unwrap();
let occupied = root.path().join("s1");
fs::write(&occupied, b"an operator's file").unwrap();
verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
assert_eq!(
slot_is_present(&occupied).unwrap_err().refusal,
SlotRefusal::SlotNotADirectory
);
assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
assert!(!slot_is_present(&root.path().join("s2")).unwrap());
let present = root.path().join("s3");
fs::create_dir(&present).unwrap();
assert!(slot_is_present(&present).unwrap());
}
#[test]
fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
let root = tempfile::tempdir().unwrap();
let disposable = root.path().join("abcdef012345");
fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
let ephemeral = RunnerAttempt::allocate(
AttemptId::new_random(),
fixtures::POLICY_ID,
&disposable,
fixtures::created_at(),
);
remove_materialized_package(&ephemeral).unwrap();
assert!(!disposable.exists());
let slot = root.path().join("s1");
fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
fs::create_dir_all(slot.join("bin")).unwrap();
let persistent = RunnerAttempt::allocate_in(
AttemptId::new_random(),
fixtures::POLICY_ID,
&slot,
AttemptWorkspace::persistent_slot(nz(1)),
fixtures::created_at(),
);
remove_materialized_package(&persistent).unwrap();
assert_eq!(entries_of(&slot), only_the_job_workspace());
assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
}
#[test]
fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
let refusals = [
SlotRefusal::NotTheJournalledSlot,
SlotRefusal::PolicyRootDisagrees,
SlotRefusal::Containment,
SlotRefusal::SlotNotADirectory,
SlotRefusal::Enumeration,
SlotRefusal::WorkNotADirectory,
SlotRefusal::Deletion,
SlotRefusal::Residue,
];
let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
assert_eq!(
classes.len(),
refusals.len(),
"an event class shared by two refusals tells an operator less than it appears to"
);
for refusal in refusals {
assert!(
refusal
.class()
.chars()
.all(|c| c.is_ascii_lowercase() || c == '_'),
"{}",
refusal.class()
);
assert!(
refusal.remediation().contains("slot lease"),
"every refusal has to say the lease is still held: {}",
refusal.class()
);
}
}
}