1#[cfg(test)]
12use std::collections::VecDeque;
13use std::collections::{BTreeMap, BTreeSet};
14use std::ffi::OsStr;
15use std::fmt;
16use std::fs;
17use std::io::Write;
18use std::num::NonZeroU16;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21use std::time::Duration;
22
23use async_trait::async_trait;
24use runner_manager_domain::attempt::{
25 AttemptOutcome, AttemptState, FailureReason, GithubRunnerObservation, RecoveryDecision,
26 RecoveryObservation, RecoveryTimeouts, RunnerAttempt, authorize, recovery_decision,
27};
28use runner_manager_domain::model::{AttemptId, Clock, HostId, PolicyId, ScaleTarget};
29use runner_manager_domain::path::LocalAbsolutePath;
30use runner_manager_domain::policy::ScalePolicy;
31use runner_manager_domain::store::{Store, StoreError};
32use runner_manager_domain::workspace::{AttemptWorkspace, WorkspacePolicy};
33use runner_manager_github::jit::{
34 DEFAULT_WORK_FOLDER, EncodedJitConfig, JitError, JitGateway, JitRegistration, JitRunnerRequest,
35};
36use runner_manager_github::rest::{CancelToken, InventoryGateway};
37use runner_manager_platform::process::{
38 Adoption, ChildProcess, ProcessIdentity, RestrictiveHandoff, SpawnSpec, Termination,
39};
40use runner_manager_platform::runner_root::{
41 self, RootOwner, RootPreflight, RunnerRootError, default_runner_root,
42};
43use secrecy::SecretString;
44
45use crate::package::{PackageCache, PackageError, RunnerVersion};
46use crate::reconcile::{
47 AllocationGuard, EventSink, LaunchFailure, LaunchRequest, LifecycleEvent, OutcomeKind,
48 ReplacementIntent, RunnerLauncher,
49};
50
51const IDENTITY_FILE: &str = ".runner-process.json";
52const FALLBACK_IDENTITY_FILE: &str = ".runner-process.recovery.json";
53const UNRESOLVED_PROCESS_FILE: &str = ".runner-process.unresolved";
54const RUNNER_ID_FILE: &str = ".github-runner-id";
55const TERMINATE_INTENT_FILE: &str = ".terminate-registration-timeout";
56const MAX_POST_SPAWN_STOP_ATTEMPTS: usize = 3;
57
58const SENSITIVE_SLOT_ENTRIES: &[&str] = &[
71 "bin",
73 "externals",
74 "run.sh",
75 "run.cmd",
76 "config.sh",
77 "config.cmd",
78 ".runner",
81 ".credentials",
82 ".credentials_rsaparams",
83 ".env",
84 ".path",
85 "_diag",
86 IDENTITY_FILE,
88 FALLBACK_IDENTITY_FILE,
89 UNRESOLVED_PROCESS_FILE,
90 RUNNER_ID_FILE,
91 TERMINATE_INTENT_FILE,
92];
93#[cfg(test)]
94const TEST_LISTENER_READY: &str = ".test-listener-ready";
95
96fn runner_listener_spec(program: PathBuf, runtime: &Path) -> SpawnSpec {
101 let tmp = runtime.join("tmp");
102 let _ = std::fs::create_dir_all(&tmp);
103 SpawnSpec::new(program)
104 .arg("run")
105 .working_dir(runtime)
106 .env("TMPDIR", &tmp)
107 .env("TEMP", &tmp)
108 .env("TMP", &tmp)
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct RetryPolicy {
114 pub max_attempts: u32,
115 pub initial: Duration,
116 pub maximum: Duration,
117}
118
119impl RetryPolicy {
120 #[must_use]
121 pub const fn bounded(max_attempts: u32, initial: Duration, maximum: Duration) -> Self {
122 Self {
123 max_attempts,
124 initial,
125 maximum,
126 }
127 }
128
129 fn delay(self, failure_index: u32) -> Duration {
130 let shift = failure_index.saturating_sub(1).min(31);
131 self.initial
132 .saturating_mul(1_u32 << shift)
133 .min(self.maximum)
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum AttemptEvent {
141 State {
142 attempt: AttemptId,
143 state: AttemptState,
144 },
145 Retry {
146 attempt: AttemptId,
147 operation: &'static str,
148 delay: Duration,
149 },
150 Launched {
153 attempt: AttemptId,
154 materialize: Duration,
155 register: Duration,
156 spawn: Duration,
157 },
158 Adopted {
159 attempt: AttemptId,
160 },
161 RemoteIdentityRecovered {
162 attempt: AttemptId,
163 runner_id: u64,
164 },
165 TerminateIntent {
166 attempt: AttemptId,
167 },
168 Terminated {
169 attempt: AttemptId,
170 },
171 Deregistered {
175 attempt: AttemptId,
176 runner_id: u64,
177 },
178 Concluded {
179 attempt: AttemptId,
180 outcome: OutcomeKind,
181 },
182 Cleaned {
183 attempt: AttemptId,
184 outcome: OutcomeKind,
185 },
186}
187
188pub trait AttemptEventSink: fmt::Debug + Send + Sync {
189 fn emit(&self, event: AttemptEvent);
190}
191
192#[derive(Debug, Default)]
193pub struct AttemptEventLog(Mutex<Vec<AttemptEvent>>);
194
195impl AttemptEventLog {
196 #[must_use]
197 pub fn events(&self) -> Vec<AttemptEvent> {
198 self.0
199 .lock()
200 .map(|events| events.clone())
201 .unwrap_or_default()
202 }
203}
204
205impl AttemptEventSink for AttemptEventLog {
206 fn emit(&self, event: AttemptEvent) {
207 if let Ok(mut events) = self.0.lock() {
208 events.push(event);
209 }
210 }
211}
212
213#[derive(Debug, Clone, Copy, Default)]
214pub struct NoAttemptEvents;
215
216impl AttemptEventSink for NoAttemptEvents {
217 fn emit(&self, _event: AttemptEvent) {}
218}
219
220const SLOW_LAUNCH: Duration = Duration::from_secs(60);
225
226#[derive(Debug, Clone, Copy, Default)]
233pub struct TracingAttemptEvents;
234
235impl AttemptEventSink for TracingAttemptEvents {
236 fn emit(&self, event: AttemptEvent) {
237 match event {
238 AttemptEvent::Retry {
239 attempt,
240 operation,
241 delay,
242 } => tracing::warn!(
243 event = "attempt_retry",
244 attempt_id = %attempt,
245 reason = operation,
246 retry_in_ms = millis(delay),
247 ),
248 AttemptEvent::Launched {
249 attempt,
250 materialize,
251 register,
252 spawn,
253 } => {
254 let total = materialize + register + spawn;
255 macro_rules! launched {
256 ($level:ident, $name:literal) => {
257 tracing::$level!(
258 event = $name,
259 attempt_id = %attempt,
260 duration_ms = millis(total),
261 materialize_ms = millis(materialize),
262 register_ms = millis(register),
263 spawn_ms = millis(spawn),
264 )
265 };
266 }
267 if total >= SLOW_LAUNCH {
268 launched!(warn, "attempt_launch_slow");
269 } else {
270 launched!(info, "attempt_launched");
271 }
272 }
273 _ => {}
274 }
275 }
276}
277
278fn millis(duration: Duration) -> u64 {
279 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
280}
281
282#[async_trait]
284pub trait DemandPersistence: fmt::Debug + Send + Sync {
285 async fn persists(&self, policy: PolicyId) -> bool;
286}
287
288#[derive(Debug, Clone, Copy, Default)]
289pub struct PersistentDemand;
290
291#[async_trait]
292impl DemandPersistence for PersistentDemand {
293 async fn persists(&self, _policy: PolicyId) -> bool {
294 true
295 }
296}
297
298#[async_trait]
299pub trait RetryDelay: fmt::Debug + Send + Sync {
300 async fn wait(&self, duration: Duration);
301}
302
303#[derive(Debug, Clone, Copy, Default)]
304pub struct TokioRetryDelay;
305
306#[async_trait]
307impl RetryDelay for TokioRetryDelay {
308 async fn wait(&self, duration: Duration) {
309 tokio::time::sleep(duration).await;
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub struct JitRequestFailure {
315 pub terminal: bool,
316 pub reason: FailureReason,
317 pub retry_after: Option<Duration>,
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub struct LifecycleGithubObservation {
325 pub status: GithubRunnerObservation,
326 pub runner_id: Option<u64>,
327}
328
329impl LifecycleGithubObservation {
330 #[must_use]
331 pub const fn unreachable() -> Self {
332 Self {
333 status: GithubRunnerObservation::Unreachable,
334 runner_id: None,
335 }
336 }
337
338 #[must_use]
339 pub const fn not_registered() -> Self {
340 Self {
341 status: GithubRunnerObservation::NotRegistered,
342 runner_id: None,
343 }
344 }
345
346 #[must_use]
347 pub const fn registered(runner_id: u64, busy: bool) -> Self {
348 Self {
349 status: GithubRunnerObservation::Registered { busy },
350 runner_id: Some(runner_id),
351 }
352 }
353}
354
355#[async_trait]
358pub trait LifecycleGithub: fmt::Debug + Send + Sync {
359 async fn register(
360 &self,
361 target: &ScaleTarget,
362 request: &JitRunnerRequest,
363 cancel: &CancelToken,
364 ) -> Result<JitRegistration, JitRequestFailure>;
365
366 async fn observe(
367 &self,
368 target: &ScaleTarget,
369 attempt: AttemptId,
370 cancel: &CancelToken,
371 ) -> LifecycleGithubObservation;
372
373 async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool;
380}
381
382#[async_trait]
383impl<T> LifecycleGithub for T
384where
385 T: JitGateway + InventoryGateway + fmt::Debug + Send + Sync,
386{
387 async fn register(
388 &self,
389 target: &ScaleTarget,
390 request: &JitRunnerRequest,
391 cancel: &CancelToken,
392 ) -> Result<JitRegistration, JitRequestFailure> {
393 self.generate_jit_config(target, request, cancel)
394 .await
395 .map_err(|error| {
396 let reason = if matches!(&error, JitError::Forbidden { .. }) {
397 FailureReason::Other(
398 "GitHub refused JIT registration with 403; check the App's runner permission and runner-group access"
399 .into(),
400 )
401 } else {
402 FailureReason::JitRequestFailed
403 };
404 JitRequestFailure {
405 terminal: error.is_terminal(),
406 reason,
407 retry_after: error
408 .rate_limited()
409 .map(|limit| limit.delay_from(self.now())),
410 }
411 })
412 }
413
414 async fn observe(
415 &self,
416 target: &ScaleTarget,
417 attempt: AttemptId,
418 cancel: &CancelToken,
419 ) -> LifecycleGithubObservation {
420 let expected_name = runner_name(attempt);
421 match self.list_runners(target, cancel).await {
422 Ok(inventory) => inventory
423 .runners()
424 .iter()
425 .find(|runner| runner.name == expected_name)
426 .map_or(LifecycleGithubObservation::not_registered(), |runner| {
427 LifecycleGithubObservation::registered(runner.id, runner.busy)
428 }),
429 Err(_) => LifecycleGithubObservation::unreachable(),
430 }
431 }
432
433 async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool {
434 self.remove_runner(target, runner_id, cancel).await.is_ok()
435 }
436}
437
438#[async_trait]
440pub trait RuntimePackages: fmt::Debug + Send + Sync {
441 async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason>;
442 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason>;
443 fn prune_obsolete_guarded(
444 &self,
445 authority: PruneAuthority<'_>,
446 current: &RunnerVersion,
447 attempts: &[RunnerAttempt],
448 ) -> Result<(), FailureReason>;
449}
450
451pub struct PruneAuthority<'a> {
456 _guard: &'a AllocationGuard,
457}
458
459impl fmt::Debug for PruneAuthority<'_> {
460 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
461 f.write_str("PruneAuthority")
462 }
463}
464
465impl<'a> PruneAuthority<'a> {
466 fn from_launch_request(guard: &'a AllocationGuard) -> Self {
467 Self { _guard: guard }
468 }
469}
470
471#[derive(Debug)]
474pub struct CachedRuntimePackages {
475 cache: Arc<PackageCache>,
476}
477
478impl CachedRuntimePackages {
479 #[must_use]
480 pub fn new(cache: Arc<PackageCache>) -> Self {
481 Self { cache }
482 }
483}
484
485#[async_trait]
486impl RuntimePackages for CachedRuntimePackages {
487 async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason> {
488 let installed = self
489 .cache
490 .ensure_installed()
491 .await
492 .map_err(package_failure)?;
493 let source = installed.root().to_path_buf();
496 let destination = attempt.runtime_path().to_path_buf();
497 tokio::task::spawn_blocking(move || copy_package_tree(&source, &destination))
498 .await
499 .map_err(|_| FailureReason::ProcessStartFailed)?
500 .map_err(|_| FailureReason::ProcessStartFailed)?;
501 if let Err(error) = self.cache.lease(attempt, installed.version()) {
502 let _ = remove_materialized_package(attempt);
506 return Err(package_failure(error));
507 }
508 Ok(installed.version().clone())
509 }
510
511 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
512 self.cache.release(attempt).map_err(package_failure)
513 }
514
515 fn prune_obsolete_guarded(
516 &self,
517 _authority: PruneAuthority<'_>,
518 current: &RunnerVersion,
519 attempts: &[RunnerAttempt],
520 ) -> Result<(), FailureReason> {
521 for installed in self.cache.installed().map_err(package_failure)? {
522 if installed.version() != current {
523 match self.cache.prune(installed.version(), attempts) {
524 Ok(()) | Err(PackageError::VersionInUse { .. }) => {}
525 Err(error) => return Err(package_failure(error)),
526 }
527 }
528 }
529 Ok(())
530 }
531}
532
533fn package_failure(error: PackageError) -> FailureReason {
534 error.failure_reason().unwrap_or(FailureReason::Other(
535 "runner package cache operation failed".into(),
536 ))
537}
538
539fn package_failure_is_terminal(reason: &FailureReason) -> bool {
540 matches!(
541 reason,
542 FailureReason::RunnerPackageUnverified | FailureReason::RunnerVersionRejected
543 )
544}
545
546const WORKSPACE_NAME_LEN: usize = 12;
569
570fn workspace_name(id: AttemptId) -> String {
572 let full = id.to_string();
573 full.chars()
574 .filter(|c| *c != '-')
575 .take(WORKSPACE_NAME_LEN)
576 .collect()
577}
578
579#[derive(Debug, Clone)]
586struct Placement {
587 runtime: PathBuf,
588 workspace: AttemptWorkspace,
589}
590
591fn root_failure(error: RunnerRootError) -> LifecycleError {
598 tracing::warn!(
615 error_kind = error.kind(),
616 "the runner root refused this launch, so no attempt was created; the host will \
617 retry every poll until the cause is resolved. Re-running `host set-runtime-root` \
618 with the same path re-runs this check and prints the directory and the \
619 remediation in full"
620 );
621 LifecycleError::Failed(FailureReason::Other(error.to_string()))
622}
623
624fn lowest_free_slot(leases: &[RunnerAttempt], ceiling: NonZeroU16) -> Option<NonZeroU16> {
633 let held: BTreeSet<u16> = leases
634 .iter()
635 .filter_map(|attempt| attempt.workspace().slot_number())
636 .collect();
637 (1..=ceiling.get())
638 .find(|slot| !held.contains(slot))
639 .and_then(NonZeroU16::new)
640}
641
642fn create_or_validate_slot(slot: &Path) -> Result<(), LifecycleError> {
650 match fs::symlink_metadata(slot) {
651 Ok(metadata) if is_link_like(&metadata) => Err(slot_refusal(
657 slot,
658 "is a symbolic link, junction or other reparse point, which could place runner \
659 files outside the configured root",
660 )),
661 Ok(metadata) if !metadata.is_dir() => Err(slot_refusal(slot, "is not a directory")),
662 Ok(_) => Ok(()),
663 Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(slot)
664 .map_err(|source| slot_refusal(slot, format!("could not be created: {source}"))),
665 Err(source) => Err(slot_refusal(
666 slot,
667 format!("could not be inspected: {source}"),
668 )),
669 }
670}
671
672fn accept_reusable_slot(slot: &Path) -> Result<(), LifecycleError> {
684 let unreadable =
685 |source: std::io::Error| slot_refusal(slot, format!("could not be read: {source}"));
686 let entries = fs::read_dir(slot).map_err(unreadable)?;
687 let mut refused: Vec<String> = Vec::new();
688 for entry in entries {
689 let entry = entry.map_err(unreadable)?;
690 let name = entry.file_name();
691 let metadata = fs::symlink_metadata(entry.path()).map_err(|source| {
692 slot_refusal(
693 slot,
694 format!("entry {name:?} could not be inspected: {source}"),
695 )
696 })?;
697 if is_retainable_work_folder(&name, &metadata) {
702 continue;
703 }
704 refused.push(name.to_string_lossy().into_owned());
705 }
706 if refused.is_empty() {
707 return Ok(());
708 }
709 refused.sort();
710 Err(slot_refusal(
711 slot,
712 format!(
713 "holds {} that this attempt may not reuse: [{}]. A reusable slot is empty or holds \
714 one real `{DEFAULT_WORK_FOLDER}` directory and nothing else; remove or move the \
715 entries listed, or let cleanup and recovery resolve them",
716 if refused.len() == 1 {
717 "an entry"
718 } else {
719 "entries"
720 },
721 refused.join(", ")
722 ),
723 ))
724}
725
726fn slot_refusal(slot: &Path, detail: impl fmt::Display) -> LifecycleError {
727 LifecycleError::Failed(FailureReason::Other(format!(
728 "the persistent slot {} {detail}",
729 slot.display()
730 )))
731}
732
733fn is_work_folder(name: &OsStr) -> bool {
743 if cfg!(windows) {
744 name.eq_ignore_ascii_case(DEFAULT_WORK_FOLDER)
745 } else {
746 name == OsStr::new(DEFAULT_WORK_FOLDER)
747 }
748}
749
750fn is_link_like(metadata: &fs::Metadata) -> bool {
759 if metadata.file_type().is_symlink() {
760 return true;
761 }
762 #[cfg(windows)]
763 {
764 use std::os::windows::fs::MetadataExt;
765
766 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
767 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
768 }
769 #[cfg(not(windows))]
770 false
771}
772
773fn is_retainable_work_folder(name: &OsStr, metadata: &fs::Metadata) -> bool {
781 is_work_folder(name) && metadata.is_dir() && !is_link_like(metadata)
782}
783
784fn remove_materialized_package(attempt: &RunnerAttempt) -> std::io::Result<()> {
791 match attempt.workspace() {
792 AttemptWorkspace::Ephemeral => remove_runtime_tree(attempt.runtime_path()),
793 AttemptWorkspace::PersistentSlot { .. } => scrub_slot_entries(attempt.runtime_path())
794 .map_err(|quarantine| std::io::Error::other(quarantine.to_string())),
795 }
796}
797
798fn remove_runtime_tree(path: &Path) -> std::io::Result<()> {
806 #[cfg(windows)]
807 {
808 remove_dir_all::remove_dir_all(path)
809 }
810 #[cfg(not(windows))]
811 {
812 fs::remove_dir_all(path)
813 }
814}
815
816#[derive(Debug, Clone, Copy, PartialEq, Eq)]
831enum SlotRefusal {
832 NotTheJournalledSlot,
834 PolicyRootDisagrees,
836 Containment,
838 SlotNotADirectory,
840 Enumeration,
842 WorkNotADirectory,
844 Deletion,
846 Residue,
848}
849
850impl SlotRefusal {
851 const fn class(self) -> &'static str {
853 match self {
854 Self::NotTheJournalledSlot => "slot_path_is_not_the_journalled_slot",
855 Self::PolicyRootDisagrees => "slot_root_disagrees_with_policy",
856 Self::Containment => "slot_escapes_its_root",
857 Self::SlotNotADirectory => "slot_is_not_a_directory",
858 Self::Enumeration => "slot_could_not_be_enumerated",
859 Self::WorkNotADirectory => "retained_work_is_not_a_directory",
860 Self::Deletion => "slot_entry_could_not_be_removed",
861 Self::Residue => "slot_still_holds_runner_state",
862 }
863 }
864
865 const fn remediation(self) -> &'static str {
867 match self {
868 Self::NotTheJournalledSlot | Self::PolicyRootDisagrees | Self::Containment => {
869 "the attempt keeps its slot lease and nothing was removed; correct the \
870 repository's persistent workspace path, or remove the slot directory by hand \
871 once you have confirmed what is in it"
872 }
873 Self::SlotNotADirectory | Self::WorkNotADirectory => {
874 "the attempt keeps its slot lease and nothing was removed; a job replaced the \
875 slot or its `_work` with a link, so inspect it before deleting anything and \
876 treat the retained workspace as untrusted"
877 }
878 Self::Enumeration | Self::Deletion | Self::Residue => {
879 "the attempt keeps its slot lease and will be cleaned again on the next pass; \
880 release whatever is holding the files open, or remove the slot's contents by \
881 hand leaving only `_work`"
882 }
883 }
884 }
885}
886
887#[derive(Debug, Clone, PartialEq, Eq)]
894struct SlotQuarantine {
895 refusal: SlotRefusal,
896 detail: String,
897}
898
899impl SlotQuarantine {
900 fn new(refusal: SlotRefusal, detail: impl Into<String>) -> Self {
901 Self {
902 refusal,
903 detail: detail.into(),
904 }
905 }
906}
907
908impl fmt::Display for SlotQuarantine {
909 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
910 write!(f, "{}; {}", self.detail, self.refusal.remediation())
911 }
912}
913
914fn verify_journalled_slot(
928 runtime: &Path,
929 slot: NonZeroU16,
930 configured: Option<&LocalAbsolutePath>,
931) -> Result<(), SlotQuarantine> {
932 let mislaid = || {
933 SlotQuarantine::new(
934 SlotRefusal::NotTheJournalledSlot,
935 format!(
936 "the journalled runtime {} is not the slot s{slot} this attempt was allocated as",
937 runtime.display()
938 ),
939 )
940 };
941 let local = |path: &Path| {
942 path.to_str()
943 .and_then(|raw| LocalAbsolutePath::new(raw).ok())
944 .ok_or_else(mislaid)
945 };
946
947 let runtime_path = local(runtime)?;
948 let root = local(runtime.parent().ok_or_else(mislaid)?)?;
949 let name = AttemptWorkspace::persistent_slot(slot)
956 .slot_directory_name()
957 .expect("a persistent workspace names its slot directory");
958 let derived = runner_root::derive_child(&root, &name).map_err(|_| mislaid())?;
959 if derived != runtime_path {
960 return Err(mislaid());
961 }
962 if let Some(configured) = configured
963 && configured != &root
964 {
965 return Err(SlotQuarantine::new(
966 SlotRefusal::PolicyRootDisagrees,
967 format!(
968 "the journalled slot {} is not under the repository's configured persistent root \
969 {}",
970 runtime.display(),
971 configured.as_str()
972 ),
973 ));
974 }
975 runner_root::verify_containment(&root, &derived).map_err(|source| {
978 SlotQuarantine::new(
979 SlotRefusal::Containment,
980 format!("the journalled slot is not inside the root it was allocated from: {source}"),
981 )
982 })
983}
984
985fn slot_is_present(slot: &Path) -> Result<bool, SlotQuarantine> {
996 match fs::symlink_metadata(slot) {
997 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
998 Err(source) => Err(SlotQuarantine::new(
999 SlotRefusal::SlotNotADirectory,
1000 format!(
1001 "the slot {} could not be inspected: {:?}",
1002 slot.display(),
1003 source.kind()
1004 ),
1005 )),
1006 Ok(metadata) if !metadata.is_dir() || is_link_like(&metadata) => Err(SlotQuarantine::new(
1007 SlotRefusal::SlotNotADirectory,
1008 format!(
1009 "the slot {} is a link or a file rather than a real directory",
1010 slot.display()
1011 ),
1012 )),
1013 Ok(_) => Ok(true),
1014 }
1015}
1016
1017fn scrub_slot_entries(slot: &Path) -> Result<(), SlotQuarantine> {
1032 let unreadable = |source: std::io::Error| {
1033 SlotQuarantine::new(
1034 SlotRefusal::Enumeration,
1035 format!(
1036 "the entries of {} could not be listed: {:?}",
1037 slot.display(),
1038 source.kind()
1039 ),
1040 )
1041 };
1042 for entry in fs::read_dir(slot).map_err(unreadable)? {
1043 let name = entry.map_err(unreadable)?.file_name();
1044 let path = slot.join(&name);
1045 let Some(metadata) = listed_entry_metadata(&path).map_err(unreadable)? else {
1048 continue;
1049 };
1050 if is_work_folder(&name) {
1051 if is_retainable_work_folder(&name, &metadata) {
1052 continue;
1053 }
1054 return Err(SlotQuarantine::new(
1055 SlotRefusal::WorkNotADirectory,
1056 format!(
1057 "the retained `{DEFAULT_WORK_FOLDER}` in {} is a link or a file rather than a \
1058 real directory",
1059 slot.display()
1060 ),
1061 ));
1062 }
1063 remove_slot_entry(&path, &metadata).map_err(|source| {
1064 SlotQuarantine::new(
1065 SlotRefusal::Deletion,
1066 format!(
1067 "an entry of {} could not be removed: {:?}",
1068 slot.display(),
1069 source.kind()
1070 ),
1071 )
1072 })?;
1073 }
1074 Ok(())
1075}
1076
1077fn listed_entry_metadata(path: &Path) -> std::io::Result<Option<fs::Metadata>> {
1083 match fs::symlink_metadata(path) {
1084 Ok(metadata) => Ok(Some(metadata)),
1085 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1086 Err(source) => Err(source),
1087 }
1088}
1089
1090fn remove_slot_entry(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
1095 let removed = if is_link_like(metadata) {
1096 fs::remove_file(path).or_else(|_| fs::remove_dir(path))
1099 } else if metadata.is_dir() {
1100 remove_runtime_tree(path)
1101 } else {
1102 fs::remove_file(path)
1103 };
1104 match removed {
1105 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1106 other => other,
1107 }
1108}
1109
1110fn verify_slot_scrubbed(slot: &Path) -> Result<(), SlotQuarantine> {
1127 let unreadable = |source: std::io::Error| {
1128 SlotQuarantine::new(
1129 SlotRefusal::Enumeration,
1130 format!(
1131 "the entries of {} could not be listed to verify the scrub: {:?}",
1132 slot.display(),
1133 source.kind()
1134 ),
1135 )
1136 };
1137 let mut residue = 0_usize;
1138 let mut named: Vec<String> = Vec::new();
1139 for entry in fs::read_dir(slot).map_err(unreadable)? {
1140 let name = entry.map_err(unreadable)?.file_name();
1141 let Some(metadata) = listed_entry_metadata(&slot.join(&name)).map_err(unreadable)? else {
1145 continue;
1146 };
1147 if is_retainable_work_folder(&name, &metadata) {
1148 continue;
1149 }
1150 residue = residue.saturating_add(1);
1151 if name
1152 .to_string_lossy()
1153 .starts_with(RestrictiveHandoff::NAME_PREFIX)
1154 {
1155 named.push("an encoded JIT handoff".to_owned());
1156 }
1157 }
1158 named.extend(
1159 SENSITIVE_SLOT_ENTRIES
1160 .iter()
1161 .filter(|entry| fs::symlink_metadata(slot.join(entry)).is_ok())
1162 .map(|entry| format!("`{entry}`")),
1163 );
1164 if residue == 0 && named.is_empty() {
1165 return Ok(());
1166 }
1167 named.sort_unstable();
1168 named.dedup();
1169 Err(SlotQuarantine::new(
1170 SlotRefusal::Residue,
1171 residue_detail(slot, residue, &named),
1172 ))
1173}
1174
1175fn residue_detail(slot: &Path, residue: usize, named: &[String]) -> String {
1185 if residue == 0 {
1186 format!(
1190 "the listing of {} reported nothing but `{DEFAULT_WORK_FOLDER}`, yet {} survived \
1191 cleanup",
1192 slot.display(),
1193 named.join(", ")
1194 )
1195 } else {
1196 format!(
1197 "{residue} entr{} other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}{}",
1198 if residue == 1 { "y" } else { "ies" },
1199 slot.display(),
1200 if named.is_empty() {
1201 String::new()
1202 } else {
1203 format!(", including {}", named.join(", "))
1204 }
1205 )
1206 }
1207}
1208
1209fn replacement_operation(outcome: &AttemptOutcome) -> Option<&'static str> {
1210 match outcome {
1211 AttemptOutcome::Failed {
1212 reason: FailureReason::JitExpired,
1213 } => Some("jit_expired_replacement"),
1214 AttemptOutcome::Failed {
1215 reason: FailureReason::ProcessExitedUnexpectedly,
1216 } => Some("exit_before_acceptance_replacement"),
1217 _ => None,
1218 }
1219}
1220
1221fn copy_package_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
1242 refuse_work_folder(source)?;
1243
1244 #[cfg(unix)]
1245 {
1246 let origin = package_seed(source, destination)
1250 .ok()
1251 .filter(|seed| refuse_work_folder(seed).is_ok())
1252 .unwrap_or_else(|| source.to_path_buf());
1253 clone_tree(&origin, destination)
1254 }
1255 #[cfg(not(unix))]
1256 copy_package_entries(source, destination, true)
1257}
1258
1259fn refuse_work_folder(source: &Path) -> std::io::Result<()> {
1260 if source.join(DEFAULT_WORK_FOLDER).exists() {
1261 return Err(std::io::Error::new(
1262 std::io::ErrorKind::InvalidData,
1263 "a cached runner package contains a _work folder, which means it was used \
1264 to run a job before it was archived; the cache must only contain clean \
1265 extracts to prevent data leakage",
1266 ));
1267 }
1268 Ok(())
1269}
1270
1271#[cfg(unix)]
1273const PACKAGE_SEED_DIR: &str = ".runner-package";
1274
1275#[cfg(unix)]
1293fn package_seed(source: &Path, destination: &Path) -> std::io::Result<PathBuf> {
1294 use std::os::unix::fs::MetadataExt as _;
1295
1296 if !cfg!(target_os = "macos") {
1297 return Ok(source.to_path_buf());
1298 }
1299 let root = destination
1300 .parent()
1301 .ok_or_else(|| std::io::Error::other("a runtime directory has no parent"))?;
1302 if fs::metadata(source)?.dev() == fs::metadata(root)?.dev() {
1303 return Ok(source.to_path_buf());
1304 }
1305 refresh_package_seed(source, &root.join(PACKAGE_SEED_DIR))
1306}
1307
1308#[cfg(unix)]
1310fn refresh_package_seed(source: &Path, seeds: &Path) -> std::io::Result<PathBuf> {
1311 use crate::package::MANIFEST_FILE;
1312
1313 let version = source
1314 .file_name()
1315 .ok_or_else(|| std::io::Error::other("a cache entry has no name"))?;
1316 let seed = seeds.join(version);
1317 let manifest = fs::read(source.join(MANIFEST_FILE))?;
1318 if fs::read(seed.join(MANIFEST_FILE)).is_ok_and(|held| held == manifest) {
1319 return Ok(seed);
1320 }
1321
1322 match remove_runtime_tree(seeds) {
1324 Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error),
1325 _ => {}
1326 }
1327 fs::create_dir_all(seeds)?;
1328 let staging = seeds.join(format!(".staging-{}", uuid::Uuid::new_v4()));
1329 fs::create_dir(&staging)?;
1330 if let Err(error) = clone_tree(source, &staging).and_then(|()| fs::rename(&staging, &seed)) {
1332 let _ = remove_runtime_tree(&staging);
1333 return Err(error);
1334 }
1335 Ok(seed)
1336}
1337
1338#[cfg(unix)]
1343fn clone_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
1344 #[cfg(target_os = "macos")]
1345 const CLONE: &[&str] = &["-c"];
1346 #[cfg(target_os = "linux")]
1347 const CLONE: &[&str] = &["--reflink=auto"];
1348 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1349 const CLONE: &[&str] = &[];
1350
1351 let from = format!("{}/.", source.display());
1352 let cp = |extra: &[&str]| {
1353 std::process::Command::new("cp")
1354 .arg("-a")
1355 .args(extra)
1356 .arg(&from)
1357 .arg(destination)
1358 .status()
1359 };
1360 if cp(CLONE)?.success() || cp(&[])?.success() {
1361 Ok(())
1362 } else {
1363 Err(std::io::Error::other("cp failed"))
1364 }
1365}
1366
1367#[cfg(not(unix))]
1368fn copy_package_entries(source: &Path, destination: &Path, top_level: bool) -> std::io::Result<()> {
1369 fs::create_dir_all(destination)?;
1370 for entry in fs::read_dir(source)? {
1371 let entry = entry?;
1372 if top_level && is_work_folder(&entry.file_name()) {
1373 return Err(std::io::Error::new(
1374 std::io::ErrorKind::InvalidData,
1375 format!(
1376 "the runner package holds a top-level `{DEFAULT_WORK_FOLDER}`; copying \
1377 it would overwrite the job workspace a persistent slot retains"
1378 ),
1379 ));
1380 }
1381 let target = destination.join(entry.file_name());
1382 if entry.file_type()?.is_dir() {
1383 copy_package_entries(&entry.path(), &target, false)?;
1384 } else {
1385 fs::copy(entry.path(), target)?;
1386 }
1387 }
1388 Ok(())
1389}
1390
1391#[derive(Debug, Clone, PartialEq, Eq)]
1394pub struct ProcessStartFailure {
1395 pub reason: FailureReason,
1396 pub retryable: bool,
1399 pub live_pid: Option<u32>,
1402}
1403
1404impl ProcessStartFailure {
1405 fn before_spawn(reason: FailureReason) -> Self {
1406 Self {
1407 reason,
1408 retryable: true,
1409 live_pid: None,
1410 }
1411 }
1412
1413 fn after_spawn_stopped() -> Self {
1414 Self {
1415 reason: FailureReason::ProcessStartFailed,
1416 retryable: false,
1417 live_pid: None,
1418 }
1419 }
1420
1421 fn after_spawn_live(pid: u32) -> Self {
1422 Self::after_spawn_live_with_reason(pid, FailureReason::ProcessStartFailed)
1423 }
1424
1425 fn after_spawn_live_with_reason(pid: u32, reason: FailureReason) -> Self {
1426 Self {
1427 reason,
1428 retryable: false,
1429 live_pid: Some(pid),
1430 }
1431 }
1432}
1433
1434pub trait ProcessSupervisor: fmt::Debug + Send + Sync {
1435 fn spawn(
1436 &self,
1437 attempt: &RunnerAttempt,
1438 config: &EncodedJitConfig,
1439 ) -> Result<u32, ProcessStartFailure>;
1440 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason>;
1441 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason>;
1444 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool;
1447 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
1448 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool;
1449 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
1450}
1451
1452#[derive(Debug, Default)]
1455pub struct NativeProcesses {
1456 children: Mutex<BTreeMap<AttemptId, ChildProcess>>,
1457 successful_exits: Mutex<BTreeMap<AttemptId, bool>>,
1458 #[cfg(test)]
1459 post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
1460 #[cfg(test)]
1461 post_spawn_reaps: std::sync::atomic::AtomicUsize,
1462 #[cfg(test)]
1463 post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
1464 #[cfg(test)]
1465 use_long_lived_test_listener: std::sync::atomic::AtomicBool,
1466}
1467
1468#[cfg(test)]
1469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1470enum PostSpawnBoundary {
1471 HandoffDelete,
1472 IdentitySerialize,
1473 IdentityWrite,
1474 ChildMapInsert,
1475}
1476
1477impl NativeProcesses {
1478 #[must_use]
1479 pub fn new() -> Self {
1480 Self::default()
1481 }
1482
1483 #[cfg(test)]
1484 fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
1485 self.post_spawn_faults.lock().unwrap().push_back(boundary);
1486 }
1487
1488 #[cfg(test)]
1489 fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
1490 let mut faults = self.post_spawn_faults.lock().unwrap();
1491 if faults.front() == Some(&boundary) {
1492 faults.pop_front();
1493 true
1494 } else {
1495 false
1496 }
1497 }
1498
1499 #[cfg(test)]
1500 fn fail_post_spawn_stops(&self, count: usize) {
1501 self.post_spawn_stop_failures
1502 .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
1503 }
1504
1505 #[cfg(test)]
1506 fn fail_next_post_spawn_stop(&self) {
1507 self.fail_post_spawn_stops(1);
1508 }
1509
1510 #[cfg(test)]
1511 fn use_long_lived_test_listener(&self) {
1512 self.use_long_lived_test_listener
1513 .store(true, std::sync::atomic::Ordering::SeqCst);
1514 }
1515
1516 fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
1517 #[cfg(test)]
1518 if self
1519 .post_spawn_stop_failures
1520 .fetch_update(
1521 std::sync::atomic::Ordering::SeqCst,
1522 std::sync::atomic::Ordering::SeqCst,
1523 |left| if left > 0 { Some(left - 1) } else { None },
1524 )
1525 .is_ok()
1526 {
1527 return Err(FailureReason::Other("injected runner stop failure".into()));
1528 }
1529 child
1530 .stop(Duration::from_secs(1))
1531 .map(|_| ())
1532 .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
1533 }
1534
1535 fn abort_spawned_child(
1536 &self,
1537 mut child: ChildProcess,
1538 attempt: &RunnerAttempt,
1539 remove_identity: bool,
1540 ) -> ProcessStartFailure {
1541 let mut reaped = self.stop_spawned_child(&mut child).is_ok();
1542 if reaped {
1543 if remove_identity {
1544 Self::remove_identity_files(attempt);
1545 }
1546 } else {
1547 let identity_durable =
1550 serde_json::to_vec(child.identity())
1551 .ok()
1552 .is_some_and(|identity| {
1553 self.persist_identity(attempt, &identity).is_ok()
1554 || self.persist_fallback_identity(attempt, &identity).is_ok()
1555 });
1556 if !identity_durable {
1557 for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
1562 if self.stop_spawned_child(&mut child).is_ok() {
1563 reaped = true;
1564 break;
1565 }
1566 }
1567 if !reaped {
1568 let pid = child.pid();
1573 let marker = write_durable_file(
1574 &Self::unresolved_process_path(attempt),
1575 pid.to_string().as_bytes(),
1576 );
1577 self.children
1578 .lock()
1579 .unwrap_or_else(std::sync::PoisonError::into_inner)
1580 .insert(attempt.id, child);
1581 let reason = if marker.is_ok() {
1582 FailureReason::Other(
1583 "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
1584 .into(),
1585 )
1586 } else {
1587 FailureReason::Other(
1588 "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
1589 .into(),
1590 )
1591 };
1592 return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
1593 }
1594 } else {
1595 let pid = child.pid();
1596 self.children
1597 .lock()
1598 .unwrap_or_else(std::sync::PoisonError::into_inner)
1599 .insert(attempt.id, child);
1600 return ProcessStartFailure::after_spawn_live(pid);
1601 }
1602 }
1603 #[cfg(test)]
1604 if reaped {
1605 self.post_spawn_reaps
1606 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1607 }
1608 #[cfg(not(test))]
1609 let _ = reaped;
1610 ProcessStartFailure::after_spawn_stopped()
1611 }
1612
1613 fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
1614 attempt.runtime_path().join(IDENTITY_FILE)
1615 }
1616
1617 fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
1618 attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
1619 }
1620
1621 fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
1622 attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
1623 }
1624
1625 fn remove_identity_files(attempt: &RunnerAttempt) {
1626 let _ = fs::remove_file(Self::identity_path(attempt));
1627 let _ = fs::remove_file(Self::fallback_identity_path(attempt));
1628 let _ = fs::remove_file(Self::unresolved_process_path(attempt));
1629 }
1630
1631 fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
1632 self.persist_identity_at(&Self::identity_path(attempt), bytes)
1633 }
1634
1635 fn persist_fallback_identity(
1636 &self,
1637 attempt: &RunnerAttempt,
1638 bytes: &[u8],
1639 ) -> std::io::Result<()> {
1640 self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
1641 }
1642
1643 fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1644 #[cfg(test)]
1645 if self.faults_at(PostSpawnBoundary::IdentityWrite) {
1646 return Err(std::io::Error::other("injected identity write failure"));
1647 }
1648 write_durable_file(path, bytes)
1649 }
1650
1651 fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
1652 attempt.runtime_path().join(TERMINATE_INTENT_FILE)
1653 }
1654
1655 fn read_identity(attempt: &RunnerAttempt) -> Result<Option<ProcessIdentity>, FailureReason> {
1656 match Self::read_identity_at(&Self::identity_path(attempt))? {
1657 Some(identity) => Ok(Some(identity)),
1658 None => Self::read_identity_at(&Self::fallback_identity_path(attempt)),
1659 }
1660 }
1661
1662 fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
1663 match fs::read(path) {
1664 Ok(bytes) => serde_json::from_slice(&bytes)
1665 .map(Some)
1666 .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
1667 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1668 Err(_) => Err(FailureReason::Other(
1669 "process identity journal could not be read".into(),
1670 )),
1671 }
1672 }
1673}
1674
1675impl ProcessSupervisor for NativeProcesses {
1676 fn spawn(
1677 &self,
1678 attempt: &RunnerAttempt,
1679 config: &EncodedJitConfig,
1680 ) -> Result<u32, ProcessStartFailure> {
1681 let handoff = RestrictiveHandoff::create(
1682 attempt.runtime_path(),
1683 SecretString::from(config.expose().to_owned()),
1684 )
1685 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
1686 #[cfg(windows)]
1687 let program = attempt
1688 .runtime_path()
1689 .join("bin")
1690 .join("Runner.Listener.exe");
1691 #[cfg(not(windows))]
1692 let program = attempt.runtime_path().join("bin").join("Runner.Listener");
1693 if !program.is_file() {
1696 return Err(ProcessStartFailure::before_spawn(
1697 FailureReason::ProcessStartFailed,
1698 ));
1699 }
1700 #[cfg(test)]
1701 let spec = if self
1702 .use_long_lived_test_listener
1703 .load(std::sync::atomic::Ordering::SeqCst)
1704 {
1705 SpawnSpec::new(program)
1706 .args([
1707 "--ignored",
1708 "--exact",
1709 "lifecycle::tests::long_lived_native_listener_helper",
1710 "--nocapture",
1711 ])
1712 .env(
1713 "RUNNER_MANAGER_TEST_LISTENER_READY",
1714 attempt.runtime_path().join(TEST_LISTENER_READY),
1715 )
1716 .working_dir(attempt.runtime_path())
1717 } else {
1718 runner_listener_spec(program, attempt.runtime_path())
1719 };
1720 #[cfg(not(test))]
1721 let spec = runner_listener_spec(program, attempt.runtime_path());
1722 let child = spec
1723 .spawn_runner_with_handoff(&handoff)
1724 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
1725 #[cfg(test)]
1727 if self.faults_at(PostSpawnBoundary::HandoffDelete) {
1728 drop(handoff);
1729 return Err(self.abort_spawned_child(child, attempt, false));
1730 }
1731 if handoff.delete().is_err() {
1732 return Err(self.abort_spawned_child(child, attempt, false));
1733 }
1734 #[cfg(test)]
1735 if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
1736 return Err(self.abort_spawned_child(child, attempt, false));
1737 }
1738 let identity = match serde_json::to_vec(child.identity()) {
1739 Ok(identity) => identity,
1740 Err(_) => {
1741 return Err(self.abort_spawned_child(child, attempt, false));
1742 }
1743 };
1744 if self.persist_identity(attempt, &identity).is_err() {
1745 return Err(self.abort_spawned_child(child, attempt, true));
1746 }
1747 let pid = child.pid();
1748 #[cfg(test)]
1749 if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
1750 return Err(self.abort_spawned_child(child, attempt, true));
1751 }
1752 let mut children = self
1753 .children
1754 .lock()
1755 .unwrap_or_else(std::sync::PoisonError::into_inner);
1756 children.insert(attempt.id, child);
1757 Ok(pid)
1758 }
1759
1760 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
1761 let mut children = self
1762 .children
1763 .lock()
1764 .unwrap_or_else(std::sync::PoisonError::into_inner);
1765 if let Some(child) = children.get_mut(&attempt.id) {
1766 return match child
1767 .try_exit_status()
1768 .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
1769 {
1770 None => Ok(true),
1771 Some(status) => {
1772 if let Ok(mut exits) = self.successful_exits.lock() {
1773 exits.insert(attempt.id, status.success());
1774 }
1775 Ok(false)
1776 }
1777 };
1778 }
1779 let Some(identity) = Self::read_identity(attempt)? else {
1780 if attempt.process_id().is_some() || Self::unresolved_process_path(attempt).is_file() {
1781 return Err(FailureReason::Other(
1782 "runner process identity is missing; refusing recovery until the process is resolved"
1783 .into(),
1784 ));
1785 }
1786 return Ok(false);
1787 };
1788 match identity.recheck() {
1789 Ok(Adoption::Live) => Ok(true),
1790 Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
1791 Err(_) => Ok(false),
1792 }
1793 }
1794
1795 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
1796 Ok(Self::read_identity(attempt)?.map(|identity| identity.pid()))
1797 }
1798
1799 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
1800 self.successful_exits
1801 .lock()
1802 .ok()
1803 .and_then(|exits| exits.get(&attempt.id).copied())
1804 .unwrap_or(false)
1805 }
1806
1807 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
1808 let path = Self::intent_path(attempt);
1809 write_durable_file(&path, b"registration-timeout\n")
1810 .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
1811 }
1812
1813 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
1814 Self::intent_path(attempt).is_file()
1815 }
1816
1817 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
1818 let mut children = self
1819 .children
1820 .lock()
1821 .unwrap_or_else(std::sync::PoisonError::into_inner);
1822 if let Some(child) = children.get_mut(&attempt.id) {
1823 child
1824 .stop(Duration::from_secs(10))
1825 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
1826 return Ok(());
1827 }
1828 let Some(identity) = Self::read_identity(attempt)? else {
1829 return Ok(());
1830 };
1831 match identity
1832 .terminate(Duration::from_secs(10))
1833 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
1834 {
1835 Termination::Terminated | Termination::AlreadyGone => Ok(()),
1836 Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
1837 "runner PID was recycled; refusing to signal it".into(),
1838 )),
1839 }
1840 }
1841}
1842
1843pub struct LifecyclePorts {
1844 pub store: Arc<dyn Store>,
1845 pub github: Arc<dyn LifecycleGithub>,
1846 pub packages: Arc<dyn RuntimePackages>,
1847 pub processes: Arc<dyn ProcessSupervisor>,
1848 pub clock: Arc<dyn Clock>,
1849 pub demand: Arc<dyn DemandPersistence>,
1850 pub delay: Arc<dyn RetryDelay>,
1851 pub events: Arc<dyn AttemptEventSink>,
1852 pub reconcile_events: Arc<dyn EventSink>,
1853}
1854
1855impl fmt::Debug for LifecyclePorts {
1856 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1857 f.debug_struct("LifecyclePorts")
1858 .field("store", &self.store)
1859 .field("github", &self.github)
1860 .field("packages", &self.packages)
1861 .field("processes", &self.processes)
1862 .finish_non_exhaustive()
1863 }
1864}
1865
1866#[derive(Debug, thiserror::Error)]
1867pub enum LifecycleError {
1868 #[error("attempt journal operation failed")]
1869 Journal,
1870 #[error("attempt {0} is not in the journal")]
1871 Missing(AttemptId),
1872 #[error("attempt lifecycle transition was refused")]
1873 Transition,
1874 #[error("startup recovery has not completed")]
1875 RecoveryIncomplete,
1876 #[error("the persistent slot was not cleaned: {detail}")]
1886 SlotQuarantined {
1887 class: &'static str,
1889 detail: String,
1890 },
1891 #[error("attempt workspace could not be removed")]
1895 WorkspaceCleanupDeferred,
1896 #[error("runner lifecycle failed: {0}")]
1897 Failed(FailureReason),
1898}
1899
1900impl LifecycleError {
1901 fn reason(&self) -> FailureReason {
1902 match self {
1903 Self::Failed(reason) => reason.clone(),
1904 Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
1905 Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
1906 Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
1907 Self::Transition => FailureReason::Other("attempt transition was refused".into()),
1908 Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
1911 Self::WorkspaceCleanupDeferred => FailureReason::Other(self.to_string()),
1912 }
1913 }
1914}
1915
1916#[derive(Debug)]
1918pub struct LifecycleLauncher {
1919 host_id: HostId,
1920 app_paths: runner_manager_platform::paths::AppPaths,
1921 diagnostics_root: PathBuf,
1922 runner_group_id: u64,
1923 timeouts: RecoveryTimeouts,
1924 retry: RetryPolicy,
1925 cancel: CancelToken,
1926 ports: LifecyclePorts,
1927 recovery_complete: Mutex<bool>,
1928 versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
1929 pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
1930}
1931
1932#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1933enum ReconcileProgress {
1934 Reconciled,
1935 Deferred,
1936 Replacement {
1937 attempt: AttemptId,
1938 operation: &'static str,
1939 },
1940}
1941
1942impl LifecycleLauncher {
1943 #[must_use]
1944 pub fn new(
1945 host_id: HostId,
1946 app_paths: runner_manager_platform::paths::AppPaths,
1947 diagnostics_root: impl Into<PathBuf>,
1948 runner_group_id: u64,
1949 timeouts: RecoveryTimeouts,
1950 retry: RetryPolicy,
1951 ports: LifecyclePorts,
1952 ) -> Self {
1953 Self {
1954 host_id,
1955 app_paths,
1956 diagnostics_root: diagnostics_root.into(),
1957 runner_group_id,
1958 timeouts,
1959 retry,
1960 cancel: CancelToken::new(),
1961 ports,
1962 recovery_complete: Mutex::new(false),
1963 versions: Mutex::new(BTreeMap::new()),
1964 pending_replacements: Mutex::new(BTreeMap::new()),
1965 }
1966 }
1967
1968 pub async fn recover_startup(
1972 &self,
1973 policies: &[ScalePolicy],
1974 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
1975 let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
1976 let attempts = self
1977 .ports
1978 .store
1979 .attempts()
1980 .map_err(|_| LifecycleError::Journal)?;
1981 let mut unresolved = false;
1982 for attempt in attempts {
1983 let Some(policy) = by_id.get(&attempt.policy_id) else {
1984 if !attempt.is_terminal() && attempt.state() != AttemptState::Cleaned {
1985 unresolved = true;
1986 }
1987 continue;
1988 };
1989 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
1990 match self.reconcile_one(policy, attempt).await? {
1991 ReconcileProgress::Deferred => unresolved = true,
1992 ReconcileProgress::Replacement { attempt, operation } => {
1993 self.pending_replacements
1994 .lock()
1995 .map_err(|_| LifecycleError::Journal)?
1996 .insert(
1997 attempt,
1998 ReplacementIntent {
1999 policy: policy.id,
2000 previous_attempt: attempt,
2001 operation,
2002 },
2003 );
2004 }
2005 ReconcileProgress::Reconciled => {}
2006 }
2007 }
2008 if unresolved {
2009 return Err(LifecycleError::RecoveryIncomplete);
2010 }
2011 *self
2012 .recovery_complete
2013 .lock()
2014 .map_err(|_| LifecycleError::Journal)? = true;
2015 Ok(self
2016 .pending_replacements
2017 .lock()
2018 .map_err(|_| LifecycleError::Journal)?
2019 .values()
2020 .copied()
2021 .collect())
2022 }
2023
2024 pub async fn supervise(
2026 &self,
2027 policy: &ScalePolicy,
2028 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
2029 let mut replacements = Vec::new();
2030 self.pending_replacements
2031 .lock()
2032 .map_err(|_| LifecycleError::Journal)?
2033 .retain(|_, intent| {
2034 if intent.policy == policy.id {
2035 replacements.push(*intent);
2036 false
2037 } else {
2038 true
2039 }
2040 });
2041 let attempts = self
2042 .ports
2043 .store
2044 .attempts_for_policy(policy.id)
2045 .map_err(|_| LifecycleError::Journal)?;
2046 for attempt in attempts {
2047 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
2048 if let ReconcileProgress::Replacement { attempt, operation } =
2049 self.reconcile_one(policy, attempt).await?
2050 {
2051 replacements.push(ReplacementIntent {
2052 policy: policy.id,
2053 previous_attempt: attempt,
2054 operation,
2055 });
2056 }
2057 }
2058 Ok(replacements)
2059 }
2060
2061 async fn reconcile_one(
2062 &self,
2063 policy: &ScalePolicy,
2064 mut attempt: RunnerAttempt,
2065 ) -> Result<ReconcileProgress, LifecycleError> {
2066 if attempt.state() == AttemptState::Cleaned {
2067 if matches!(attempt.workspace(), AttemptWorkspace::Ephemeral)
2076 && attempt.runtime_path().exists()
2077 {
2078 match self.scrub_workspace(&attempt) {
2084 Ok(()) => {}
2085 Err(LifecycleError::WorkspaceCleanupDeferred) => {
2086 self.ports
2087 .reconcile_events
2088 .emit(LifecycleEvent::AttemptCleanFailed {
2089 policy: attempt.policy_id,
2090 attempt: attempt.id,
2091 reason: "late_ephemeral_workspace_could_not_be_removed",
2092 });
2093 tracing::warn!(
2094 policy_id = %attempt.policy_id,
2095 attempt_id = %attempt.id,
2096 reason = "late_ephemeral_workspace_could_not_be_removed",
2097 "a cleaned ephemeral attempt left late workspace residue; cleanup will retry without blocking the daemon"
2098 );
2099 }
2100 Err(error) => return Err(error),
2101 }
2102 }
2103 return Ok(ReconcileProgress::Reconciled);
2104 }
2105 if attempt.is_terminal() {
2106 self.clean_or_quarantine(&mut attempt)?;
2107 return Ok(ReconcileProgress::Reconciled);
2108 }
2109 let process_alive = self
2110 .ports
2111 .processes
2112 .is_alive(&attempt)
2113 .map_err(LifecycleError::Failed)?;
2114 let github = self
2115 .ports
2116 .github
2117 .observe(&policy.target, attempt.id, &self.cancel)
2118 .await;
2119
2120 if let Some(runner_id) = github.runner_id
2125 && read_runner_id(attempt.runtime_path()).is_none()
2126 {
2127 write_runner_id(attempt.runtime_path(), runner_id)?;
2128 self.ports
2129 .events
2130 .emit(AttemptEvent::RemoteIdentityRecovered {
2131 attempt: attempt.id,
2132 runner_id,
2133 });
2134 }
2135
2136 if attempt.state() == AttemptState::JitReceived
2141 && process_alive
2142 && let Some(pid) = self
2143 .ports
2144 .processes
2145 .recovered_pid(&attempt)
2146 .map_err(LifecycleError::Failed)?
2147 {
2148 attempt
2149 .started(pid, self.ports.clock.now())
2150 .map_err(|_| LifecycleError::Transition)?;
2151 self.record(&attempt)?;
2152 }
2153
2154 if attempt.state() == AttemptState::Busy
2159 && !process_alive
2160 && github.status == GithubRunnerObservation::NotRegistered
2161 && self.ports.processes.completed_successfully(&attempt)
2162 {
2163 self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
2164 self.clean_or_quarantine(&mut attempt)?;
2165 return Ok(ReconcileProgress::Reconciled);
2166 }
2167
2168 if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
2171 self.deregister_runner(policy, &attempt).await;
2172 self.conclude(
2173 &mut attempt,
2174 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
2175 )?;
2176 self.clean_or_quarantine(&mut attempt)?;
2177 return Ok(ReconcileProgress::Replacement {
2178 attempt: attempt.id,
2179 operation: "registration_timeout_replacement",
2180 });
2181 }
2182
2183 if matches!(
2189 attempt.state(),
2190 AttemptState::Allocated | AttemptState::JitReceived
2191 ) && !process_alive
2192 && matches!(github.status, GithubRunnerObservation::Registered { .. })
2193 {
2194 if attempt.state() == AttemptState::Allocated {
2195 attempt
2196 .jit_received(self.ports.clock.now())
2197 .map_err(|_| LifecycleError::Transition)?;
2198 self.record(&attempt)?;
2199 }
2200 self.deregister_runner(policy, &attempt).await;
2201 self.conclude(
2202 &mut attempt,
2203 AttemptOutcome::failed(FailureReason::JitExpired),
2204 )?;
2205 self.clean_or_quarantine(&mut attempt)?;
2206 return Ok(ReconcileProgress::Replacement {
2207 attempt: attempt.id,
2208 operation: "jit_expired_replacement",
2209 });
2210 }
2211
2212 match recovery_decision(
2213 &attempt,
2214 RecoveryObservation {
2215 process_alive,
2216 github: github.status,
2217 },
2218 self.timeouts,
2219 self.ports.clock.as_ref(),
2220 ) {
2221 RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
2222 RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
2223 RecoveryDecision::Adopt => {
2224 self.ports.events.emit(AttemptEvent::Adopted {
2225 attempt: attempt.id,
2226 });
2227 Ok(ReconcileProgress::Reconciled)
2228 }
2229 RecoveryDecision::Clean => {
2230 self.clean_or_quarantine(&mut attempt)?;
2231 Ok(ReconcileProgress::Reconciled)
2232 }
2233 RecoveryDecision::Observe(state) => {
2234 let runner_id = attempt
2235 .github_runner_id()
2236 .or(github.runner_id)
2237 .or_else(|| read_runner_id(attempt.runtime_path()))
2238 .ok_or(LifecycleError::Transition)?;
2239 match state {
2240 AttemptState::JitReceived => attempt
2241 .jit_received(self.ports.clock.now())
2242 .map_err(|_| LifecycleError::Transition)?,
2243 AttemptState::Starting => {
2244 let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
2245 attempt
2246 .started(pid, self.ports.clock.now())
2247 .map_err(|_| LifecycleError::Transition)?;
2248 }
2249 AttemptState::Idle => attempt
2250 .registered_idle(runner_id, self.ports.clock.now())
2251 .map_err(|_| LifecycleError::Transition)?,
2252 AttemptState::Busy => attempt
2253 .assigned_job(runner_id, self.ports.clock.now())
2254 .map_err(|_| LifecycleError::Transition)?,
2255 _ => return Err(LifecycleError::Transition),
2256 }
2257 self.record(&attempt)?;
2258 Ok(ReconcileProgress::Reconciled)
2259 }
2260 RecoveryDecision::Conclude(outcome) => {
2261 let replacement = replacement_operation(&outcome);
2262 if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
2267 self.deregister_runner(policy, &attempt).await;
2268 }
2269 self.conclude(&mut attempt, outcome)?;
2270 self.clean_or_quarantine(&mut attempt)?;
2271 Ok(
2272 replacement.map_or(ReconcileProgress::Reconciled, |operation| {
2273 ReconcileProgress::Replacement {
2274 attempt: attempt.id,
2275 operation,
2276 }
2277 }),
2278 )
2279 }
2280 RecoveryDecision::Terminate(payload) => {
2281 let idle_exit = payload.is_idle_exit();
2291 self.ports
2292 .processes
2293 .record_terminate_intent(&attempt)
2294 .map_err(LifecycleError::Failed)?;
2295 self.ports.events.emit(AttemptEvent::TerminateIntent {
2296 attempt: attempt.id,
2297 });
2298 self.ports
2299 .processes
2300 .terminate(&attempt)
2301 .map_err(LifecycleError::Failed)?;
2302 if self
2303 .ports
2304 .processes
2305 .is_alive(&attempt)
2306 .map_err(LifecycleError::Failed)?
2307 {
2308 return Ok(ReconcileProgress::Deferred);
2309 }
2310 self.ports.events.emit(AttemptEvent::Terminated {
2311 attempt: attempt.id,
2312 });
2313 let outcome = if idle_exit {
2319 AttemptOutcome::ExitedIdleWithoutWork
2320 } else {
2321 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
2322 };
2323 self.deregister_runner(policy, &attempt).await;
2324 self.conclude(&mut attempt, outcome)?;
2325 self.clean_or_quarantine(&mut attempt)?;
2326 if idle_exit {
2330 Ok(ReconcileProgress::Reconciled)
2331 } else {
2332 Ok(ReconcileProgress::Replacement {
2333 attempt: attempt.id,
2334 operation: "registration_timeout_replacement",
2335 })
2336 }
2337 }
2338 }
2339 }
2340
2341 fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2342 self.ports
2343 .store
2344 .record_attempt(attempt)
2345 .map_err(|_| LifecycleError::Journal)?;
2346 self.ports.events.emit(AttemptEvent::State {
2347 attempt: attempt.id,
2348 state: attempt.state(),
2349 });
2350 Ok(())
2351 }
2352
2353 async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
2374 let Some(runner_id) = attempt
2375 .github_runner_id()
2376 .or_else(|| read_runner_id(attempt.runtime_path()))
2377 else {
2378 return;
2379 };
2380 if self
2381 .ports
2382 .github
2383 .deregister(&policy.target, runner_id, &self.cancel)
2384 .await
2385 {
2386 self.ports.events.emit(AttemptEvent::Deregistered {
2387 attempt: attempt.id,
2388 runner_id,
2389 });
2390 } else {
2391 tracing::warn!(
2392 attempt = %attempt.id,
2393 runner_id,
2394 "the runner registration could not be removed from GitHub; it will show in the \
2395 target's runner settings until GitHub retires it or a later pass removes it"
2396 );
2397 }
2398 }
2399
2400 fn conclude(
2401 &self,
2402 attempt: &mut RunnerAttempt,
2403 outcome: AttemptOutcome,
2404 ) -> Result<(), LifecycleError> {
2405 attempt
2406 .conclude(outcome.clone(), self.ports.clock.now())
2407 .map_err(|_| LifecycleError::Transition)?;
2408 self.record(attempt)?;
2409 self.ports.events.emit(AttemptEvent::Concluded {
2410 attempt: attempt.id,
2411 outcome: OutcomeKind::of(&outcome),
2412 });
2413 Ok(())
2414 }
2415
2416 fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
2432 match self.clean_attempt(attempt) {
2433 Err(LifecycleError::SlotQuarantined { class, .. }) => {
2434 self.ports
2435 .reconcile_events
2436 .emit(LifecycleEvent::AttemptCleanFailed {
2437 policy: attempt.policy_id,
2438 attempt: attempt.id,
2439 reason: class,
2440 });
2441 Ok(())
2442 }
2443 Err(LifecycleError::WorkspaceCleanupDeferred) => {
2444 self.ports
2445 .reconcile_events
2446 .emit(LifecycleEvent::AttemptCleanFailed {
2447 policy: attempt.policy_id,
2448 attempt: attempt.id,
2449 reason: "ephemeral_workspace_could_not_be_removed",
2450 });
2451 Ok(())
2452 }
2453 other => other,
2454 }
2455 }
2456
2457 fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
2458 let outcome = attempt
2459 .outcome()
2460 .cloned()
2461 .ok_or(LifecycleError::Transition)?;
2462 self.preserve_diagnostics(attempt, &outcome)?;
2463 self.scrub_workspace(attempt)?;
2464 self.ports
2465 .packages
2466 .release(attempt.id)
2467 .map_err(LifecycleError::Failed)?;
2468 attempt
2469 .clean(self.ports.clock.now())
2470 .map_err(|_| LifecycleError::Transition)?;
2471 self.record(attempt)?;
2472 let kind = OutcomeKind::of(&outcome);
2473 self.ports.events.emit(AttemptEvent::Cleaned {
2474 attempt: attempt.id,
2475 outcome: kind,
2476 });
2477 self.ports
2478 .reconcile_events
2479 .emit(LifecycleEvent::AttemptCleaned {
2480 policy: attempt.policy_id,
2481 attempt: attempt.id,
2482 outcome: kind,
2483 });
2484 Ok(())
2485 }
2486
2487 fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2497 #[cfg(test)]
2498 {
2499 if matches!(
2503 std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
2504 Ok("skip_workspace_cleanup" | "reuse_job_workspace")
2505 ) {
2506 return Ok(());
2507 }
2508 }
2509 match attempt.workspace() {
2510 AttemptWorkspace::Ephemeral => match remove_runtime_tree(attempt.runtime_path()) {
2511 Ok(()) => Ok(()),
2512 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2513 Err(_) => Err(LifecycleError::WorkspaceCleanupDeferred),
2514 },
2515 AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
2516 }
2517 }
2518
2519 fn scrub_persistent_slot(
2532 &self,
2533 attempt: &RunnerAttempt,
2534 slot: NonZeroU16,
2535 ) -> Result<(), LifecycleError> {
2536 let configured = self
2541 .ports
2542 .store
2543 .policy(attempt.policy_id)
2544 .map_err(|_| LifecycleError::Journal)?
2545 .and_then(|policy| match policy.workspace_policy() {
2546 WorkspacePolicy::Persistent { root } => Some(root.clone()),
2547 WorkspacePolicy::Ephemeral => None,
2548 });
2549 let runtime = attempt.runtime_path();
2550 self.quarantine_on_refusal(
2551 attempt,
2552 verify_journalled_slot(runtime, slot, configured.as_ref())
2553 .and_then(|()| slot_is_present(runtime))
2554 .and_then(|present| {
2555 if present {
2556 scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
2557 } else {
2558 Ok(())
2559 }
2560 }),
2561 )
2562 }
2563
2564 fn quarantine_on_refusal(
2573 &self,
2574 attempt: &RunnerAttempt,
2575 outcome: Result<(), SlotQuarantine>,
2576 ) -> Result<(), LifecycleError> {
2577 let Err(quarantine) = outcome else {
2578 return Ok(());
2579 };
2580 let detail = quarantine.to_string();
2581 tracing::warn!(
2582 attempt = %attempt.id,
2583 policy = %attempt.policy_id,
2584 slot = attempt.workspace().slot_number(),
2585 refusal = quarantine.refusal.class(),
2586 "{detail}"
2587 );
2588 Err(LifecycleError::SlotQuarantined {
2589 class: quarantine.refusal.class(),
2590 detail,
2591 })
2592 }
2593
2594 fn preserve_diagnostics(
2595 &self,
2596 attempt: &RunnerAttempt,
2597 outcome: &AttemptOutcome,
2598 ) -> Result<(), LifecycleError> {
2599 fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
2600 LifecycleError::Failed(FailureReason::Other(
2601 "diagnostics directory could not be created".into(),
2602 ))
2603 })?;
2604 let diagnostic = format!(
2607 "attempt_id={}\npolicy_id={}\noutcome={}\n",
2608 attempt.id,
2609 attempt.policy_id,
2610 OutcomeKind::of(outcome).as_str()
2611 );
2612 fs::write(
2613 self.diagnostics_root.join(format!("{}.log", attempt.id)),
2614 diagnostic,
2615 )
2616 .map_err(|_| {
2617 LifecycleError::Failed(FailureReason::Other(
2618 "redacted diagnostics could not be preserved".into(),
2619 ))
2620 })
2621 }
2622
2623 async fn materialize_with_retry(
2624 &self,
2625 policy: &ScalePolicy,
2626 attempt: &RunnerAttempt,
2627 ) -> Result<RunnerVersion, FailureReason> {
2628 let mut issued = 0_u32;
2629 loop {
2630 issued = issued.saturating_add(1);
2631 match self.ports.packages.materialize(attempt).await {
2632 Ok(version) => return Ok(version),
2633 Err(reason)
2634 if package_failure_is_terminal(&reason)
2635 || issued >= self.retry.max_attempts.max(1) =>
2636 {
2637 return Err(reason);
2638 }
2639 Err(reason) => {
2640 if !self.ports.demand.persists(policy.id).await {
2641 return Err(reason);
2642 }
2643 let delay = self.retry.delay(issued);
2644 self.ports.events.emit(AttemptEvent::Retry {
2645 attempt: attempt.id,
2646 operation: "package_materialization",
2647 delay,
2648 });
2649 self.ports.delay.wait(delay).await;
2650 if !self.ports.demand.persists(policy.id).await {
2651 return Err(reason);
2652 }
2653 }
2654 }
2655 }
2656 }
2657
2658 async fn register_with_retry(
2659 &self,
2660 policy: &ScalePolicy,
2661 attempt: AttemptId,
2662 request: &JitRunnerRequest,
2663 ) -> Result<JitRegistration, LifecycleError> {
2664 let mut issued = 0_u32;
2665 loop {
2666 issued = issued.saturating_add(1);
2667 match self
2668 .ports
2669 .github
2670 .register(&policy.target, request, &self.cancel)
2671 .await
2672 {
2673 Ok(registration) => return Ok(registration),
2674 Err(error) if error.terminal => {
2675 return Err(LifecycleError::Failed(error.reason));
2676 }
2677 Err(error) => {
2678 if issued >= self.retry.max_attempts.max(1)
2679 || !self.ports.demand.persists(policy.id).await
2680 {
2681 return Err(LifecycleError::Failed(error.reason));
2682 }
2683 let delay = error
2684 .retry_after
2685 .unwrap_or_else(|| self.retry.delay(issued));
2686 self.ports.events.emit(AttemptEvent::Retry {
2687 attempt,
2688 operation: "jit_request",
2689 delay,
2690 });
2691 self.ports.delay.wait(delay).await;
2692 if !self.ports.demand.persists(policy.id).await {
2693 return Err(LifecycleError::Failed(error.reason));
2694 }
2695 }
2696 }
2697 }
2698 }
2699
2700 fn allocate_workspace(
2710 &self,
2711 policy: &ScalePolicy,
2712 id: AttemptId,
2713 ) -> Result<Placement, LifecycleError> {
2714 let placement = match policy.workspace_policy() {
2715 WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
2720 WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
2721 };
2722 if placement.is_ok() {
2725 self.root_accepted(policy.id);
2726 }
2727 placement
2728 }
2729
2730 fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
2737 let host = self
2738 .ports
2739 .store
2740 .host(self.host_id)
2741 .map_err(|_| LifecycleError::Journal)?
2742 .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
2743 Ok(host.runner_root_override.clone())
2744 }
2745
2746 fn effective_host_root(
2751 &self,
2752 policy: &ScalePolicy,
2753 ) -> Result<LocalAbsolutePath, LifecycleError> {
2754 match self.configured_host_root()? {
2755 Some(configured) => Ok(configured),
2756 None => default_runner_root(&self.app_paths).map_err(|error| {
2757 self.root_refused(policy.id, "the platform default runner root", error)
2760 }),
2761 }
2762 }
2763
2764 fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
2777 let _ = runner_manager_platform::service::record_runner_root_refusal(
2778 &self.app_paths,
2779 &policy.to_string(),
2780 self.ports.clock.now(),
2781 error.kind(),
2782 root,
2783 &error.to_string(),
2784 );
2785 root_failure(error)
2786 }
2787
2788 fn root_accepted(&self, policy: PolicyId) {
2795 let _ = runner_manager_platform::service::clear_runner_root_refusal(
2796 &self.app_paths,
2797 &policy.to_string(),
2798 );
2799 }
2800
2801 fn allocate_disposable(
2804 &self,
2805 policy: &ScalePolicy,
2806 id: AttemptId,
2807 ) -> Result<Placement, LifecycleError> {
2808 let effective_root = self.effective_host_root(policy)?;
2809 RootPreflight::new(&self.app_paths)
2810 .check(&RootOwner::Host, &effective_root)
2811 .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
2812 let runtime = effective_root.as_path().join({
2813 #[cfg(test)]
2814 {
2815 if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
2816 == Ok("reuse_job_workspace")
2817 {
2818 "mutant-shared-workspace".to_owned()
2819 } else {
2820 workspace_name(id)
2821 }
2822 }
2823 #[cfg(not(test))]
2824 {
2825 workspace_name(id)
2826 }
2827 });
2828 fs::create_dir_all(&runtime)
2829 .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
2830 Ok(Placement {
2831 runtime,
2832 workspace: AttemptWorkspace::Ephemeral,
2833 })
2834 }
2835
2836 fn allocate_persistent_slot(
2848 &self,
2849 policy: &ScalePolicy,
2850 root: &LocalAbsolutePath,
2851 ) -> Result<Placement, LifecycleError> {
2852 let ceiling = policy.max_capacity().ok_or_else(|| {
2855 LifecycleError::Failed(FailureReason::Other(
2856 "a persistent workspace needs the policy's max_capacity to bound its slots"
2857 .to_string(),
2858 ))
2859 })?;
2860 let leases = self
2861 .ports
2862 .store
2863 .slot_leases_for_policy(policy.id)
2864 .map_err(|_| LifecycleError::Journal)?;
2865 let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
2866 LifecycleError::Failed(FailureReason::Other(format!(
2867 "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
2868 not been cleaned, so no slot is free; raise the repository's max capacity, or \
2869 finish cleaning a concluded attempt",
2870 policy.target
2871 )))
2872 })?;
2873 let workspace = AttemptWorkspace::persistent_slot(slot);
2874 let name = workspace
2875 .slot_directory_name()
2876 .expect("a persistent allocation names its slot directory");
2877
2878 let host_root = self
2890 .configured_host_root()?
2891 .or_else(|| default_runner_root(&self.app_paths).ok());
2892 let mut preflight = RootPreflight::new(&self.app_paths);
2893 if let Some(host_root) = host_root {
2894 preflight = preflight.against(RootOwner::Host, host_root);
2895 }
2896 let checked = preflight
2897 .check(&RootOwner::Repository(policy.target.to_string()), root)
2898 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
2899 if let Some(leaf) = checked.leaf_to_create() {
2900 fs::create_dir(leaf).map_err(|source| {
2901 LifecycleError::Failed(FailureReason::Other(format!(
2902 "the persistent workspace root {} could not be created: {source}",
2903 leaf.display()
2904 )))
2905 })?;
2906 }
2907
2908 let slot_path = runner_root::derive_child(root, &name)
2912 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
2913 create_or_validate_slot(slot_path.as_path())?;
2914 runner_root::verify_containment(root, &slot_path)
2915 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
2916 accept_reusable_slot(slot_path.as_path())?;
2917 Ok(Placement {
2918 runtime: slot_path.as_path().to_path_buf(),
2919 workspace,
2920 })
2921 }
2922
2923 fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2935 match self.ports.store.record_attempt(attempt) {
2936 Ok(()) => {
2937 self.ports.events.emit(AttemptEvent::State {
2938 attempt: attempt.id,
2939 state: attempt.state(),
2940 });
2941 Ok(())
2942 }
2943 Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
2944 FailureReason::Other(error.to_string()),
2945 )),
2946 Err(_) => Err(LifecycleError::Journal),
2947 }
2948 }
2949
2950 async fn launch_attempt(
2951 &self,
2952 policy: &ScalePolicy,
2953 allocation_guard: &AllocationGuard,
2954 ) -> Result<RunnerAttempt, LifecycleError> {
2955 if !*self
2956 .recovery_complete
2957 .lock()
2958 .map_err(|_| LifecycleError::Journal)?
2959 {
2960 return Err(LifecycleError::RecoveryIncomplete);
2961 }
2962 let labels = policy
2963 .routing_labels()
2964 .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
2965 let id = AttemptId::new_random();
2966 let placement = self.allocate_workspace(policy, id)?;
2967 let mut attempt = RunnerAttempt::allocate_in(
2968 id,
2969 policy.id,
2970 placement.runtime,
2971 placement.workspace,
2972 self.ports.clock.now(),
2973 );
2974 self.record_allocation(&attempt)?;
2979
2980 let started_at = std::time::Instant::now();
2981 let version = match self.materialize_with_retry(policy, &attempt).await {
2982 Ok(version) => version,
2983 Err(reason) => return self.fail_launch(&mut attempt, reason),
2984 };
2985 self.prune_under_allocation_lock(allocation_guard, &version)?;
2986 self.versions
2987 .lock()
2988 .map_err(|_| LifecycleError::Journal)?
2989 .insert(id, version);
2990 let materialized_at = std::time::Instant::now();
2991
2992 let jit_request =
2993 JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
2994 let registration = match self.register_with_retry(policy, id, &jit_request).await {
2995 Ok(registration) => registration,
2996 Err(error) => return self.fail_launch(&mut attempt, error.reason()),
2997 };
2998 let registered_at = std::time::Instant::now();
2999 let runner_id = registration.runner().id;
3000 write_runner_id(attempt.runtime_path(), runner_id)?;
3001 attempt
3002 .jit_received(self.ports.clock.now())
3003 .map_err(|_| LifecycleError::Transition)?;
3004 self.record(&attempt)?;
3005 let config = registration.into_config();
3006 let mut issued = 0_u32;
3007 let pid = loop {
3008 issued = issued.saturating_add(1);
3009 match self.ports.processes.spawn(&attempt, &config) {
3010 Ok(pid) => break pid,
3011 Err(error) => {
3012 if let Some(pid) = error.live_pid {
3013 attempt
3014 .started(pid, self.ports.clock.now())
3015 .map_err(|_| LifecycleError::Transition)?;
3016 self.record(&attempt)?;
3017 return Err(LifecycleError::Failed(error.reason));
3018 }
3019 if !error.retryable
3020 || issued >= self.retry.max_attempts.max(1)
3021 || !self.ports.demand.persists(policy.id).await
3022 {
3023 return self.fail_launch(&mut attempt, error.reason);
3024 }
3025 let delay = self.retry.delay(issued);
3026 self.ports.events.emit(AttemptEvent::Retry {
3027 attempt: attempt.id,
3028 operation: "process_start",
3029 delay,
3030 });
3031 self.ports.delay.wait(delay).await;
3032 if !self.ports.demand.persists(policy.id).await {
3033 return self.fail_launch(&mut attempt, error.reason);
3034 }
3035 }
3036 }
3037 };
3038 attempt
3039 .started(pid, self.ports.clock.now())
3040 .map_err(|_| LifecycleError::Transition)?;
3041 self.record(&attempt)?;
3042 self.ports.events.emit(AttemptEvent::Launched {
3043 attempt: attempt.id,
3044 materialize: materialized_at - started_at,
3045 register: registered_at - materialized_at,
3046 spawn: registered_at.elapsed(),
3047 });
3048 Ok(attempt)
3049 }
3050
3051 fn fail_launch<T>(
3052 &self,
3053 attempt: &mut RunnerAttempt,
3054 reason: FailureReason,
3055 ) -> Result<T, LifecycleError> {
3056 self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
3057 Err(LifecycleError::Failed(reason))
3058 }
3059
3060 fn prune_under_allocation_lock(
3063 &self,
3064 guard: &AllocationGuard,
3065 version: &RunnerVersion,
3066 ) -> Result<(), LifecycleError> {
3067 let attempts = self
3068 .ports
3069 .store
3070 .attempts()
3071 .map_err(|_| LifecycleError::Journal)?;
3072 self.ports
3073 .packages
3074 .prune_obsolete_guarded(
3075 PruneAuthority::from_launch_request(guard),
3076 version,
3077 &attempts,
3078 )
3079 .map_err(LifecycleError::Failed)
3080 }
3081}
3082
3083#[async_trait]
3084impl RunnerLauncher for LifecycleLauncher {
3085 async fn supervise(
3086 &self,
3087 policy: &ScalePolicy,
3088 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
3089 LifecycleLauncher::supervise(self, policy)
3090 .await
3091 .map_err(|error| LaunchFailure::new(error.reason()))
3092 }
3093
3094 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
3095 self.ports.store.attempts().map_err(|_| {
3096 LaunchFailure::new(FailureReason::Other(
3097 "attempt journal could not be read".into(),
3098 ))
3099 })
3100 }
3101
3102 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
3103 self.launch_attempt(request.policy, request.allocation_guard)
3104 .await
3105 .map_err(|error| LaunchFailure::new(error.reason()))
3106 }
3107
3108 async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
3109 let mut attempt = self
3110 .ports
3111 .store
3112 .attempt(id)
3113 .map_err(|_| {
3114 LaunchFailure::new(FailureReason::Other(
3115 "attempt journal could not be read".into(),
3116 ))
3117 })?
3118 .ok_or_else(|| {
3119 LaunchFailure::new(FailureReason::Other(
3120 "attempt disappeared from the journal".into(),
3121 ))
3122 })?;
3123 self.clean_attempt(&mut attempt)
3124 .map_err(|error| LaunchFailure::new(error.reason()))
3125 }
3126}
3127
3128fn runner_name(attempt: AttemptId) -> String {
3129 format!("runner-manager-{attempt}")
3130}
3131
3132fn read_runner_id(runtime: &Path) -> Option<u64> {
3133 fs::read_to_string(runtime.join(RUNNER_ID_FILE))
3134 .ok()?
3135 .trim()
3136 .parse()
3137 .ok()
3138}
3139
3140fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
3141 let target = runtime.join(RUNNER_ID_FILE);
3142 if let Some(existing) = read_runner_id(runtime) {
3143 return (existing == runner_id)
3144 .then_some(())
3145 .ok_or(LifecycleError::Journal);
3146 }
3147 let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
3148 write_durable_file(&temporary, runner_id.to_string().as_bytes())
3149 .map_err(|_| LifecycleError::Journal)?;
3150 match fs::rename(&temporary, &target) {
3151 Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
3152 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
3153 let _ = fs::remove_file(&temporary);
3154 (read_runner_id(runtime) == Some(runner_id))
3155 .then_some(())
3156 .ok_or(LifecycleError::Journal)
3157 }
3158 Err(_) => {
3159 let _ = fs::remove_file(&temporary);
3160 Err(LifecycleError::Journal)
3161 }
3162 }
3163}
3164
3165fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
3166 let mut file = fs::OpenOptions::new()
3167 .create(true)
3168 .truncate(true)
3169 .write(true)
3170 .open(path)?;
3171 file.write_all(bytes)?;
3172 file.sync_all()?;
3173 let parent = path.parent().ok_or_else(|| {
3174 std::io::Error::new(
3175 std::io::ErrorKind::InvalidInput,
3176 "file has no parent directory",
3177 )
3178 })?;
3179 sync_directory(parent)
3180}
3181
3182#[cfg(unix)]
3183fn sync_directory(path: &Path) -> std::io::Result<()> {
3184 fs::File::open(path)?.sync_all()
3185}
3186
3187#[cfg(windows)]
3188fn sync_directory(path: &Path) -> std::io::Result<()> {
3189 use std::os::windows::fs::OpenOptionsExt;
3190
3191 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
3192 const FILE_SHARE_ALL: u32 = 0x0000_0007;
3193 const GENERIC_WRITE: u32 = 0x4000_0000;
3194 fs::OpenOptions::new()
3195 .access_mode(GENERIC_WRITE)
3196 .share_mode(FILE_SHARE_ALL)
3197 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
3198 .open(path)?
3199 .sync_all()
3200}
3201
3202#[cfg(test)]
3203mod tests {
3204 use super::*;
3205 use std::collections::BTreeSet;
3206 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
3207
3208 use crate::reconcile::{AllocationLock, InProcessAllocationLock};
3209 use runner_manager_domain::model::{Elapsed, TargetScope};
3210 use runner_manager_domain::store::SqliteStore;
3211 use runner_manager_github::jit::JitRunner;
3212 use runner_manager_testkit::clock::FakeClock;
3213 use runner_manager_testkit::fixtures;
3214
3215 type CapturedFields = Vec<(String, String)>;
3217
3218 #[derive(Clone, Default)]
3226 struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
3227
3228 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
3229 fn on_event(
3230 &self,
3231 event: &tracing::Event<'_>,
3232 _context: tracing_subscriber::layer::Context<'_, S>,
3233 ) {
3234 struct Collect(Vec<(String, String)>);
3235 impl tracing::field::Visit for Collect {
3236 fn record_debug(
3237 &mut self,
3238 field: &tracing::field::Field,
3239 value: &dyn std::fmt::Debug,
3240 ) {
3241 self.0.push((
3244 field.name().to_owned(),
3245 format!("{value:?}").trim_matches('"').to_owned(),
3246 ));
3247 }
3248 }
3249 let mut collected = Collect(Vec::new());
3250 event.record(&mut collected);
3251 self.0
3252 .lock()
3253 .expect("the capture mutex is not poisoned")
3254 .push(collected.0);
3255 }
3256 }
3257
3258 #[test]
3276 fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
3277 use runner_manager_platform::logging;
3278 use tracing_subscriber::layer::SubscriberExt as _;
3279
3280 let captured = CapturedEvents::default();
3281 let error = RunnerRootError::DeniedByPrivacyPolicy {
3282 requested: PathBuf::from("/Volumes/NVME/runners"),
3283 refused: PathBuf::from("/Volumes/NVME"),
3284 remediation: RootOwner::Host.remediation(),
3285 };
3286 let kind = error.kind();
3287
3288 let failure = tracing::subscriber::with_default(
3289 tracing_subscriber::registry().with(captured.clone()),
3290 || root_failure(error),
3291 );
3292
3293 assert!(
3298 matches!(
3299 &failure,
3300 LifecycleError::Failed(FailureReason::Other(detail))
3301 if detail.contains("/Volumes/NVME/runners")
3302 && detail.contains("Full Disk Access")
3303 ),
3304 "the reason must still carry the detail: {failure:?}"
3305 );
3306
3307 let events = captured
3308 .0
3309 .lock()
3310 .expect("the capture mutex is not poisoned")
3311 .clone();
3312 let event = events
3313 .iter()
3314 .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
3315 .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
3316
3317 for (name, value) in event {
3322 assert!(
3323 logging::is_field_allowed(name),
3324 "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
3325 logging::REDACTION
3326 );
3327 assert_eq!(
3328 &logging::redact(value),
3329 value,
3330 "`{name}` does not survive value-shape scrubbing: {event:?}"
3331 );
3332 }
3333 }
3334
3335 fn nz(slot: u16) -> NonZeroU16 {
3337 NonZeroU16::new(slot).expect("a positive slot")
3338 }
3339
3340 const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
3341
3342 #[derive(Debug, Default)]
3343 struct FakeGithubLifecycle {
3344 registration_failures: Mutex<VecDeque<bool>>,
3345 observations: Mutex<VecDeque<LifecycleGithubObservation>>,
3346 registrations: AtomicUsize,
3347 remaining_runners: AtomicUsize,
3348 deregistrations: Mutex<Vec<u64>>,
3352 deregistration_fails: AtomicBool,
3355 journal: Mutex<Option<Arc<SqliteStore>>>,
3360 registration_facts: Mutex<Vec<RegistrationFact>>,
3362 }
3363
3364 #[derive(Debug, Clone)]
3371 struct RegistrationFact {
3372 leased_slots: Vec<u16>,
3374 work_folder: String,
3376 runner_name: String,
3379 }
3380
3381 impl FakeGithubLifecycle {
3382 fn fail(mut self, terminal: bool) -> Self {
3383 self.registration_failures
3384 .get_mut()
3385 .expect("unpoisoned")
3386 .push_back(terminal);
3387 self
3388 }
3389
3390 fn watch_journal(&self, store: Arc<SqliteStore>) {
3391 *self.journal.lock().unwrap() = Some(store);
3392 }
3393
3394 fn registration_facts(&self) -> Vec<RegistrationFact> {
3395 self.registration_facts.lock().unwrap().clone()
3396 }
3397
3398 fn observe(&self, observation: GithubRunnerObservation) {
3399 let observation = match observation {
3400 GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
3401 GithubRunnerObservation::NotRegistered => {
3402 LifecycleGithubObservation::not_registered()
3403 }
3404 GithubRunnerObservation::Registered { busy } => {
3405 LifecycleGithubObservation::registered(73, busy)
3406 }
3407 };
3408 self.observations.lock().unwrap().push_back(observation);
3409 }
3410 }
3411
3412 #[async_trait]
3413 impl LifecycleGithub for FakeGithubLifecycle {
3414 async fn register(
3415 &self,
3416 _target: &ScaleTarget,
3417 request: &JitRunnerRequest,
3418 _cancel: &CancelToken,
3419 ) -> Result<JitRegistration, JitRequestFailure> {
3420 self.registrations.fetch_add(1, Ordering::SeqCst);
3421 if let Some(store) = self.journal.lock().unwrap().as_ref() {
3422 let slots = store
3423 .attempts()
3424 .expect("the journal is readable")
3425 .iter()
3426 .filter_map(|attempt| attempt.workspace().slot_number())
3427 .collect();
3428 self.registration_facts
3429 .lock()
3430 .unwrap()
3431 .push(RegistrationFact {
3432 leased_slots: slots,
3433 work_folder: request.work_folder().to_string(),
3434 runner_name: request.name().to_string(),
3435 });
3436 }
3437 if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
3438 return Err(JitRequestFailure {
3439 terminal,
3440 reason: if terminal {
3441 FailureReason::Other("GitHub refused JIT registration with 403".into())
3442 } else {
3443 FailureReason::JitRequestFailed
3444 },
3445 retry_after: None,
3446 });
3447 }
3448 self.remaining_runners.store(1, Ordering::SeqCst);
3449 Ok(JitRegistration::new(
3450 EncodedJitConfig::new(JIT),
3451 JitRunner {
3452 id: 73,
3453 name: request.name().to_string(),
3454 os: "windows".into(),
3455 status: "offline".into(),
3456 busy: false,
3457 runner_group_id: Some(1),
3458 labels: request.labels().to_vec(),
3459 },
3460 ))
3461 }
3462
3463 async fn observe(
3464 &self,
3465 _target: &ScaleTarget,
3466 _attempt: AttemptId,
3467 _cancel: &CancelToken,
3468 ) -> LifecycleGithubObservation {
3469 let observation = self
3470 .observations
3471 .lock()
3472 .unwrap()
3473 .pop_front()
3474 .unwrap_or(LifecycleGithubObservation::not_registered());
3475 if observation.status == GithubRunnerObservation::NotRegistered {
3476 self.remaining_runners.store(0, Ordering::SeqCst);
3477 }
3478 observation
3479 }
3480
3481 async fn deregister(
3482 &self,
3483 _target: &ScaleTarget,
3484 runner_id: u64,
3485 _cancel: &CancelToken,
3486 ) -> bool {
3487 self.deregistrations.lock().unwrap().push(runner_id);
3488 if self.deregistration_fails.load(Ordering::SeqCst) {
3489 return false;
3490 }
3491 self.remaining_runners.store(0, Ordering::SeqCst);
3492 true
3493 }
3494 }
3495
3496 #[derive(Debug)]
3497 struct FakePackages {
3498 version: RunnerVersion,
3499 leases: Mutex<BTreeSet<AttemptId>>,
3500 materializations: AtomicUsize,
3501 materialization_failures: AtomicUsize,
3502 release_failures: AtomicUsize,
3503 releases: AtomicUsize,
3504 prunes: AtomicUsize,
3505 prune_currents: Mutex<Vec<RunnerVersion>>,
3506 }
3507
3508 impl Default for FakePackages {
3509 fn default() -> Self {
3510 Self {
3511 version: RunnerVersion::parse("2.330.0").unwrap(),
3512 leases: Mutex::new(BTreeSet::new()),
3513 materializations: AtomicUsize::new(0),
3514 materialization_failures: AtomicUsize::new(0),
3515 release_failures: AtomicUsize::new(0),
3516 releases: AtomicUsize::new(0),
3517 prunes: AtomicUsize::new(0),
3518 prune_currents: Mutex::new(Vec::new()),
3519 }
3520 }
3521 }
3522
3523 impl FakePackages {
3524 fn fail_materializations(&self, count: usize) {
3525 self.materialization_failures.store(count, Ordering::SeqCst);
3526 }
3527
3528 fn fail_releases(&self, count: usize) {
3529 self.release_failures.store(count, Ordering::SeqCst);
3530 }
3531 }
3532
3533 #[async_trait]
3534 impl RuntimePackages for FakePackages {
3535 async fn materialize(
3536 &self,
3537 attempt: &RunnerAttempt,
3538 ) -> Result<RunnerVersion, FailureReason> {
3539 self.materializations.fetch_add(1, Ordering::SeqCst);
3540 if self
3541 .materialization_failures
3542 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
3543 if left > 0 { Some(left - 1) } else { None }
3544 })
3545 .is_ok()
3546 {
3547 return Err(FailureReason::Other(
3548 "runner package materialization failed transiently".into(),
3549 ));
3550 }
3551 fs::create_dir_all(attempt.runtime_path()).unwrap();
3552 fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
3553 self.leases.lock().unwrap().insert(attempt.id);
3554 Ok(self.version.clone())
3555 }
3556
3557 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
3558 if self
3559 .release_failures
3560 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
3561 if left > 0 { Some(left - 1) } else { None }
3562 })
3563 .is_ok()
3564 {
3565 return Err(FailureReason::Other(
3566 "runner package lease could not be released".into(),
3567 ));
3568 }
3569 self.leases.lock().unwrap().remove(&attempt);
3570 self.releases.fetch_add(1, Ordering::SeqCst);
3571 Ok(())
3572 }
3573
3574 fn prune_obsolete_guarded(
3575 &self,
3576 _authority: PruneAuthority<'_>,
3577 current: &RunnerVersion,
3578 _attempts: &[RunnerAttempt],
3579 ) -> Result<(), FailureReason> {
3580 self.prunes.fetch_add(1, Ordering::SeqCst);
3581 self.prune_currents.lock().unwrap().push(current.clone());
3582 Ok(())
3583 }
3584 }
3585
3586 #[derive(Debug, Default)]
3587 struct FakeProcesses {
3588 alive: AtomicBool,
3589 completed_successfully: AtomicBool,
3590 spawns: AtomicUsize,
3591 spawn_failures: AtomicUsize,
3592 live_spawn_failure: AtomicBool,
3593 terminations: AtomicUsize,
3594 intent: AtomicBool,
3595 intent_failure: AtomicBool,
3596 actions: Mutex<Vec<&'static str>>,
3597 saw_secret: AtomicBool,
3598 }
3599
3600 impl FakeProcesses {
3601 fn fail_spawns(&self, count: usize) {
3602 self.spawn_failures.store(count, Ordering::SeqCst);
3603 }
3604
3605 fn fail_spawn_with_live_child(&self) {
3606 self.live_spawn_failure.store(true, Ordering::SeqCst);
3607 }
3608
3609 fn set_alive(&self, alive: bool) {
3610 self.alive.store(alive, Ordering::SeqCst);
3611 }
3612
3613 fn finish_successfully(&self) {
3614 self.completed_successfully.store(true, Ordering::SeqCst);
3615 self.alive.store(false, Ordering::SeqCst);
3616 }
3617
3618 fn fail_intent(&self) {
3619 self.intent_failure.store(true, Ordering::SeqCst);
3620 }
3621 }
3622
3623 impl ProcessSupervisor for FakeProcesses {
3624 fn spawn(
3625 &self,
3626 attempt: &RunnerAttempt,
3627 config: &EncodedJitConfig,
3628 ) -> Result<u32, ProcessStartFailure> {
3629 self.spawns.fetch_add(1, Ordering::SeqCst);
3630 let handoff = RestrictiveHandoff::create(
3633 attempt.runtime_path(),
3634 SecretString::from(config.expose().to_owned()),
3635 )
3636 .unwrap();
3637 self.saw_secret
3638 .store(config.expose() == JIT, Ordering::SeqCst);
3639 let handoff_path = handoff.path().to_path_buf();
3640 let failing = self
3641 .spawn_failures
3642 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
3643 if left > 0 { Some(left - 1) } else { None }
3644 })
3645 .is_ok();
3646 drop(handoff);
3647 assert!(!handoff_path.exists(), "handoff must be absent on return");
3648 if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
3649 self.alive.store(true, Ordering::SeqCst);
3650 return Err(ProcessStartFailure::after_spawn_live(4242));
3651 }
3652 if failing {
3653 return Err(ProcessStartFailure::before_spawn(
3654 FailureReason::ProcessStartFailed,
3655 ));
3656 }
3657 self.alive.store(true, Ordering::SeqCst);
3658 Ok(4242)
3659 }
3660
3661 fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
3662 self.actions.lock().unwrap().push("observe_process");
3663 Ok(self.alive.load(Ordering::SeqCst))
3664 }
3665
3666 fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
3667 Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
3668 }
3669
3670 fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
3671 self.completed_successfully.load(Ordering::SeqCst)
3672 }
3673
3674 fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3675 self.actions.lock().unwrap().push("terminate_intent");
3676 if self.intent_failure.load(Ordering::SeqCst) {
3677 return Err(FailureReason::Other(
3678 "terminate intent directory sync failed".into(),
3679 ));
3680 }
3681 self.intent.store(true, Ordering::SeqCst);
3682 Ok(())
3683 }
3684
3685 fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
3686 self.intent.load(Ordering::SeqCst)
3687 }
3688
3689 fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3690 assert!(
3691 self.intent.load(Ordering::SeqCst),
3692 "the durable intent must exist before signalling"
3693 );
3694 self.actions.lock().unwrap().push("terminate");
3695 self.terminations.fetch_add(1, Ordering::SeqCst);
3696 self.alive.store(false, Ordering::SeqCst);
3697 Ok(())
3698 }
3699 }
3700
3701 #[derive(Debug, Default)]
3702 struct FakeDemand {
3703 answers: Mutex<VecDeque<bool>>,
3704 }
3705
3706 impl FakeDemand {
3707 fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
3708 Self {
3709 answers: Mutex::new(answers.into_iter().collect()),
3710 }
3711 }
3712 }
3713
3714 #[async_trait]
3715 impl DemandPersistence for FakeDemand {
3716 async fn persists(&self, _policy: PolicyId) -> bool {
3717 self.answers.lock().unwrap().pop_front().unwrap_or(true)
3718 }
3719 }
3720
3721 #[derive(Debug, Default)]
3722 struct FakeDelay(Mutex<Vec<Duration>>);
3723
3724 #[async_trait]
3725 impl RetryDelay for FakeDelay {
3726 async fn wait(&self, duration: Duration) {
3727 self.0.lock().unwrap().push(duration);
3728 }
3729 }
3730
3731 struct Harness {
3732 _root: tempfile::TempDir,
3733 app_paths: runner_manager_platform::paths::AppPaths,
3734 launcher: LifecycleLauncher,
3735 demand: Arc<dyn DemandPersistence>,
3736 store: Arc<SqliteStore>,
3737 github: Arc<FakeGithubLifecycle>,
3738 packages: Arc<FakePackages>,
3739 processes: Arc<FakeProcesses>,
3740 clock: Arc<FakeClock>,
3741 events: Arc<AttemptEventLog>,
3742 reconcile_events: Arc<crate::reconcile::EventLog>,
3743 delay: Arc<FakeDelay>,
3744 host: runner_manager_domain::model::Host,
3745 policy: ScalePolicy,
3746 allocation_lock: InProcessAllocationLock,
3747 workspace_root: Option<LocalAbsolutePath>,
3749 }
3750
3751 impl Harness {
3752 fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
3753 let root = tempfile::tempdir().unwrap();
3754 let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
3755 paths.create_all().unwrap();
3756 let policy = fixtures::policy()
3757 .repository("octo/repo")
3758 .autoscale("home", 2)
3759 .active()
3760 .build();
3761 let host_root = root.path().join("host-root");
3767 fs::create_dir_all(&host_root).unwrap();
3768 let mut host = fixtures::host().build();
3769 host.runner_root_override = Some(
3770 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
3771 .expect("a local absolute host root"),
3772 );
3773 let store = Arc::new(SqliteStore::open_in_memory().unwrap());
3774 store.put_host(&host).unwrap();
3775 let github = Arc::new(github);
3776 let packages = Arc::new(FakePackages::default());
3777 let processes = Arc::new(FakeProcesses::default());
3778 let clock = Arc::new(FakeClock::default());
3779 let events = Arc::new(AttemptEventLog::default());
3780 let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
3781 let delay = Arc::new(FakeDelay::default());
3782 let ports = LifecyclePorts {
3783 store: Arc::clone(&store) as Arc<dyn Store>,
3784 github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
3785 packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
3786 processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
3787 clock: Arc::clone(&clock) as Arc<dyn Clock>,
3788 demand: Arc::clone(&demand),
3789 delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
3790 events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
3791 reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
3792 };
3793 let launcher = Self::launcher_over(policy.host_id, &paths, ports);
3794 Self {
3795 _root: root,
3796 app_paths: paths,
3797 launcher,
3798 demand,
3799 store,
3800 github,
3801 packages,
3802 processes,
3803 clock,
3804 events,
3805 reconcile_events,
3806 delay,
3807 host,
3808 policy,
3809 allocation_lock: InProcessAllocationLock::new(),
3810 workspace_root: None,
3811 }
3812 }
3813
3814 fn with_host_runner_root(mut self) -> Self {
3821 let host_root = self.host_root();
3822 fs::create_dir_all(&host_root).unwrap();
3823 self.host.runner_root_override = Some(
3824 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
3825 .expect("a local absolute host root"),
3826 );
3827 self.store.put_host(&self.host).unwrap();
3828 self
3829 }
3830
3831 fn with_persistent_workspace(mut self, capacity: u16) -> Self {
3833 self = self.with_host_runner_root();
3834 let root = self._root.path().join("persist");
3835 let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
3836 .expect("a local absolute workspace root");
3837 self.policy = fixtures::policy()
3838 .repository("octo/repo")
3839 .autoscale("home", capacity)
3840 .active()
3841 .build();
3842 self.policy
3843 .set_workspace_policy(
3844 WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
3845 .expect("a repository may be persistent"),
3846 )
3847 .expect("a repository may be persistent");
3848 self.workspace_root = Some(root);
3849 self.store.insert_policy(&self.policy).unwrap();
3852 self
3853 }
3854
3855 fn workspace_root(&self) -> &LocalAbsolutePath {
3856 self.workspace_root
3857 .as_ref()
3858 .expect("this harness configured a persistent workspace")
3859 }
3860
3861 fn slot_path(&self, slot: u16) -> PathBuf {
3862 self.workspace_root().as_path().join(format!("s{slot}"))
3863 }
3864
3865 fn host_root(&self) -> PathBuf {
3866 self._root.path().join("host-root")
3867 }
3868
3869 fn attempt(&self, id: AttemptId) -> RunnerAttempt {
3870 self.store
3871 .attempt(id)
3872 .unwrap()
3873 .expect("the attempt is journalled")
3874 }
3875
3876 fn conclude(&self, id: AttemptId) -> RunnerAttempt {
3878 let mut attempt = self.attempt(id);
3879 attempt
3880 .conclude(
3881 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
3882 self.clock.now(),
3883 )
3884 .unwrap();
3885 self.store.record_attempt(&attempt).unwrap();
3886 attempt
3887 }
3888
3889 async fn cleanup_retaining_work(&self, id: AttemptId) {
3890 self.conclude(id);
3891 self.launcher
3892 .clean(id)
3893 .await
3894 .expect("the slot is scrubbed and the lease released");
3895 }
3896
3897 fn launcher_over(
3900 host: HostId,
3901 paths: &runner_manager_platform::paths::AppPaths,
3902 ports: LifecyclePorts,
3903 ) -> LifecycleLauncher {
3904 LifecycleLauncher::new(
3905 host,
3906 paths.clone(),
3907 paths.logs_dir(),
3908 1,
3909 RecoveryTimeouts::new(
3910 Elapsed::seconds(10),
3911 Elapsed::seconds(10),
3912 Elapsed::seconds(10),
3913 ),
3914 RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
3915 ports,
3916 )
3917 }
3918
3919 fn restart(&self) -> LifecycleLauncher {
3922 Self::launcher_over(
3923 self.policy.host_id,
3924 &self.app_paths,
3925 LifecyclePorts {
3926 store: Arc::clone(&self.store) as Arc<dyn Store>,
3927 github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
3928 packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
3929 processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
3930 clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
3931 demand: Arc::clone(&self.demand),
3932 delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
3933 events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
3934 reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
3935 },
3936 )
3937 }
3938
3939 async fn ready(&self) {
3940 self.launcher
3941 .recover_startup(std::slice::from_ref(&self.policy))
3942 .await
3943 .unwrap();
3944 }
3945
3946 async fn launch(&self) -> RunnerAttempt {
3947 self.launch_result().await.unwrap()
3948 }
3949
3950 async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
3951 let guard = self.allocation_lock.acquire().await.unwrap();
3952 self.launcher
3953 .launch(LaunchRequest {
3954 host: &self.host,
3955 policy: &self.policy,
3956 allocation_guard: &guard,
3957 })
3958 .await
3959 }
3960
3961 fn only_attempt(&self) -> RunnerAttempt {
3962 self.store.attempts().unwrap().into_iter().next().unwrap()
3963 }
3964 }
3965
3966 #[tokio::test]
3975 async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
3976 use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
3977
3978 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
3979 .with_host_runner_root();
3980 harness.ready().await;
3981
3982 let unusable = harness
3986 ._root
3987 .path()
3988 .join("absent")
3989 .join("deeper")
3990 .join("runners");
3991 let mut host = harness.host.clone();
3992 host.runner_root_override = Some(
3993 LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
3994 .expect("a local absolute host root"),
3995 );
3996 harness.store.put_host(&host).unwrap();
3997
3998 let failure = harness
3999 .launch_result()
4000 .await
4001 .expect_err("a root whose parents are missing cannot hold a runner");
4002 assert!(
4003 matches!(failure.reason, FailureReason::Other(_)),
4004 "{failure:?}"
4005 );
4006
4007 let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
4008 let refusal = refusals
4009 .first()
4010 .expect("the refusal reached the one surface that can hold it");
4011 assert_eq!(refusal.policy, harness.policy.id.to_string());
4012 assert_eq!(refusal.kind, "missing_parents");
4013 assert!(
4014 refusal.root.contains("runners") && refusal.detail.contains("runners"),
4015 "the directory must be named in full: {refusal:?}"
4016 );
4017
4018 harness.store.put_host(&harness.host).unwrap();
4021 harness.launch().await;
4022 assert!(
4023 runner_root_refusals(&harness.app_paths)
4024 .expect("readable")
4025 .is_empty(),
4026 "a successful placement clears that policy's record"
4027 );
4028
4029 clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
4030 .expect("cleanup");
4031 }
4032
4033 #[tokio::test]
4034 async fn a_launch_reports_how_long_each_step_took() {
4035 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4036 harness.ready().await;
4037 let started = harness.launch().await;
4038 let events = harness.events.events();
4039 let launched = events
4040 .iter()
4041 .position(|event| matches!(event, AttemptEvent::Launched { attempt, .. } if *attempt == started.id))
4042 .expect("a successful launch reports its step timings");
4043 let starting = events
4044 .iter()
4045 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == started.id))
4046 .unwrap();
4047 assert!(starting < launched, "timings follow the start: {events:?}");
4048 }
4049
4050 #[tokio::test]
4051 async fn a_job_walks_every_state_and_cleans_every_artifact() {
4052 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4053 harness.ready().await;
4054 let started = harness.launch().await;
4055 assert_eq!(started.state(), AttemptState::Starting);
4056 assert_eq!(read_runner_id(started.runtime_path()), Some(73));
4057
4058 harness
4059 .github
4060 .observe(GithubRunnerObservation::Registered { busy: false });
4061 harness.launcher.supervise(&harness.policy).await.unwrap();
4062 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
4063
4064 harness
4065 .github
4066 .observe(GithubRunnerObservation::Registered { busy: true });
4067 harness.launcher.supervise(&harness.policy).await.unwrap();
4068 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
4069
4070 harness.processes.finish_successfully();
4071 harness
4072 .github
4073 .observe(GithubRunnerObservation::NotRegistered);
4074 harness.launcher.supervise(&harness.policy).await.unwrap();
4075 let cleaned = harness.only_attempt();
4076 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4077 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
4078 assert!(!started.runtime_path().exists());
4079 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
4080 assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
4081
4082 let states: Vec<_> = harness
4083 .events
4084 .events()
4085 .into_iter()
4086 .filter_map(|event| match event {
4087 AttemptEvent::State { state, .. } => Some(state),
4088 _ => None,
4089 })
4090 .collect();
4091 assert_eq!(
4092 states,
4093 vec![
4094 AttemptState::Allocated,
4095 AttemptState::JitReceived,
4096 AttemptState::Starting,
4097 AttemptState::Idle,
4098 AttemptState::Busy,
4099 AttemptState::Finished,
4100 AttemptState::Cleaned,
4101 ]
4102 );
4103 }
4104
4105 #[tokio::test]
4106 async fn a_cleaned_ephemeral_attempt_reaps_a_directory_recreated_after_cleanup() {
4107 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4108 harness.ready().await;
4109 let started = harness.launch().await;
4110 let runtime = started.runtime_path().to_path_buf();
4111
4112 harness
4113 .github
4114 .observe(GithubRunnerObservation::Registered { busy: true });
4115 harness.launcher.supervise(&harness.policy).await.unwrap();
4116 harness.processes.finish_successfully();
4117 harness
4118 .github
4119 .observe(GithubRunnerObservation::NotRegistered);
4120 harness.launcher.supervise(&harness.policy).await.unwrap();
4121
4122 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
4123 assert!(!runtime.exists());
4124 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
4125
4126 let residue = runtime.join("_work").join("late-node-process");
4130 fs::create_dir_all(&residue).unwrap();
4131 fs::write(residue.join("node_modules.lock"), b"late residue").unwrap();
4132
4133 harness.launcher.supervise(&harness.policy).await.unwrap();
4134
4135 assert!(!runtime.exists(), "late ephemeral residue is reaped");
4136 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
4137 assert_eq!(
4138 harness.packages.releases.load(Ordering::SeqCst),
4139 1,
4140 "reaping residue does not release the package lease twice"
4141 );
4142 }
4143
4144 #[tokio::test]
4145 async fn locked_late_residue_never_blocks_startup_recovery() {
4146 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4147 harness.ready().await;
4148 let started = harness.launch().await;
4149 let runtime = started.runtime_path().to_path_buf();
4150
4151 harness
4152 .github
4153 .observe(GithubRunnerObservation::Registered { busy: true });
4154 harness.launcher.supervise(&harness.policy).await.unwrap();
4155 harness.processes.finish_successfully();
4156 harness
4157 .github
4158 .observe(GithubRunnerObservation::NotRegistered);
4159 harness.launcher.supervise(&harness.policy).await.unwrap();
4160 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
4161
4162 let held = runtime.join("_work").join("late-node-process");
4163 fs::create_dir_all(&held).unwrap();
4164 fs::write(held.join("node_modules.lock"), b"late residue").unwrap();
4165 let Some(block) = BlockedDeletion::inject(&held) else {
4166 eprintln!(
4167 "skipped: this account cannot be refused a deletion, so locked late residue cannot be injected"
4168 );
4169 return;
4170 };
4171
4172 let restarted = harness.restart();
4173 restarted
4174 .recover_startup(std::slice::from_ref(&harness.policy))
4175 .await
4176 .expect("late residue cannot take the daemon offline");
4177 assert!(runtime.exists(), "the locked residue remains for a retry");
4178 assert!(
4179 harness
4180 .reconcile_events
4181 .events()
4182 .iter()
4183 .any(|event| matches!(
4184 event,
4185 LifecycleEvent::AttemptCleanFailed {
4186 attempt,
4187 reason: "late_ephemeral_workspace_could_not_be_removed",
4188 ..
4189 } if *attempt == started.id
4190 )),
4191 "the non-blocking cleanup failure remains visible"
4192 );
4193
4194 block.release();
4195 restarted
4196 .supervise(&harness.policy)
4197 .await
4198 .expect("the ordinary retry succeeds after the lock is released");
4199 assert!(!runtime.exists(), "the retry removes the late residue");
4200 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
4201 assert_eq!(
4202 harness.packages.releases.load(Ordering::SeqCst),
4203 1,
4204 "retrying residue never releases the package lease twice"
4205 );
4206 }
4207
4208 #[tokio::test]
4209 async fn mixed_locked_cleanup_residue_isolated_from_the_whole_startup_pass() {
4210 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4211 harness.ready().await;
4212
4213 let terminal = harness.launch().await;
4217 let terminal_runtime = terminal.runtime_path().to_path_buf();
4218 harness.conclude(terminal.id);
4219 let Some(terminal_block) = BlockedDeletion::inject(&terminal_runtime.join("late-child"))
4220 else {
4221 eprintln!("skipped: this account cannot inject the two independent deletion refusals");
4222 return;
4223 };
4224
4225 let cleaned = harness.launch().await;
4229 let cleaned_runtime = cleaned.runtime_path().to_path_buf();
4230 harness.conclude(cleaned.id);
4231 harness
4232 .launcher
4233 .clean(cleaned.id)
4234 .await
4235 .expect("the second attempt initially cleans");
4236 let Some(cleaned_block) =
4237 BlockedDeletion::inject(&cleaned_runtime.join("_work").join("late-child"))
4238 else {
4239 terminal_block.release();
4240 eprintln!("skipped: this account cannot inject the two independent deletion refusals");
4241 return;
4242 };
4243
4244 let restarted = harness.restart();
4245 for _ in 0..2 {
4246 restarted
4247 .recover_startup(std::slice::from_ref(&harness.policy))
4248 .await
4249 .expect("repeated recovery remains ready while both residues are locked");
4250 }
4251 assert_ne!(
4252 harness.attempt(terminal.id).state(),
4253 AttemptState::Cleaned,
4254 "the journal does not claim the terminal workspace was removed"
4255 );
4256 assert_eq!(
4257 harness.attempt(cleaned.id).state(),
4258 AttemptState::Cleaned,
4259 "late residue does not undo an already durable cleaned transition"
4260 );
4261 assert!(terminal_runtime.exists());
4262 assert!(cleaned_runtime.exists());
4263 assert_eq!(
4264 harness.packages.releases.load(Ordering::SeqCst),
4265 1,
4266 "repeated recovery neither loses nor duplicates package leases"
4267 );
4268
4269 terminal_block.release();
4270 cleaned_block.release();
4271 restarted
4272 .supervise(&harness.policy)
4273 .await
4274 .expect("one ordinary pass clears both residues after their locks disappear");
4275 assert!(!terminal_runtime.exists());
4276 assert!(!cleaned_runtime.exists());
4277 assert_eq!(harness.attempt(terminal.id).state(), AttemptState::Cleaned);
4278 assert_eq!(harness.attempt(cleaned.id).state(), AttemptState::Cleaned);
4279 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 2);
4280 }
4281
4282 #[tokio::test]
4283 async fn a_terminal_attempt_whose_runtime_is_already_gone_recovers_cleanly() {
4284 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4285 harness.ready().await;
4286 let attempt = harness.launch().await;
4287 let runtime = attempt.runtime_path().to_path_buf();
4288 harness.conclude(attempt.id);
4289 fs::remove_dir_all(&runtime).unwrap();
4290
4291 harness
4292 .restart()
4293 .recover_startup(std::slice::from_ref(&harness.policy))
4294 .await
4295 .expect("an already absent disposable runtime is successful cleanup");
4296
4297 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
4298 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
4299 }
4300
4301 #[tokio::test]
4302 async fn a_non_workspace_cleanup_failure_still_fails_closed_and_can_retry() {
4303 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4304 harness.ready().await;
4305 let attempt = harness.launch().await;
4306 let runtime = attempt.runtime_path().to_path_buf();
4307 harness.conclude(attempt.id);
4308 harness.packages.fail_releases(1);
4309
4310 let restarted = harness.restart();
4311 let failure = restarted
4312 .recover_startup(std::slice::from_ref(&harness.policy))
4313 .await
4314 .expect_err("a package-accounting failure is not safe to downgrade to residue");
4315 assert!(
4316 failure
4317 .reason()
4318 .to_string()
4319 .contains("package lease could not be released")
4320 );
4321 assert!(
4322 !runtime.exists(),
4323 "workspace removal completed before release failed"
4324 );
4325 assert_ne!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
4326
4327 restarted
4328 .recover_startup(std::slice::from_ref(&harness.policy))
4329 .await
4330 .expect("the same journal entry retries safely after the transient failure");
4331 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
4332 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
4333 }
4334
4335 #[tokio::test]
4336 async fn startup_never_scrubs_a_cleaned_persistent_work_directory() {
4337 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
4338 .with_persistent_workspace(1);
4339 harness.ready().await;
4340 let attempt = harness.launch().await;
4341 let retained = attempt.runtime_path().join("_work").join("checkout-marker");
4342 fs::create_dir_all(retained.parent().unwrap()).unwrap();
4343 fs::write(&retained, b"persistent checkout").unwrap();
4344 harness.cleanup_retaining_work(attempt.id).await;
4345
4346 harness
4347 .restart()
4348 .recover_startup(std::slice::from_ref(&harness.policy))
4349 .await
4350 .expect("a cleaned persistent slot is already reconciled");
4351
4352 assert_eq!(fs::read(&retained).unwrap(), b"persistent checkout");
4353 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
4354 }
4355
4356 #[tokio::test]
4357 async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
4358 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4359 harness.ready().await;
4360 let started = harness.launch().await;
4361 harness
4362 .github
4363 .observe(GithubRunnerObservation::Registered { busy: false });
4364 harness.launcher.supervise(&harness.policy).await.unwrap();
4365 harness.clock.advance_secs(11);
4366 harness.processes.set_alive(false);
4367 harness
4368 .github
4369 .observe(GithubRunnerObservation::NotRegistered);
4370 harness.launcher.supervise(&harness.policy).await.unwrap();
4371
4372 let cleaned = harness.only_attempt();
4373 assert!(cleaned.outcome().unwrap().is_idle_exit());
4374 assert!(!cleaned.outcome().unwrap().is_failure());
4375 assert!(!started.runtime_path().exists());
4376 assert!(
4377 harness
4378 .reconcile_events
4379 .events()
4380 .iter()
4381 .any(|event| matches!(
4382 event,
4383 LifecycleEvent::AttemptCleaned {
4384 outcome: OutcomeKind::IdleExit,
4385 ..
4386 }
4387 ))
4388 );
4389 assert!(!harness.events.events().iter().any(|event| matches!(
4390 event,
4391 AttemptEvent::Concluded {
4392 outcome: OutcomeKind::Failed,
4393 ..
4394 }
4395 )));
4396 }
4397
4398 #[tokio::test]
4399 async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
4400 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4401 harness.processes.fail_spawns(2);
4402 harness.ready().await;
4403 let attempt = harness.launch().await;
4404 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
4405 assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
4406 let names: Vec<_> = fs::read_dir(attempt.runtime_path())
4407 .unwrap()
4408 .map(|entry| entry.unwrap().file_name())
4409 .collect();
4410 assert!(
4411 names.iter().all(|name| {
4412 !name
4413 .to_string_lossy()
4414 .starts_with(RestrictiveHandoff::NAME_PREFIX)
4415 }),
4416 "JIT artifact survived: {names:?}"
4417 );
4418 assert_eq!(
4419 *harness.delay.0.lock().unwrap(),
4420 vec![Duration::from_millis(10), Duration::from_millis(20)]
4421 );
4422 }
4423
4424 #[tokio::test]
4425 async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
4426 let gone = Harness::new(
4427 FakeGithubLifecycle::default().fail(false),
4428 Arc::new(FakeDemand::answering([false])),
4429 );
4430 gone.ready().await;
4431 assert!(gone.launch_result().await.is_err());
4432 assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
4433 assert!(gone.delay.0.lock().unwrap().is_empty());
4434
4435 let forbidden = Harness::new(
4436 FakeGithubLifecycle::default().fail(true),
4437 Arc::new(PersistentDemand),
4438 );
4439 forbidden.ready().await;
4440 assert!(forbidden.launch_result().await.is_err());
4441 assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
4442 assert!(forbidden.delay.0.lock().unwrap().is_empty());
4443 assert!(matches!(
4444 forbidden.only_attempt().outcome(),
4445 Some(AttemptOutcome::Failed {
4446 reason: FailureReason::Other(action)
4447 }) if action.contains("403")
4448 ));
4449
4450 let transient = Harness::new(
4451 FakeGithubLifecycle::default().fail(false).fail(false),
4452 Arc::new(PersistentDemand),
4453 );
4454 transient.ready().await;
4455 transient.launch().await;
4456 assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
4457 assert_eq!(
4458 *transient.delay.0.lock().unwrap(),
4459 vec![Duration::from_millis(10), Duration::from_millis(20)]
4460 );
4461 }
4462
4463 #[test]
4471 fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
4472 const MAX_PATH: usize = 260;
4473 let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
4475 let repo = "GitHub-Runner-Scaler-UI";
4479 let deepest = format!(
4480 r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
4481 "0".repeat(40)
4482 );
4483
4484 let name = workspace_name(AttemptId::new_random());
4485 assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
4486 assert!(
4487 name.chars().all(|c| c.is_ascii_hexdigit()),
4488 "a directory name must not carry the identifier's dashes: {name}"
4489 );
4490
4491 let full = format!(r"{root}\{name}\{deepest}");
4492 assert!(
4493 full.len() < MAX_PATH,
4494 "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
4495 full.len()
4496 );
4497
4498 let old = format!(
4501 r"{root}\{}\{}\{deepest}",
4502 PolicyId::new_random(),
4503 AttemptId::new_random()
4504 );
4505 assert!(
4506 old.len() > MAX_PATH,
4507 "the old layout is supposed to be the thing that did not fit: {} characters",
4508 old.len()
4509 );
4510 }
4511
4512 #[tokio::test]
4513 async fn two_attempts_never_share_a_workspace_even_after_failure() {
4514 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4515 harness.ready().await;
4516 let first = harness.launch().await;
4517 fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
4518 harness
4519 .github
4520 .observe(GithubRunnerObservation::Registered { busy: false });
4521 harness.launcher.supervise(&harness.policy).await.unwrap();
4522 harness.clock.advance_secs(11);
4523 harness.processes.set_alive(false);
4524 harness
4525 .github
4526 .observe(GithubRunnerObservation::NotRegistered);
4527 harness.launcher.supervise(&harness.policy).await.unwrap();
4528 assert!(!first.runtime_path().exists());
4529
4530 let second = harness.launch().await;
4531 assert_ne!(first.runtime_path(), second.runtime_path());
4532 assert!(!second.runtime_path().join("hostile-leftover").exists());
4533
4534 fs::write(
4535 second.runtime_path().join("hostile-on-failure"),
4536 b"second job",
4537 )
4538 .unwrap();
4539 harness.processes.set_alive(false);
4540 harness
4541 .github
4542 .observe(GithubRunnerObservation::NotRegistered);
4543 harness.launcher.supervise(&harness.policy).await.unwrap();
4544 assert!(
4545 !second.runtime_path().exists(),
4546 "failed workspace was retained"
4547 );
4548 }
4549
4550 #[tokio::test]
4551 async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
4552 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4553 harness.ready().await;
4554 let attempt = harness.launch().await;
4555
4556 harness
4560 .github
4561 .observe(GithubRunnerObservation::Registered { busy: false });
4562 harness.launcher.supervise(&harness.policy).await.unwrap();
4563 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
4564
4565 harness.clock.advance_secs(9);
4568 harness
4569 .github
4570 .observe(GithubRunnerObservation::Registered { busy: false });
4571 let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
4572 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
4573 assert!(none_yet.is_empty());
4574 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
4575
4576 harness.clock.advance_secs(1);
4578 harness
4579 .github
4580 .observe(GithubRunnerObservation::Registered { busy: false });
4581 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4582
4583 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
4584 assert_eq!(
4585 concluded.outcome(),
4586 Some(&AttemptOutcome::ExitedIdleWithoutWork),
4587 "a surplus runner did not fail; recording one as a failure sends an operator \
4588 hunting a fault that does not exist"
4589 );
4590 assert_eq!(concluded.state(), AttemptState::Cleaned);
4591 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
4592 assert!(!attempt.runtime_path().exists());
4593
4594 assert_eq!(
4597 *harness.github.deregistrations.lock().unwrap(),
4598 vec![73],
4599 "the attempt's own runner id, deleted exactly once"
4600 );
4601
4602 assert!(
4605 replacements.is_empty(),
4606 "a surplus exit must not request a replacement"
4607 );
4608 }
4609
4610 #[tokio::test]
4611 async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
4612 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4616 harness.ready().await;
4617 let attempt = harness.launch().await;
4618 harness
4619 .github
4620 .observe(GithubRunnerObservation::Registered { busy: false });
4621 harness.launcher.supervise(&harness.policy).await.unwrap();
4622
4623 harness
4624 .github
4625 .deregistration_fails
4626 .store(true, Ordering::SeqCst);
4627 harness.clock.advance_secs(11);
4628 harness
4629 .github
4630 .observe(GithubRunnerObservation::Registered { busy: false });
4631 harness.launcher.supervise(&harness.policy).await.unwrap();
4632
4633 assert_eq!(
4634 *harness.github.deregistrations.lock().unwrap(),
4635 vec![73],
4636 "the delete was attempted"
4637 );
4638 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
4639 assert_eq!(
4640 concluded.outcome(),
4641 Some(&AttemptOutcome::ExitedIdleWithoutWork),
4642 "the attempt concluded anyway"
4643 );
4644 assert_eq!(concluded.state(), AttemptState::Cleaned);
4645 assert!(!attempt.runtime_path().exists());
4646 }
4647
4648 #[tokio::test]
4649 async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
4650 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4651 harness.ready().await;
4652 let first = harness.launch().await;
4653 harness.processes.set_alive(false);
4654 harness
4655 .github
4656 .observe(GithubRunnerObservation::NotRegistered);
4657 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4658 let failed = harness.store.attempt(first.id).unwrap().unwrap();
4659 assert!(matches!(
4660 failed.outcome(),
4661 Some(AttemptOutcome::Failed {
4662 reason: FailureReason::ProcessExitedUnexpectedly
4663 })
4664 ));
4665 assert!(!first.runtime_path().exists());
4666
4667 assert_eq!(
4668 replacements,
4669 vec![ReplacementIntent {
4670 policy: harness.policy.id,
4671 previous_attempt: first.id,
4672 operation: "exit_before_acceptance_replacement",
4673 }]
4674 );
4675 assert_eq!(harness.store.attempts().unwrap().len(), 1);
4676 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
4677 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
4678 assert!(harness.delay.0.lock().unwrap().is_empty());
4679 }
4680
4681 #[tokio::test]
4682 async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
4683 let harness = Harness::new(
4684 FakeGithubLifecycle::default(),
4685 Arc::new(FakeDemand::answering([false])),
4686 );
4687 let id = AttemptId::new_random();
4688 let runtime = harness
4689 .launcher
4690 .app_paths
4691 .runtime_dir()
4692 .join(harness.policy.id.to_string())
4693 .join(id.to_string());
4694 fs::create_dir_all(&runtime).unwrap();
4695 let mut attempt =
4696 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4697 attempt.jit_received(harness.clock.now()).unwrap();
4698 harness.store.record_attempt(&attempt).unwrap();
4699 harness.clock.advance_secs(11);
4700 let replacements = harness
4701 .launcher
4702 .recover_startup(std::slice::from_ref(&harness.policy))
4703 .await
4704 .unwrap();
4705 assert_eq!(
4706 replacements,
4707 vec![ReplacementIntent {
4708 policy: harness.policy.id,
4709 previous_attempt: id,
4710 operation: "jit_expired_replacement",
4711 }]
4712 );
4713
4714 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4715 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4716 assert!(matches!(
4717 cleaned.outcome(),
4718 Some(AttemptOutcome::Failed {
4719 reason: FailureReason::JitExpired
4720 })
4721 ));
4722 assert!(!runtime.exists());
4723 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
4724 assert!(harness.delay.0.lock().unwrap().is_empty());
4725 }
4726
4727 #[tokio::test]
4728 async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
4729 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4730 let id = AttemptId::new_random();
4731 let runtime = harness
4732 .launcher
4733 .app_paths
4734 .runtime_dir()
4735 .join("expired-with-demand");
4736 fs::create_dir_all(&runtime).unwrap();
4737 let mut attempt =
4738 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4739 attempt.jit_received(harness.clock.now()).unwrap();
4740 harness.store.record_attempt(&attempt).unwrap();
4741 harness.clock.advance_secs(11);
4742 let replacements = harness
4743 .launcher
4744 .recover_startup(std::slice::from_ref(&harness.policy))
4745 .await
4746 .unwrap();
4747
4748 let attempts = harness.store.attempts().unwrap();
4749 assert_eq!(attempts.len(), 1);
4750 assert_eq!(
4751 attempts
4752 .iter()
4753 .find(|attempt| attempt.id == id)
4754 .unwrap()
4755 .state(),
4756 AttemptState::Cleaned
4757 );
4758 assert_eq!(
4759 replacements,
4760 vec![ReplacementIntent {
4761 policy: harness.policy.id,
4762 previous_attempt: id,
4763 operation: "jit_expired_replacement",
4764 }]
4765 );
4766 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
4767 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
4768 assert!(harness.delay.0.lock().unwrap().is_empty());
4769 }
4770
4771 #[tokio::test]
4772 async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
4773 let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4774 persistent.packages.fail_materializations(2);
4775 persistent.ready().await;
4776 persistent.launch().await;
4777 assert_eq!(
4778 persistent.packages.materializations.load(Ordering::SeqCst),
4779 3
4780 );
4781 assert_eq!(
4782 *persistent.delay.0.lock().unwrap(),
4783 vec![Duration::from_millis(10), Duration::from_millis(20)]
4784 );
4785
4786 let gone_before_wait = Harness::new(
4787 FakeGithubLifecycle::default(),
4788 Arc::new(FakeDemand::answering([false])),
4789 );
4790 gone_before_wait.packages.fail_materializations(3);
4791 gone_before_wait.ready().await;
4792 assert!(gone_before_wait.launch_result().await.is_err());
4793 assert_eq!(
4794 gone_before_wait
4795 .packages
4796 .materializations
4797 .load(Ordering::SeqCst),
4798 1
4799 );
4800 assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
4801
4802 let gone_during_wait = Harness::new(
4803 FakeGithubLifecycle::default(),
4804 Arc::new(FakeDemand::answering([true, false])),
4805 );
4806 gone_during_wait.packages.fail_materializations(3);
4807 gone_during_wait.ready().await;
4808 assert!(gone_during_wait.launch_result().await.is_err());
4809 assert_eq!(
4810 gone_during_wait
4811 .packages
4812 .materializations
4813 .load(Ordering::SeqCst),
4814 1
4815 );
4816 assert_eq!(
4817 *gone_during_wait.delay.0.lock().unwrap(),
4818 vec![Duration::from_millis(10)]
4819 );
4820 }
4821
4822 #[tokio::test]
4823 async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
4824 let harness = Harness::new(
4825 FakeGithubLifecycle::default(),
4826 Arc::new(FakeDemand::answering([true, false])),
4827 );
4828 harness.ready().await;
4829 let first = harness.launch().await;
4830 harness.processes.set_alive(false);
4831 harness
4832 .github
4833 .observe(GithubRunnerObservation::NotRegistered);
4834 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4835
4836 assert_eq!(harness.store.attempts().unwrap().len(), 1);
4837 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
4838 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
4839 assert!(harness.delay.0.lock().unwrap().is_empty());
4840 assert_eq!(
4841 replacements,
4842 vec![ReplacementIntent {
4843 policy: harness.policy.id,
4844 previous_attempt: first.id,
4845 operation: "exit_before_acceptance_replacement",
4846 }]
4847 );
4848 assert_eq!(
4849 harness.store.attempt(first.id).unwrap().unwrap().state(),
4850 AttemptState::Cleaned
4851 );
4852 }
4853
4854 #[tokio::test]
4855 async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
4856 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4857 let before = harness.launch_result().await;
4858 assert!(before.is_err());
4859 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
4860
4861 let id = AttemptId::new_random();
4862 let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
4863 fs::create_dir_all(&runtime).unwrap();
4864 let mut attempt =
4865 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
4866 attempt.jit_received(harness.clock.now()).unwrap();
4867 attempt.started(4242, harness.clock.now()).unwrap();
4868 harness.store.record_attempt(&attempt).unwrap();
4869 harness.processes.set_alive(true);
4870 harness
4871 .github
4872 .observe(GithubRunnerObservation::NotRegistered);
4873 let replacements = harness
4874 .launcher
4875 .recover_startup(std::slice::from_ref(&harness.policy))
4876 .await
4877 .unwrap();
4878 assert!(replacements.is_empty());
4879 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
4880 assert!(
4881 harness
4882 .events
4883 .events()
4884 .contains(&AttemptEvent::Adopted { attempt: id })
4885 );
4886 }
4887
4888 #[tokio::test]
4889 async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
4890 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4891 let id = AttemptId::new_random();
4892 let runtime = harness
4893 .launcher
4894 .app_paths
4895 .runtime_dir()
4896 .join("spawn-before-starting");
4897 fs::create_dir_all(&runtime).unwrap();
4898 let mut attempt =
4899 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4900 attempt.jit_received(harness.clock.now()).unwrap();
4901 harness.store.record_attempt(&attempt).unwrap();
4902 harness.processes.set_alive(true);
4903 harness
4904 .github
4905 .observe(GithubRunnerObservation::Registered { busy: true });
4906
4907 let replacements = harness
4908 .launcher
4909 .recover_startup(std::slice::from_ref(&harness.policy))
4910 .await
4911 .unwrap();
4912 assert!(replacements.is_empty());
4913 let recovered = harness.store.attempt(id).unwrap().unwrap();
4914 assert_eq!(recovered.state(), AttemptState::Busy);
4915 assert_eq!(recovered.process_id(), Some(4242));
4916 assert_eq!(recovered.github_runner_id(), Some(73));
4917 let events = harness.events.events();
4918 let starting = events
4919 .iter()
4920 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
4921 .unwrap();
4922 let busy = events
4923 .iter()
4924 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
4925 .unwrap();
4926 assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
4927
4928 harness.processes.finish_successfully();
4929 harness
4930 .github
4931 .observe(GithubRunnerObservation::NotRegistered);
4932 assert!(
4933 harness
4934 .launcher
4935 .supervise(&harness.policy)
4936 .await
4937 .unwrap()
4938 .is_empty()
4939 );
4940 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4941 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4942 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
4943 assert!(!runtime.exists());
4944 }
4945
4946 #[tokio::test]
4947 async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
4948 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4949 harness.processes.fail_spawn_with_live_child();
4950 harness.ready().await;
4951 assert!(harness.launch_result().await.is_err());
4952
4953 let attempt = harness.only_attempt();
4954 assert_eq!(attempt.state(), AttemptState::Starting);
4955 assert_eq!(attempt.process_id(), Some(4242));
4956 assert!(attempt.outcome().is_none());
4957 assert!(attempt.state().counts_against_capacity());
4958 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
4959 assert!(harness.delay.0.lock().unwrap().is_empty());
4960
4961 harness.processes.set_alive(false);
4962 harness
4963 .github
4964 .observe(GithubRunnerObservation::NotRegistered);
4965 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4966 assert_eq!(replacements.len(), 1);
4967 assert_eq!(
4968 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
4969 AttemptState::Cleaned
4970 );
4971 }
4972
4973 #[tokio::test]
4974 async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
4975 for sidecar_already_present in [false, true] {
4976 let harness = Harness::new(
4977 FakeGithubLifecycle::default(),
4978 Arc::new(FakeDemand::answering([false])),
4979 );
4980 let id = AttemptId::new_random();
4981 let runtime =
4982 harness
4983 .launcher
4984 .app_paths
4985 .runtime_dir()
4986 .join(if sidecar_already_present {
4987 "after-id-sidecar"
4988 } else {
4989 "before-id-sidecar"
4990 });
4991 fs::create_dir_all(&runtime).unwrap();
4992 let mut attempt =
4993 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4994 if sidecar_already_present {
4995 write_runner_id(&runtime, 73).unwrap();
4996 attempt.jit_received(harness.clock.now()).unwrap();
4997 }
4998 harness.store.record_attempt(&attempt).unwrap();
4999 harness.processes.set_alive(true);
5000 harness
5001 .github
5002 .observe(GithubRunnerObservation::Registered { busy: false });
5003 harness
5004 .launcher
5005 .recover_startup(std::slice::from_ref(&harness.policy))
5006 .await
5007 .unwrap();
5008
5009 assert_eq!(read_runner_id(&runtime), Some(73));
5010 assert!(
5011 harness
5012 .store
5013 .attempt(id)
5014 .unwrap()
5015 .unwrap()
5016 .outcome()
5017 .is_none()
5018 );
5019 let events = harness.events.events();
5020 let recovered = events.iter().position(|event| {
5021 matches!(
5022 event,
5023 AttemptEvent::RemoteIdentityRecovered {
5024 attempt,
5025 runner_id: 73
5026 } if *attempt == id
5027 )
5028 });
5029 assert_eq!(recovered.is_some(), !sidecar_already_present);
5030 if let Some(recovered) = recovered {
5031 let adopted = events
5032 .iter()
5033 .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
5034 .unwrap();
5035 assert!(
5036 recovered < adopted,
5037 "identity was not durable before adoption: {events:?}"
5038 );
5039 }
5040 assert!(runtime.exists());
5041 }
5042 }
5043
5044 #[tokio::test]
5045 async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
5046 let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5047 let unknown_attempt = RunnerAttempt::allocate(
5048 AttemptId::new_random(),
5049 PolicyId::from_u128(0xfeed),
5050 unknown
5051 .launcher
5052 .app_paths
5053 .runtime_dir()
5054 .join("unknown-policy"),
5055 unknown.clock.now(),
5056 );
5057 unknown.store.record_attempt(&unknown_attempt).unwrap();
5058 let expired_id = AttemptId::new_random();
5059 let expired_runtime = unknown
5060 .launcher
5061 .app_paths
5062 .runtime_dir()
5063 .join("expired-beside-unknown");
5064 fs::create_dir_all(&expired_runtime).unwrap();
5065 let mut expired = RunnerAttempt::allocate(
5066 expired_id,
5067 unknown.policy.id,
5068 expired_runtime,
5069 unknown.clock.now(),
5070 );
5071 expired.jit_received(unknown.clock.now()).unwrap();
5072 unknown.store.record_attempt(&expired).unwrap();
5073 unknown.clock.advance_secs(11);
5074 assert!(matches!(
5075 unknown
5076 .launcher
5077 .recover_startup(std::slice::from_ref(&unknown.policy))
5078 .await,
5079 Err(LifecycleError::RecoveryIncomplete)
5080 ));
5081 assert!(unknown.launch_result().await.is_err());
5082 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
5083 let recovered_policy = fixtures::policy()
5084 .id(PolicyId::from_u128(0xfeed))
5085 .repository("octo/repo")
5086 .autoscale("home", 2)
5087 .active()
5088 .build();
5089 let pending = unknown
5090 .launcher
5091 .recover_startup(&[unknown.policy.clone(), recovered_policy])
5092 .await
5093 .unwrap();
5094 assert_eq!(
5095 pending,
5096 vec![ReplacementIntent {
5097 policy: unknown.policy.id,
5098 previous_attempt: expired_id,
5099 operation: "jit_expired_replacement",
5100 }]
5101 );
5102 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
5103
5104 let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5105 let id = AttemptId::new_random();
5106 let runtime = unreachable
5107 .launcher
5108 .app_paths
5109 .runtime_dir()
5110 .join("unreachable");
5111 fs::create_dir_all(&runtime).unwrap();
5112 unreachable
5113 .store
5114 .record_attempt(&RunnerAttempt::allocate(
5115 id,
5116 unreachable.policy.id,
5117 runtime,
5118 unreachable.clock.now(),
5119 ))
5120 .unwrap();
5121 unreachable
5122 .github
5123 .observe(GithubRunnerObservation::Unreachable);
5124 assert!(matches!(
5125 unreachable
5126 .launcher
5127 .recover_startup(std::slice::from_ref(&unreachable.policy))
5128 .await,
5129 Err(LifecycleError::RecoveryIncomplete)
5130 ));
5131 assert!(unreachable.launch_result().await.is_err());
5132 assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
5133 }
5134
5135 #[tokio::test]
5136 async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
5137 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5138 let id = AttemptId::new_random();
5139 let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
5140 fs::create_dir_all(&runtime).unwrap();
5141 let mut attempt =
5142 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
5143 attempt.jit_received(harness.clock.now()).unwrap();
5144 attempt.started(4242, harness.clock.now()).unwrap();
5145 attempt.assigned_job(73, harness.clock.now()).unwrap();
5146 harness.store.record_attempt(&attempt).unwrap();
5147 harness.processes.set_alive(false);
5148 harness
5149 .github
5150 .observe(GithubRunnerObservation::NotRegistered);
5151 harness
5152 .launcher
5153 .recover_startup(std::slice::from_ref(&harness.policy))
5154 .await
5155 .unwrap();
5156 let cleaned = harness.store.attempt(id).unwrap().unwrap();
5157 assert_eq!(cleaned.state(), AttemptState::Cleaned);
5158 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
5159 assert!(!runtime.exists());
5160 }
5161
5162 #[tokio::test]
5163 async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
5164 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5165 harness.ready().await;
5166 let id = AttemptId::new_random();
5167 let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
5168 fs::create_dir_all(&runtime).unwrap();
5169 let mut attempt =
5170 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
5171 attempt.jit_received(harness.clock.now()).unwrap();
5172 attempt.started(4242, harness.clock.now()).unwrap();
5173 harness.store.record_attempt(&attempt).unwrap();
5174 harness.clock.advance_secs(11);
5175 harness.processes.set_alive(true);
5176 harness
5177 .github
5178 .observe(GithubRunnerObservation::NotRegistered);
5179 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
5180 assert_eq!(
5181 replacements,
5182 vec![ReplacementIntent {
5183 policy: harness.policy.id,
5184 previous_attempt: id,
5185 operation: "registration_timeout_replacement",
5186 }]
5187 );
5188
5189 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
5190 assert!(!harness.processes.alive.load(Ordering::SeqCst));
5191 let actions = harness.processes.actions.lock().unwrap().clone();
5192 let intent = actions
5193 .iter()
5194 .position(|action| *action == "terminate_intent")
5195 .unwrap();
5196 let signal = actions
5197 .iter()
5198 .position(|action| *action == "terminate")
5199 .unwrap();
5200 assert!(
5201 intent < signal,
5202 "intent was not durable before signal: {actions:?}"
5203 );
5204
5205 let cleaned = harness.store.attempt(id).unwrap().unwrap();
5206 assert!(matches!(
5207 cleaned.outcome(),
5208 Some(AttemptOutcome::Failed {
5209 reason: FailureReason::TerminatedAfterRegistrationTimeout
5210 })
5211 ));
5212 let events = harness.events.events();
5213 let intent = events
5214 .iter()
5215 .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
5216 .unwrap();
5217 let stopped = events
5218 .iter()
5219 .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
5220 .unwrap();
5221 let concluded = events
5222 .iter()
5223 .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
5224 .unwrap();
5225 assert!(intent < stopped && stopped < concluded, "{events:?}");
5226 }
5227
5228 #[tokio::test]
5229 async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
5230 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5231 let id = AttemptId::new_random();
5232 let runtime = harness
5233 .launcher
5234 .app_paths
5235 .runtime_dir()
5236 .join("timeout-after-crash");
5237 fs::create_dir_all(&runtime).unwrap();
5238 let mut attempt =
5239 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
5240 attempt.jit_received(harness.clock.now()).unwrap();
5241 attempt.started(4242, harness.clock.now()).unwrap();
5242 harness.store.record_attempt(&attempt).unwrap();
5243 harness.processes.intent.store(true, Ordering::SeqCst);
5244 harness.processes.set_alive(false);
5245 harness
5246 .github
5247 .observe(GithubRunnerObservation::NotRegistered);
5248
5249 let replacements = harness
5250 .launcher
5251 .recover_startup(std::slice::from_ref(&harness.policy))
5252 .await
5253 .unwrap();
5254 assert_eq!(
5255 replacements,
5256 vec![ReplacementIntent {
5257 policy: harness.policy.id,
5258 previous_attempt: id,
5259 operation: "registration_timeout_replacement",
5260 }]
5261 );
5262 let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
5263 .await
5264 .unwrap();
5265 assert_eq!(consumed, replacements);
5266 assert!(
5267 RunnerLauncher::supervise(&harness.launcher, &harness.policy)
5268 .await
5269 .unwrap()
5270 .is_empty(),
5271 "startup replacement evidence must be consumed exactly once by e1"
5272 );
5273 assert!(matches!(
5274 harness.store.attempt(id).unwrap().unwrap().outcome(),
5275 Some(AttemptOutcome::Failed {
5276 reason: FailureReason::TerminatedAfterRegistrationTimeout
5277 })
5278 ));
5279 }
5280
5281 #[tokio::test]
5282 async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
5283 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5284 let id = AttemptId::new_random();
5285 let runtime = harness
5286 .launcher
5287 .app_paths
5288 .runtime_dir()
5289 .join("timeout-sync-failure");
5290 fs::create_dir_all(&runtime).unwrap();
5291 let mut attempt =
5292 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
5293 attempt.jit_received(harness.clock.now()).unwrap();
5294 attempt.started(4242, harness.clock.now()).unwrap();
5295 harness.store.record_attempt(&attempt).unwrap();
5296 harness.clock.advance_secs(11);
5297 harness.processes.set_alive(true);
5298 harness.processes.fail_intent();
5299 harness
5300 .github
5301 .observe(GithubRunnerObservation::NotRegistered);
5302
5303 assert!(
5304 harness
5305 .launcher
5306 .recover_startup(std::slice::from_ref(&harness.policy))
5307 .await
5308 .is_err()
5309 );
5310 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
5311 assert!(harness.processes.alive.load(Ordering::SeqCst));
5312 assert_eq!(
5313 harness.store.attempt(id).unwrap().unwrap().state(),
5314 AttemptState::Starting
5315 );
5316 assert!(!harness.events.events().iter().any(|event| matches!(
5317 event,
5318 AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
5319 if *attempt == id
5320 )));
5321 }
5322
5323 #[tokio::test]
5324 async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
5325 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5326 harness.ready().await;
5327 let attempt = harness.launch().await;
5328 harness
5329 .github
5330 .observe(GithubRunnerObservation::Registered { busy: false });
5331 harness.launcher.supervise(&harness.policy).await.unwrap();
5332 harness.clock.advance_secs(11);
5333 harness.processes.set_alive(false);
5334 harness
5335 .github
5336 .observe(GithubRunnerObservation::NotRegistered);
5337 harness.launcher.supervise(&harness.policy).await.unwrap();
5338 let diagnostic = fs::read_to_string(
5339 harness
5340 .launcher
5341 .diagnostics_root
5342 .join(format!("{}.log", attempt.id)),
5343 )
5344 .unwrap();
5345 assert!(diagnostic.contains("exited_idle_without_work"));
5346 assert!(!diagnostic.contains(JIT));
5347 assert!(!diagnostic.contains("ghp_"));
5348 assert!(!attempt.runtime_path().exists());
5349 }
5350
5351 #[test]
5352 fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
5353 let root = tempfile::tempdir().unwrap();
5354 let policy = fixtures::policy()
5355 .repository("octo/repo")
5356 .autoscale("home", 1)
5357 .active()
5358 .build();
5359 let runtime = root.path().join("successful");
5360 fs::create_dir_all(&runtime).unwrap();
5361 let processes = NativeProcesses::new();
5362 let config = EncodedJitConfig::new(JIT);
5363 let handoff =
5364 RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
5365 .unwrap();
5366 let mut child = native_inspection_spec()
5367 .spawn_runner_with_handoff(&handoff)
5368 .expect("native child starts");
5369 let pid = child.pid();
5370 handoff.delete().unwrap();
5371 let command_line = native_command_line(pid);
5372 assert!(
5373 !command_line.contains(JIT),
5374 "the encoded JIT configuration appeared in the native process listing"
5375 );
5376 assert_no_jit_file(&runtime);
5377 child
5378 .stop(Duration::from_secs(1))
5379 .expect("native child stops");
5380
5381 let failed_runtime = root.path().join("failed");
5382 fs::create_dir_all(&failed_runtime).unwrap();
5383 let failed = RunnerAttempt::allocate(
5384 AttemptId::new_random(),
5385 policy.id,
5386 &failed_runtime,
5387 FakeClock::default().now(),
5388 );
5389 assert!(
5390 processes
5391 .spawn(&failed, &EncodedJitConfig::new(JIT))
5392 .is_err(),
5393 "a runtime with no runner executable must fail"
5394 );
5395 assert_no_jit_file(&failed_runtime);
5396 processes
5397 .record_terminate_intent(&failed)
5398 .expect("the intent file and its directory entry are durably synced");
5399 assert_eq!(
5400 fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
5401 b"registration-timeout\n"
5402 );
5403 }
5404
5405 #[test]
5406 fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
5407 let root = tempfile::tempdir().unwrap();
5408 let policy = fixtures::policy()
5409 .repository("octo/repo")
5410 .autoscale("home", 1)
5411 .active()
5412 .build();
5413 let processes = NativeProcesses::new();
5414 processes.use_long_lived_test_listener();
5415 for (index, boundary) in [
5416 PostSpawnBoundary::HandoffDelete,
5417 PostSpawnBoundary::IdentitySerialize,
5418 PostSpawnBoundary::IdentityWrite,
5419 PostSpawnBoundary::ChildMapInsert,
5420 ]
5421 .into_iter()
5422 .enumerate()
5423 {
5424 let runtime = root.path().join(format!("post-spawn-{index}"));
5425 let bin = runtime.join("bin");
5426 fs::create_dir_all(&bin).unwrap();
5427 #[cfg(windows)]
5428 let listener = bin.join("Runner.Listener.exe");
5429 #[cfg(not(windows))]
5430 let listener = bin.join("Runner.Listener");
5431 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
5432 let attempt = RunnerAttempt::allocate(
5433 AttemptId::new_random(),
5434 policy.id,
5435 &runtime,
5436 FakeClock::default().now(),
5437 );
5438 processes.fail_post_spawn_at(boundary);
5439 let failure = processes
5440 .spawn(&attempt, &EncodedJitConfig::new(JIT))
5441 .expect_err("fault must cross the post-spawn cleanup path");
5442 assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
5443 assert!(
5444 !processes.is_alive(&attempt).unwrap(),
5445 "{boundary:?} left a child"
5446 );
5447 assert!(!NativeProcesses::identity_path(&attempt).exists());
5448 assert_no_jit_file(&runtime);
5449 }
5450 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
5451
5452 let runtime = root.path().join("identity-and-stop-fail");
5453 let bin = runtime.join("bin");
5454 fs::create_dir_all(&bin).unwrap();
5455 #[cfg(windows)]
5456 let listener = bin.join("Runner.Listener.exe");
5457 #[cfg(not(windows))]
5458 let listener = bin.join("Runner.Listener");
5459 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
5460 let attempt = RunnerAttempt::allocate(
5461 AttemptId::new_random(),
5462 policy.id,
5463 &runtime,
5464 FakeClock::default().now(),
5465 );
5466 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
5470 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
5471 processes.fail_next_post_spawn_stop();
5472 let failure = processes
5473 .spawn(&attempt, &EncodedJitConfig::new(JIT))
5474 .expect_err("the identity boundary must fail closed");
5475 assert!(failure.live_pid.is_some());
5476 assert_long_lived_listener_ready(&processes, &attempt);
5477 assert!(processes.is_alive(&attempt).unwrap());
5478 assert!(!NativeProcesses::identity_path(&attempt).exists());
5479 assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
5480 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
5481 processes.terminate(&attempt).unwrap();
5482
5483 let runtime = root.path().join("persistent-stop-and-identity-failures");
5484 let bin = runtime.join("bin");
5485 fs::create_dir_all(&bin).unwrap();
5486 #[cfg(windows)]
5487 let listener = bin.join("Runner.Listener.exe");
5488 #[cfg(not(windows))]
5489 let listener = bin.join("Runner.Listener");
5490 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
5491 let mut unresolved = RunnerAttempt::allocate(
5492 AttemptId::new_random(),
5493 policy.id,
5494 &runtime,
5495 FakeClock::default().now(),
5496 );
5497 for _ in 0..3 {
5498 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
5499 }
5500 processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
5501 let failure = processes
5502 .spawn(&unresolved, &EncodedJitConfig::new(JIT))
5503 .expect_err("bounded cleanup must return even when every stop errors");
5504 let pid = failure
5505 .live_pid
5506 .expect("the owned child remains supervised in this invocation");
5507 assert!(matches!(failure.reason, FailureReason::Other(_)));
5508 assert_long_lived_listener_ready(&processes, &unresolved);
5509 unresolved.jit_received(FakeClock::default().now()).unwrap();
5510 unresolved.started(pid, FakeClock::default().now()).unwrap();
5511 let journal = SqliteStore::open_in_memory().unwrap();
5512 journal.record_attempt(&unresolved).unwrap();
5513 let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
5514 assert_eq!(recovered.process_id(), Some(pid));
5515 assert_eq!(recovered.state(), AttemptState::Starting);
5516 assert!(processes.is_alive(&unresolved).unwrap());
5517 assert!(!NativeProcesses::identity_path(&unresolved).exists());
5518 assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
5519 assert_eq!(
5520 fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
5521 pid.to_string(),
5522 "bounded cleanup must leave durable unresolved-process evidence before returning"
5523 );
5524 assert!(
5525 NativeProcesses::new().is_alive(&recovered).is_err(),
5526 "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
5527 );
5528 processes.terminate(&unresolved).unwrap();
5529
5530 let runtime = root.path().join("post-spawn-stop-failed");
5531 let bin = runtime.join("bin");
5532 fs::create_dir_all(&bin).unwrap();
5533 #[cfg(windows)]
5534 let listener = bin.join("Runner.Listener.exe");
5535 #[cfg(not(windows))]
5536 let listener = bin.join("Runner.Listener");
5537 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
5538 let attempt = RunnerAttempt::allocate(
5539 AttemptId::new_random(),
5540 policy.id,
5541 &runtime,
5542 FakeClock::default().now(),
5543 );
5544 processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
5545 processes.fail_next_post_spawn_stop();
5546 let failure = processes
5547 .spawn(&attempt, &EncodedJitConfig::new(JIT))
5548 .expect_err("the injected stop failure must preserve supervision");
5549 let live_pid = failure
5550 .live_pid
5551 .expect("live PID is returned to the journal");
5552 assert!(!failure.retryable);
5553 assert_long_lived_listener_ready(&processes, &attempt);
5554 assert!(NativeProcesses::identity_path(&attempt).is_file());
5555 assert_eq!(
5556 NativeProcesses::read_identity(&attempt)
5557 .unwrap()
5558 .unwrap()
5559 .pid(),
5560 live_pid
5561 );
5562 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
5563 processes.terminate(&attempt).unwrap();
5564 }
5565
5566 #[test]
5567 #[ignore = "spawned only as the platform-stable native listener fixture"]
5568 fn long_lived_native_listener_helper() {
5569 let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
5570 .map(PathBuf::from)
5571 .expect("the parent supplies the readiness path");
5572 fs::write(ready, b"ready\n").expect("the listener publishes readiness");
5573 std::thread::sleep(Duration::from_secs(30));
5574 }
5575
5576 fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
5577 let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
5578 let deadline = std::time::Instant::now() + Duration::from_secs(5);
5579 loop {
5580 if ready.is_file() {
5581 assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
5582 return;
5583 }
5584 assert!(
5585 processes.is_alive(attempt).unwrap(),
5586 "the native listener exited before publishing readiness"
5587 );
5588 assert!(
5589 std::time::Instant::now() < deadline,
5590 "the native listener stayed alive but never published readiness"
5591 );
5592 std::thread::sleep(Duration::from_millis(10));
5593 }
5594 }
5595
5596 #[tokio::test]
5597 async fn every_production_launch_prunes_under_the_same_allocation_guard() {
5598 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5599 harness.ready().await;
5600 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
5601 harness.launch().await;
5602 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
5603 assert_eq!(
5604 *harness.packages.prune_currents.lock().unwrap(),
5605 vec![harness.packages.version.clone()],
5606 "the leased current version is an exclusion, never the prune target"
5607 );
5608 }
5609
5610 fn assert_no_jit_file(runtime: &Path) {
5611 for entry in fs::read_dir(runtime).unwrap() {
5612 let path = entry.unwrap().path();
5613 if path.is_file() {
5614 let bytes = fs::read(&path).unwrap();
5615 assert!(
5616 !bytes
5617 .windows(JIT.len())
5618 .any(|window| window == JIT.as_bytes()),
5619 "a JIT payload survived in a runtime file"
5620 );
5621 }
5622 }
5623 }
5624
5625 #[test]
5626 fn production_listener_command_uses_the_supported_jit_contract() {
5627 let runtime = Path::new("runtime");
5628 let spec = runner_listener_spec(PathBuf::from("Runner.Listener"), runtime);
5629 let arguments: Vec<_> = spec
5630 .arguments()
5631 .iter()
5632 .map(|argument| argument.to_string_lossy().into_owned())
5633 .collect();
5634
5635 assert_eq!(arguments, ["run"]);
5636 assert!(
5637 !arguments
5638 .iter()
5639 .any(|argument| argument == "--jit-config-file"),
5640 "the obsolete file option would be rejected by Runner.Listener 2.336.0"
5641 );
5642 }
5643
5644 #[cfg(windows)]
5645 fn native_inspection_spec() -> SpawnSpec {
5646 SpawnSpec::new("powershell.exe").args([
5647 "-NoProfile",
5648 "-NonInteractive",
5649 "-Command",
5650 "Start-Sleep -Seconds 30",
5651 ])
5652 }
5653
5654 #[cfg(unix)]
5655 fn native_inspection_spec() -> SpawnSpec {
5656 SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
5657 }
5658
5659 #[cfg(windows)]
5660 fn native_command_line(pid: u32) -> String {
5661 let output = std::process::Command::new("powershell.exe")
5662 .args([
5663 "-NoProfile",
5664 "-NonInteractive",
5665 "-Command",
5666 &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
5667 ])
5668 .output()
5669 .expect("PowerShell can inspect the native child");
5670 assert!(output.status.success(), "native process inspection failed");
5671 String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
5672 }
5673
5674 #[cfg(target_os = "linux")]
5675 fn native_command_line(pid: u32) -> String {
5676 fs::read(format!("/proc/{pid}/cmdline"))
5677 .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
5678 .expect("/proc exposes the native child command line")
5679 }
5680
5681 #[cfg(target_os = "macos")]
5682 fn native_command_line(pid: u32) -> String {
5683 let output = std::process::Command::new("ps")
5684 .args(["-o", "command=", "-p", &pid.to_string()])
5685 .output()
5686 .expect("ps can inspect the native child");
5687 assert!(output.status.success(), "native process inspection failed");
5688 String::from_utf8(output.stdout).expect("the command line is UTF-8")
5689 }
5690
5691 #[tokio::test]
5694 async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
5695 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5696 .with_persistent_workspace(2);
5697 harness.github.watch_journal(Arc::clone(&harness.store));
5698 harness.ready().await;
5699
5700 let attempt = harness.launch().await;
5701
5702 assert_eq!(
5703 attempt.workspace(),
5704 AttemptWorkspace::persistent_slot(nz(1)),
5705 "the lowest free slot is leased"
5706 );
5707 assert_eq!(attempt.runtime_path(), harness.slot_path(1));
5708 assert!(attempt.holds_slot_lease());
5709 assert_eq!(
5711 harness.attempt(attempt.id).runtime_path(),
5712 harness.slot_path(1)
5713 );
5714
5715 let facts = harness.github.registration_facts();
5719 assert_eq!(facts.len(), 1);
5720 assert_eq!(
5721 facts[0].leased_slots,
5722 vec![1],
5723 "the lease was journalled first"
5724 );
5725 assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
5726 }
5727
5728 #[tokio::test]
5729 async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
5730 let harness = Harness::new(
5731 FakeGithubLifecycle::default().fail(true),
5732 Arc::new(PersistentDemand),
5733 )
5734 .with_persistent_workspace(2);
5735 harness.ready().await;
5736
5737 harness.launch_result().await.unwrap_err();
5739 let first = harness.store.attempts().unwrap().remove(0);
5740 assert_eq!(first.state(), AttemptState::Failed);
5741 assert!(
5742 !first.state().counts_against_capacity(),
5743 "a concluded attempt is invisible to host capacity"
5744 );
5745 assert!(
5746 first.holds_slot_lease(),
5747 "and still owns its directory, so its slot is not free"
5748 );
5749
5750 let second = harness.launch().await;
5751 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
5752 assert_eq!(second.runtime_path(), harness.slot_path(2));
5753 assert_eq!(
5754 harness
5755 .store
5756 .slot_leases_for_policy(harness.policy.id)
5757 .unwrap()
5758 .len(),
5759 2
5760 );
5761 }
5762
5763 #[tokio::test]
5764 async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
5765 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5766 .with_persistent_workspace(1);
5767 harness.ready().await;
5768
5769 let first = harness.launch().await;
5770 assert_eq!(first.runtime_path(), harness.slot_path(1));
5771
5772 let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
5774 fs::create_dir_all(&checkout).unwrap();
5775 fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
5776
5777 harness.cleanup_retaining_work(first.id).await;
5778
5779 let second = harness.launch().await;
5780 assert_ne!(second.id, first.id);
5781 assert_eq!(
5782 second.workspace(),
5783 AttemptWorkspace::persistent_slot(nz(1)),
5784 "a released slot is leased again rather than skipped"
5785 );
5786 assert_eq!(
5787 second.runtime_path(),
5788 first.runtime_path(),
5789 "the same slot is the same exact path"
5790 );
5791 assert_eq!(
5792 fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
5793 "from the first job",
5794 "the retained job workspace survived the second allocation"
5795 );
5796 assert!(harness.slot_path(1).join("runner-package").exists());
5798 }
5799
5800 #[tokio::test]
5801 async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
5802 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5803 .with_persistent_workspace(2);
5804 harness.ready().await;
5805
5806 let first = harness.launch().await;
5807 let second = harness.launch().await;
5808 assert_eq!(second.runtime_path(), harness.slot_path(2));
5809 let kept = harness
5810 .slot_path(2)
5811 .join(DEFAULT_WORK_FOLDER)
5812 .join("kept.txt");
5813 fs::create_dir_all(kept.parent().unwrap()).unwrap();
5814 fs::write(&kept, b"s2 was here").unwrap();
5815 harness.cleanup_retaining_work(second.id).await;
5816
5817 harness.policy.set_max_capacity(nz(1)).unwrap();
5819 let refusal = harness.launch_result().await.unwrap_err().to_string();
5820 assert!(
5821 refusal.contains("s1 to s1"),
5822 "the refusal names the ceiling it reached: {refusal}"
5823 );
5824 assert!(
5825 harness.slot_path(2).exists() && kept.exists(),
5826 "lowering capacity deletes nothing; the higher slot is merely unusable"
5827 );
5828
5829 harness.policy.set_max_capacity(nz(2)).unwrap();
5831 let third = harness.launch().await;
5832 assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
5833 assert_eq!(third.runtime_path(), harness.slot_path(2));
5834 assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
5835 assert!(first.holds_slot_lease(), "s1 was never disturbed");
5836 }
5837
5838 #[tokio::test]
5839 async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
5840 for policy in [
5841 fixtures::policy()
5842 .organization("octo")
5843 .autoscale("home", 2)
5844 .active()
5845 .build(),
5846 fixtures::policy()
5847 .repository("octo/repo")
5848 .autoscale("home", 2)
5849 .active()
5850 .build(),
5851 ] {
5852 let mut harness =
5853 Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5854 .with_host_runner_root();
5855 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
5856 harness.policy = policy;
5857 harness.ready().await;
5858
5859 let attempt = harness.launch().await;
5860 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
5861 assert_eq!(attempt.workspace().slot_number(), None);
5862 assert!(!attempt.holds_slot_lease());
5863 assert_eq!(
5864 attempt.runtime_path().parent().unwrap(),
5865 harness.host_root(),
5866 "a disposable attempt is a child of the host root, never of a slot"
5867 );
5868 assert!(
5869 harness
5870 .store
5871 .slot_leases_for_policy(harness.policy.id)
5872 .unwrap()
5873 .is_empty()
5874 );
5875 }
5876 }
5877
5878 #[tokio::test]
5879 async fn two_concurrent_allocations_never_share_a_slot() {
5880 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5881 .with_persistent_workspace(2);
5882 harness.github.watch_journal(Arc::clone(&harness.store));
5883 harness.ready().await;
5884
5885 let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
5889 let first = first.unwrap();
5890 let second = second.unwrap();
5891
5892 let slots: BTreeSet<u16> = [&first, &second]
5893 .iter()
5894 .map(|attempt| {
5895 attempt
5896 .workspace()
5897 .slot_number()
5898 .expect("a persistent attempt leases a slot")
5899 })
5900 .collect();
5901 assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
5902 assert_ne!(first.runtime_path(), second.runtime_path());
5903 assert_eq!(
5904 harness
5905 .store
5906 .slot_leases_for_policy(harness.policy.id)
5907 .unwrap()
5908 .len(),
5909 2
5910 );
5911
5912 let facts = harness.github.registration_facts();
5917 assert_eq!(facts.len(), 2);
5918 for fact in facts {
5919 let attempt = [&first, &second]
5920 .into_iter()
5921 .find(|attempt| runner_name(attempt.id) == fact.runner_name)
5922 .expect("every registration belongs to one of the two attempts");
5923 let slot = attempt
5924 .workspace()
5925 .slot_number()
5926 .expect("a persistent attempt leases a slot");
5927 assert!(
5928 fact.leased_slots.contains(&slot),
5929 "a JIT request never precedes its own lease: s{slot} not in {:?}",
5930 fact.leased_slots
5931 );
5932 assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
5933 }
5934 }
5935
5936 #[tokio::test]
5937 async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
5938 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5939 .with_persistent_workspace(2);
5940 harness.ready().await;
5941 let first = harness.launch().await;
5942
5943 let clash = RunnerAttempt::allocate_in(
5947 AttemptId::new_random(),
5948 harness.policy.id,
5949 first.runtime_path(),
5950 AttemptWorkspace::persistent_slot(nz(1)),
5951 harness.clock.now(),
5952 );
5953 assert!(matches!(
5954 harness.store.record_attempt(&clash).unwrap_err(),
5955 StoreError::SlotAlreadyLeased { slot: 1, .. }
5956 ));
5957
5958 let error = harness.launcher.record_allocation(&clash).unwrap_err();
5961 let rendered = error.to_string();
5962 assert!(rendered.contains("slot s1"), "{rendered}");
5963 assert!(rendered.contains("nothing was written"), "{rendered}");
5964 assert_eq!(
5965 harness.store.attempts().unwrap().len(),
5966 1,
5967 "the losing allocator journalled nothing"
5968 );
5969 }
5970
5971 #[test]
5972 fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
5973 let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
5974 slots
5975 .iter()
5976 .map(|slot| {
5977 RunnerAttempt::allocate_in(
5978 AttemptId::new_random(),
5979 fixtures::POLICY_ID,
5980 format!("/srv/rman/acme/s{slot}"),
5981 AttemptWorkspace::persistent_slot(nz(*slot)),
5982 fixtures::created_at(),
5983 )
5984 })
5985 .collect()
5986 };
5987
5988 assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
5989 assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
5990 assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
5992 assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
5994 assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
5995 let ephemeral = vec![RunnerAttempt::allocate(
5997 AttemptId::new_random(),
5998 fixtures::POLICY_ID,
5999 "/srv/rman/host/abc",
6000 fixtures::created_at(),
6001 )];
6002 assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
6003 }
6004
6005 #[test]
6006 fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
6007 let root = tempfile::tempdir().unwrap();
6008 let slot = root.path().join("s1");
6009 fs::create_dir(&slot).unwrap();
6010 accept_reusable_slot(&slot).expect("an empty slot is reusable");
6011
6012 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6013 accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
6014
6015 fs::create_dir(slot.join("bin")).unwrap();
6018 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
6019 let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
6020 assert!(refusal.contains("bin"), "{refusal}");
6021 assert!(refusal.contains(".github-runner-id"), "{refusal}");
6022
6023 let file_work = root.path().join("s2");
6025 fs::create_dir(&file_work).unwrap();
6026 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
6027 assert!(accept_reusable_slot(&file_work).is_err());
6028 }
6029
6030 #[cfg(unix)]
6031 #[test]
6032 fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
6033 let root = tempfile::tempdir().unwrap();
6037 let elsewhere = root.path().join("elsewhere");
6038 fs::create_dir(&elsewhere).unwrap();
6039
6040 let slot = root.path().join("s1");
6041 fs::create_dir(&slot).unwrap();
6042 std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6043 assert!(accept_reusable_slot(&slot).is_err());
6044
6045 let linked_slot = root.path().join("s2");
6046 std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
6047 assert!(create_or_validate_slot(&linked_slot).is_err());
6048 }
6049
6050 #[test]
6051 fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
6052 let root = tempfile::tempdir().unwrap();
6053 let occupied = root.path().join("s1");
6054 fs::write(&occupied, b"an operator's file").unwrap();
6055 let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
6056 assert!(refusal.contains("is not a directory"), "{refusal}");
6057 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
6058
6059 let fresh = root.path().join("s2");
6060 create_or_validate_slot(&fresh).expect("a missing slot is created");
6061 assert!(fresh.is_dir());
6062 create_or_validate_slot(&fresh).expect("an existing directory is accepted");
6063 }
6064
6065 #[test]
6066 fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
6067 assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
6068 assert!(!is_work_folder(OsStr::new("_work2")));
6069 assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
6073 }
6074
6075 #[test]
6076 fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
6077 let root = tempfile::tempdir().unwrap();
6078 let package = root.path().join("package");
6079 fs::create_dir_all(package.join("bin")).unwrap();
6080 fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
6081 fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
6083
6084 let slot = root.path().join("s1");
6085 let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
6086 fs::create_dir_all(&retained).unwrap();
6087 fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
6088
6089 copy_package_tree(&package, &slot).expect("the package lays out around `_work`");
6090 assert!(slot.join("bin").join("Runner.Listener").exists());
6091 assert!(
6092 slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
6093 "the guard is top-level only"
6094 );
6095 assert_eq!(
6096 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
6097 "from the first job"
6098 );
6099
6100 fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
6102 let error = copy_package_tree(&package, &slot).unwrap_err();
6103 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
6104 assert_eq!(
6105 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
6106 "from the first job"
6107 );
6108 }
6109
6110 #[test]
6111 fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
6112 let root = tempfile::tempdir().unwrap();
6113
6114 let slot = root.path().join("s1");
6115 let retained = slot.join(DEFAULT_WORK_FOLDER);
6116 fs::create_dir_all(retained.join("repo")).unwrap();
6117 fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
6118 fs::create_dir_all(slot.join("bin")).unwrap();
6119 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
6120 let persistent = RunnerAttempt::allocate_in(
6121 AttemptId::new_random(),
6122 fixtures::POLICY_ID,
6123 &slot,
6124 AttemptWorkspace::persistent_slot(nz(1)),
6125 fixtures::created_at(),
6126 );
6127
6128 remove_materialized_package(&persistent).unwrap();
6129 assert!(slot.is_dir(), "the slot itself is not removed");
6130 assert!(!slot.join("bin").exists());
6131 assert!(!slot.join(".github-runner-id").exists());
6132 assert_eq!(
6133 fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
6134 "kept"
6135 );
6136
6137 let disposable_path = root.path().join("abcdef012345");
6138 fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
6139 let disposable = RunnerAttempt::allocate(
6140 AttemptId::new_random(),
6141 fixtures::POLICY_ID,
6142 &disposable_path,
6143 fixtures::created_at(),
6144 );
6145 remove_materialized_package(&disposable).unwrap();
6146 assert!(
6147 !disposable_path.exists(),
6148 "a disposable directory is still removed whole"
6149 );
6150 }
6151
6152 fn litter_the_slot(slot: &Path) {
6162 for directory in ["bin", "externals", "_diag"] {
6163 fs::create_dir_all(slot.join(directory)).unwrap();
6164 }
6165 fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
6166 for file in SENSITIVE_SLOT_ENTRIES
6167 .iter()
6168 .filter(|entry| !slot.join(entry).is_dir())
6169 {
6170 fs::write(slot.join(file), b"runner state").unwrap();
6171 }
6172 fs::write(
6174 slot.join(format!(
6175 "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
6176 RestrictiveHandoff::NAME_PREFIX
6177 )),
6178 JIT.as_bytes(),
6179 )
6180 .unwrap();
6181 }
6182
6183 fn retain_under_work(slot: &Path) -> PathBuf {
6185 let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
6186 fs::create_dir_all(&checkout).unwrap();
6187 let marker = checkout.join("build-output.bin");
6188 fs::write(&marker, RETAINED).unwrap();
6189 marker
6190 }
6191
6192 const RETAINED: &str = "a Git-ignored build output the next job reuses";
6194
6195 fn entries_of(directory: &Path) -> Vec<String> {
6197 let mut names: Vec<String> = fs::read_dir(directory)
6198 .unwrap()
6199 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
6200 .collect();
6201 names.sort();
6202 names
6203 }
6204
6205 fn only_the_job_workspace() -> Vec<String> {
6207 vec![DEFAULT_WORK_FOLDER.to_owned()]
6208 }
6209
6210 #[cfg(unix)]
6211 #[test]
6212 fn disposable_tree_removal_does_not_open_a_dotnet_diagnostic_fifo() {
6213 use std::sync::mpsc;
6214
6215 let temporary = tempfile::tempdir().unwrap();
6216 let tree = temporary.path().join("attempt");
6217 let diagnostic = tree.join("tmp/clr-debug-pipe-runner-in");
6218 fs::create_dir_all(diagnostic.parent().unwrap()).unwrap();
6219 assert!(
6220 std::process::Command::new("mkfifo")
6221 .arg(&diagnostic)
6222 .status()
6223 .unwrap()
6224 .success()
6225 );
6226
6227 let (finished, result) = mpsc::channel();
6228 std::thread::spawn(move || {
6229 let removed = remove_runtime_tree(&tree);
6230 let _ = finished.send(removed);
6231 });
6232
6233 result
6234 .recv_timeout(Duration::from_secs(2))
6235 .expect("runtime deletion must not wait for a FIFO peer")
6236 .unwrap();
6237 assert!(!diagnostic.exists());
6238 }
6239
6240 struct BlockedDeletion {
6256 directory: PathBuf,
6257 #[cfg(windows)]
6258 _handle: fs::File,
6259 }
6260
6261 impl BlockedDeletion {
6262 const HELD: &'static str = "held-open";
6263
6264 fn inject(directory: &Path) -> Option<Self> {
6267 #[cfg(unix)]
6268 if !Self::refusal_is_possible() {
6269 return None;
6270 }
6271 fs::create_dir_all(directory).unwrap();
6272 fs::write(
6273 directory.join(Self::HELD),
6274 b"a file the scrub cannot remove",
6275 )
6276 .unwrap();
6277 #[cfg(windows)]
6278 let handle = {
6279 use std::os::windows::fs::OpenOptionsExt;
6280
6281 fs::OpenOptions::new()
6282 .read(true)
6283 .share_mode(0)
6284 .open(directory.join(Self::HELD))
6285 .expect("the blocking handle opens")
6286 };
6287 #[cfg(unix)]
6288 Self::set_mode(directory, 0o555);
6289 Some(Self {
6290 directory: directory.to_path_buf(),
6291 #[cfg(windows)]
6292 _handle: handle,
6293 })
6294 }
6295
6296 fn release(self) {
6297 drop(self);
6298 }
6299
6300 #[cfg(unix)]
6301 fn refusal_is_possible() -> bool {
6302 let probe = tempfile::tempdir().unwrap();
6303 let directory = probe.path().join("probe");
6304 fs::create_dir(&directory).unwrap();
6305 fs::write(directory.join("file"), b"probe").unwrap();
6306 Self::set_mode(&directory, 0o555);
6307 let refused = fs::remove_dir_all(&directory).is_err();
6308 Self::set_mode(&directory, 0o755);
6309 refused
6310 }
6311
6312 #[cfg(unix)]
6313 fn set_mode(directory: &Path, mode: u32) {
6314 use std::os::unix::fs::PermissionsExt;
6315
6316 let mut permissions = fs::metadata(directory).unwrap().permissions();
6317 permissions.set_mode(mode);
6318 fs::set_permissions(directory, permissions).unwrap();
6319 }
6320 }
6321
6322 impl Drop for BlockedDeletion {
6323 fn drop(&mut self) {
6324 #[cfg(unix)]
6325 Self::set_mode(&self.directory, 0o755);
6326 #[cfg(not(unix))]
6327 let _ = &self.directory;
6328 }
6329 }
6330
6331 #[tokio::test]
6332 async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
6333 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6334 .with_persistent_workspace(1);
6335 harness.ready().await;
6336
6337 let first = harness.launch().await;
6338 let slot = harness.slot_path(1);
6339 assert_eq!(first.runtime_path(), slot);
6340 assert_eq!(
6341 read_runner_id(&slot),
6342 Some(73),
6343 "the attempt registered, so its identity is on disk"
6344 );
6345 let marker = retain_under_work(&slot);
6346 litter_the_slot(&slot);
6347
6348 harness.cleanup_retaining_work(first.id).await;
6349
6350 assert_eq!(entries_of(&slot), only_the_job_workspace());
6353 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
6354 assert_eq!(
6355 read_runner_id(&slot),
6356 None,
6357 "the first attempt's registration identity is gone before the second starts"
6358 );
6359 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
6360 assert!(!harness.attempt(first.id).holds_slot_lease());
6361
6362 let second = harness.launch().await;
6363 assert_ne!(second.id, first.id);
6364 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
6365 assert_eq!(
6366 second.runtime_path(),
6367 slot,
6368 "the same slot, so the same retained `_work`"
6369 );
6370 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
6371 }
6372
6373 #[tokio::test]
6374 async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
6375 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6376 .with_persistent_workspace(1);
6377 harness.ready().await;
6378 let attempt = harness.launch().await;
6379 let slot = harness.slot_path(1);
6380 let marker = retain_under_work(&slot);
6381 litter_the_slot(&slot);
6382 harness.conclude(attempt.id);
6383
6384 harness
6390 .store
6391 .remove_policy(harness.policy.id, harness.policy.revision())
6392 .unwrap();
6393 assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
6394
6395 harness
6396 .launcher
6397 .clean(attempt.id)
6398 .await
6399 .expect("journal facts alone are enough to clean the slot");
6400
6401 assert_eq!(entries_of(&slot), only_the_job_workspace());
6402 assert!(marker.exists());
6403 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
6404 }
6405
6406 #[tokio::test]
6407 async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
6408 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6409 .with_persistent_workspace(2);
6410 harness.ready().await;
6411 let first = harness.launch().await;
6412 let slot = harness.slot_path(1);
6413 let marker = retain_under_work(&slot);
6414 litter_the_slot(&slot);
6415 harness.conclude(first.id);
6416
6417 let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
6418 eprintln!(
6419 "skipped: this account cannot be refused a deletion, so no partial deletion can \
6420 be injected"
6421 );
6422 return;
6423 };
6424
6425 let refusal = harness
6426 .launcher
6427 .clean(first.id)
6428 .await
6429 .expect_err("a deletion that failed may not report a cleaned slot");
6430 let rendered = refusal.reason.to_string();
6431 assert!(rendered.contains("could not be removed"), "{rendered}");
6432
6433 let held = harness.attempt(first.id);
6434 assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
6435 assert!(held.holds_slot_lease(), "so the slot is still leased");
6436 assert!(
6437 !held.state().counts_against_capacity(),
6438 "and a concluded attempt still costs the host no capacity"
6439 );
6440
6441 let restarted = harness.restart();
6446 restarted
6447 .recover_startup(std::slice::from_ref(&harness.policy))
6448 .await
6449 .expect("one quarantined slot does not stop the host recovering");
6450 assert_eq!(
6451 harness.attempt(first.id).state(),
6452 AttemptState::Failed,
6453 "the quarantine survived the restart"
6454 );
6455 assert!(
6456 harness
6457 .reconcile_events
6458 .events()
6459 .iter()
6460 .any(|event| matches!(
6461 event,
6462 LifecycleEvent::AttemptCleanFailed {
6463 reason: "slot_entry_could_not_be_removed",
6464 ..
6465 }
6466 )),
6467 "the refusal is reported rather than retried in silence"
6468 );
6469
6470 let guard = harness.allocation_lock.acquire().await.unwrap();
6473 let second = restarted
6474 .launch(LaunchRequest {
6475 host: &harness.host,
6476 policy: &harness.policy,
6477 allocation_guard: &guard,
6478 })
6479 .await
6480 .expect("the host can still launch");
6481 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
6482 drop(guard);
6483
6484 block.release();
6487 restarted
6488 .clean(first.id)
6489 .await
6490 .expect("the retried cleanup completes");
6491 assert_eq!(entries_of(&slot), only_the_job_workspace());
6492 assert!(marker.exists());
6493 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
6494 }
6495
6496 #[tokio::test]
6507 async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
6508 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6509 harness.ready().await;
6510 let attempt = harness.launch().await;
6511 let runtime = attempt.runtime_path().to_path_buf();
6512 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
6513 harness.conclude(attempt.id);
6514
6515 let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
6516 eprintln!(
6517 "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
6518 );
6519 return;
6520 };
6521
6522 let refusal = harness
6523 .launcher
6524 .clean(attempt.id)
6525 .await
6526 .expect_err("a deletion that failed may not report a removed workspace");
6527 let rendered = refusal.reason.to_string();
6528 assert!(
6529 rendered.contains("could not be removed"),
6530 "the refusal names what happened: {rendered}"
6531 );
6532 assert_ne!(
6533 harness.attempt(attempt.id).state(),
6534 AttemptState::Cleaned,
6535 "an attempt whose directory is still on disk is not cleaned"
6536 );
6537 assert!(
6538 runtime.is_dir(),
6539 "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
6540 );
6541
6542 let restarted = harness.restart();
6543 restarted
6544 .recover_startup(std::slice::from_ref(&harness.policy))
6545 .await
6546 .expect("one locked ephemeral workspace does not stop startup recovery");
6547 assert!(
6548 harness
6549 .reconcile_events
6550 .events()
6551 .iter()
6552 .any(|event| matches!(
6553 event,
6554 LifecycleEvent::AttemptCleanFailed {
6555 attempt: failed_attempt,
6556 reason: "ephemeral_workspace_could_not_be_removed",
6557 ..
6558 } if *failed_attempt == attempt.id
6559 )),
6560 "the deferred cleanup remains visible while the daemon keeps running"
6561 );
6562
6563 block.release();
6566 harness
6567 .launcher
6568 .clean(attempt.id)
6569 .await
6570 .expect("the retried cleanup completes");
6571 assert!(!runtime.exists(), "the whole attempt directory goes");
6572 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
6573 }
6574
6575 #[tokio::test]
6576 async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
6577 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6578 .with_persistent_workspace(1);
6579 harness.ready().await;
6580 let first = harness.launch().await;
6581 let slot = harness.slot_path(1);
6582 let marker = retain_under_work(&slot);
6583 harness.cleanup_retaining_work(first.id).await;
6584
6585 harness
6589 .policy
6590 .set_workspace_policy(WorkspacePolicy::Ephemeral)
6591 .unwrap();
6592
6593 let second = harness.launch().await;
6594 assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
6595 assert_eq!(
6596 second.runtime_path().parent().unwrap(),
6597 harness.host_root(),
6598 "a disposable attempt is a child of the host root"
6599 );
6600 assert!(slot.is_dir(), "the old slot is left where it stands");
6601 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
6602
6603 harness.conclude(second.id);
6606 harness.launcher.clean(second.id).await.unwrap();
6607 assert!(!second.runtime_path().exists());
6608 assert!(marker.exists());
6609 }
6610
6611 #[tokio::test]
6612 async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
6613 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6614 .with_persistent_workspace(1);
6615 harness.ready().await;
6616 let slot = harness.slot_path(1);
6617 fs::create_dir_all(&slot).unwrap();
6618 let marker = retain_under_work(&slot);
6619 litter_the_slot(&slot);
6620
6621 let id = AttemptId::new_random();
6622 let mut attempt = RunnerAttempt::allocate_in(
6623 id,
6624 harness.policy.id,
6625 &slot,
6626 AttemptWorkspace::persistent_slot(nz(1)),
6627 harness.clock.now(),
6628 );
6629 attempt.jit_received(harness.clock.now()).unwrap();
6630 attempt.started(4242, harness.clock.now()).unwrap();
6631 harness.store.record_attempt(&attempt).unwrap();
6632 harness.clock.advance_secs(11);
6633 harness.processes.set_alive(true);
6634 harness
6635 .github
6636 .observe(GithubRunnerObservation::NotRegistered);
6637
6638 harness.launcher.supervise(&harness.policy).await.unwrap();
6639
6640 let actions = harness.processes.actions.lock().unwrap().clone();
6644 let intent = actions
6645 .iter()
6646 .position(|action| *action == "terminate_intent")
6647 .unwrap();
6648 let signal = actions
6649 .iter()
6650 .position(|action| *action == "terminate")
6651 .unwrap();
6652 assert!(intent < signal, "{actions:?}");
6653 assert!(!harness.processes.alive.load(Ordering::SeqCst));
6654
6655 let cleaned = harness.attempt(id);
6656 assert_eq!(cleaned.state(), AttemptState::Cleaned);
6657 assert!(matches!(
6658 cleaned.outcome(),
6659 Some(AttemptOutcome::Failed {
6660 reason: FailureReason::TerminatedAfterRegistrationTimeout
6661 })
6662 ));
6663 assert_eq!(entries_of(&slot), only_the_job_workspace());
6664 assert!(marker.exists());
6665 }
6666
6667 #[test]
6668 fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
6669 let root = tempfile::tempdir().unwrap();
6670 let slot = root.path().join("s1");
6671 fs::create_dir(&slot).unwrap();
6672 let marker = retain_under_work(&slot);
6673 litter_the_slot(&slot);
6674 fs::write(slot.join("runner-package"), b"verified").unwrap();
6675
6676 scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
6677 verify_slot_scrubbed(&slot).expect("and proves it afterwards");
6678
6679 assert_eq!(entries_of(&slot), only_the_job_workspace());
6680 assert!(marker.exists());
6681 }
6682
6683 #[test]
6684 fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
6685 let slot = Path::new("/runners/s1");
6686
6687 let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
6690 assert!(counted.contains("2 entries other than"), "{counted}");
6691 assert!(counted.contains("including `bin`"), "{counted}");
6692 assert_eq!(
6693 residue_detail(slot, 1, &[]),
6694 format!(
6695 "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
6696 slot.display()
6697 )
6698 );
6699
6700 let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
6705 assert!(!raced.contains('0'), "{raced}");
6706 assert!(raced.contains("reported nothing but"), "{raced}");
6707 assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
6708 }
6709
6710 #[test]
6711 fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
6712 let root = tempfile::tempdir().unwrap();
6713 let slot = root.path().join("s1");
6714 fs::create_dir(&slot).unwrap();
6715 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6716 verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
6717
6718 for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
6722 fs::write(slot.join(survivor), b"left behind").unwrap();
6723 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
6724 assert_eq!(quarantine.refusal, SlotRefusal::Residue);
6725 assert!(
6726 quarantine.detail.contains(&format!("`{survivor}`")),
6727 "{quarantine}"
6728 );
6729 fs::remove_file(slot.join(survivor)).unwrap();
6730 }
6731
6732 let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
6735 fs::write(&handoff, JIT.as_bytes()).unwrap();
6736 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
6737 assert!(
6738 quarantine.detail.contains("an encoded JIT handoff"),
6739 "{quarantine}"
6740 );
6741 assert!(!quarantine.detail.contains(JIT), "{quarantine}");
6742 fs::remove_file(&handoff).unwrap();
6743
6744 fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
6748 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
6749 assert!(
6750 quarantine.detail.contains("1 entry other than"),
6751 "{quarantine}"
6752 );
6753 assert!(
6754 !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
6755 "{quarantine}"
6756 );
6757 }
6758
6759 #[test]
6760 fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
6761 let root = tempfile::tempdir().unwrap();
6762 let configured =
6763 LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
6764 let slot = configured.as_path().join("s1");
6765 fs::create_dir(&slot).unwrap();
6766
6767 verify_journalled_slot(&slot, nz(1), Some(&configured))
6768 .expect("the journalled slot agrees");
6769 verify_journalled_slot(&slot, nz(1), None)
6770 .expect("and a policy that is gone removes a check, not the ability to clean");
6771
6772 assert_eq!(
6775 verify_journalled_slot(&slot, nz(2), None)
6776 .unwrap_err()
6777 .refusal,
6778 SlotRefusal::NotTheJournalledSlot
6779 );
6780 for stray in ["s1/nested", "not-a-slot", "s01"] {
6781 let path = configured.as_path().join(stray);
6782 assert_eq!(
6783 verify_journalled_slot(&path, nz(1), None)
6784 .unwrap_err()
6785 .refusal,
6786 SlotRefusal::NotTheJournalledSlot,
6787 "{}",
6788 path.display()
6789 );
6790 }
6791
6792 let elsewhere = tempfile::tempdir().unwrap();
6795 let other =
6796 LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
6797 assert_eq!(
6798 verify_journalled_slot(&slot, nz(1), Some(&other))
6799 .unwrap_err()
6800 .refusal,
6801 SlotRefusal::PolicyRootDisagrees
6802 );
6803 }
6804
6805 #[cfg(unix)]
6806 #[test]
6807 fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
6808 let root = tempfile::tempdir().unwrap();
6812 let outside = root.path().join("operator-data");
6813 fs::create_dir(&outside).unwrap();
6814 let sentinel = outside.join("do-not-delete.txt");
6815 fs::write(
6816 &sentinel,
6817 b"an operator's data, outside every approved root",
6818 )
6819 .unwrap();
6820
6821 let slot = root.path().join("s1");
6822 fs::create_dir(&slot).unwrap();
6823 fs::create_dir(slot.join("bin")).unwrap();
6824 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6825
6826 let quarantine = scrub_slot_entries(&slot).unwrap_err();
6827 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
6828 assert!(
6829 sentinel.exists(),
6830 "the deletion followed the link out of the slot"
6831 );
6832 assert!(outside.is_dir());
6833 assert!(
6834 slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
6835 "the substituted link is left for the operator, never unlinked as if it were ours"
6836 );
6837
6838 let file_work = root.path().join("s2");
6842 fs::create_dir(&file_work).unwrap();
6843 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
6844 assert_eq!(
6845 scrub_slot_entries(&file_work).unwrap_err().refusal,
6846 SlotRefusal::WorkNotADirectory
6847 );
6848 }
6849
6850 #[cfg(unix)]
6851 #[test]
6852 fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
6853 let root = tempfile::tempdir().unwrap();
6854 let outside = root.path().join("operator-data");
6855 fs::create_dir(&outside).unwrap();
6856 let sentinel = outside.join("do-not-delete.txt");
6857 fs::write(
6858 &sentinel,
6859 b"an operator's data, outside every approved root",
6860 )
6861 .unwrap();
6862
6863 let inside = root.path().join("inside");
6866 fs::create_dir(&inside).unwrap();
6867 let slot = inside.join("s1");
6868 std::os::unix::fs::symlink(&outside, &slot).unwrap();
6869
6870 assert_eq!(
6871 verify_journalled_slot(&slot, nz(1), None)
6872 .unwrap_err()
6873 .refusal,
6874 SlotRefusal::Containment
6875 );
6876 assert!(sentinel.exists());
6877 assert!(
6878 slot.symlink_metadata().is_ok(),
6879 "the link is left for the operator rather than removed as if it were ours"
6880 );
6881 }
6882
6883 #[cfg(windows)]
6893 #[test]
6894 fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
6895 let root = tempfile::tempdir().unwrap();
6896 let outside = root.path().join("operator-data");
6897 fs::create_dir(&outside).unwrap();
6898 let sentinel = outside.join("do-not-delete.txt");
6899 fs::write(
6900 &sentinel,
6901 b"an operator's data, outside every approved root",
6902 )
6903 .unwrap();
6904
6905 let inside = root.path().join("inside");
6908 fs::create_dir(&inside).unwrap();
6909 let slot = inside.join("s1");
6910 let Some(()) = plant_junction(&slot, &outside) else {
6911 eprintln!("skipped: this machine would not create a directory junction");
6912 return;
6913 };
6914
6915 assert_eq!(
6916 verify_journalled_slot(&slot, nz(1), None)
6917 .unwrap_err()
6918 .refusal,
6919 SlotRefusal::Containment
6920 );
6921 assert!(
6922 sentinel.exists(),
6923 "the refusal resolved the junction and reached the operator's data"
6924 );
6925 assert!(
6926 slot.symlink_metadata().is_ok(),
6927 "the junction is left for the operator rather than removed as if it were ours"
6928 );
6929 }
6930
6931 #[cfg(windows)]
6940 fn plant_junction(link: &Path, target: &Path) -> Option<()> {
6941 let made = std::process::Command::new("cmd")
6942 .arg("/C")
6943 .arg("mklink")
6944 .arg("/J")
6945 .arg(link)
6946 .arg(target)
6947 .output()
6948 .ok()?;
6949 (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
6950 }
6951
6952 #[cfg(windows)]
6953 #[test]
6954 fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
6955 let root = tempfile::tempdir().unwrap();
6956 let outside = root.path().join("operator-data");
6957 fs::create_dir(&outside).unwrap();
6958 let sentinel = outside.join("do-not-delete.txt");
6959 fs::write(
6960 &sentinel,
6961 b"an operator's data, outside every approved root",
6962 )
6963 .unwrap();
6964
6965 let slot = root.path().join("s1");
6966 fs::create_dir(&slot).unwrap();
6967 fs::create_dir(slot.join("bin")).unwrap();
6968 let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
6969 eprintln!("skipped: this machine would not create a directory junction");
6970 return;
6971 };
6972
6973 let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6977 assert!(is_link_like(&work), "a junction is a reparse point");
6978 let quarantine = scrub_slot_entries(&slot).unwrap_err();
6979 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
6980 assert!(
6981 sentinel.exists(),
6982 "the deletion followed the junction out of the slot"
6983 );
6984 assert!(outside.is_dir());
6985
6986 let elsewhere = root.path().join("s2");
6989 fs::create_dir(&elsewhere).unwrap();
6990 fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
6991 if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
6992 scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
6993 verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
6994 assert!(sentinel.exists(), "the junction was followed, not unlinked");
6995 assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
6996 }
6997 }
6998
6999 #[cfg(unix)]
7000 #[tokio::test]
7001 async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
7002 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7003 .with_persistent_workspace(2);
7004 harness.ready().await;
7005 let first = harness.launch().await;
7006 let slot = harness.slot_path(1);
7007 harness.conclude(first.id);
7008
7009 let outside = harness._root.path().join("operator-data");
7010 fs::create_dir_all(&outside).unwrap();
7011 let sentinel = outside.join("do-not-delete.txt");
7012 fs::write(&sentinel, b"outside every approved root").unwrap();
7013 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
7014
7015 harness
7016 .launcher
7017 .clean(first.id)
7018 .await
7019 .expect_err("a slot whose `_work` was substituted is quarantined");
7020 assert!(sentinel.exists());
7021
7022 let held = harness.attempt(first.id);
7023 assert_eq!(held.state(), AttemptState::Failed);
7024 assert!(held.holds_slot_lease());
7025
7026 let second = harness.launch().await;
7028 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
7029 }
7030
7031 #[test]
7032 fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
7033 let root = tempfile::tempdir().unwrap();
7034 let occupied = root.path().join("s1");
7035 fs::write(&occupied, b"an operator's file").unwrap();
7036 verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
7039 assert_eq!(
7040 slot_is_present(&occupied).unwrap_err().refusal,
7041 SlotRefusal::SlotNotADirectory
7042 );
7043 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
7044
7045 assert!(!slot_is_present(&root.path().join("s2")).unwrap());
7048 let present = root.path().join("s3");
7049 fs::create_dir(&present).unwrap();
7050 assert!(slot_is_present(&present).unwrap());
7051 }
7052
7053 #[test]
7054 fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
7055 let root = tempfile::tempdir().unwrap();
7056
7057 let disposable = root.path().join("abcdef012345");
7061 fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
7062 let ephemeral = RunnerAttempt::allocate(
7063 AttemptId::new_random(),
7064 fixtures::POLICY_ID,
7065 &disposable,
7066 fixtures::created_at(),
7067 );
7068 remove_materialized_package(&ephemeral).unwrap();
7069 assert!(!disposable.exists());
7070
7071 let slot = root.path().join("s1");
7073 fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
7074 fs::create_dir_all(slot.join("bin")).unwrap();
7075 let persistent = RunnerAttempt::allocate_in(
7076 AttemptId::new_random(),
7077 fixtures::POLICY_ID,
7078 &slot,
7079 AttemptWorkspace::persistent_slot(nz(1)),
7080 fixtures::created_at(),
7081 );
7082 remove_materialized_package(&persistent).unwrap();
7083 assert_eq!(entries_of(&slot), only_the_job_workspace());
7084 assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
7085 }
7086
7087 #[test]
7088 fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
7089 let refusals = [
7090 SlotRefusal::NotTheJournalledSlot,
7091 SlotRefusal::PolicyRootDisagrees,
7092 SlotRefusal::Containment,
7093 SlotRefusal::SlotNotADirectory,
7094 SlotRefusal::Enumeration,
7095 SlotRefusal::WorkNotADirectory,
7096 SlotRefusal::Deletion,
7097 SlotRefusal::Residue,
7098 ];
7099 let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
7100 assert_eq!(
7101 classes.len(),
7102 refusals.len(),
7103 "an event class shared by two refusals tells an operator less than it appears to"
7104 );
7105 for refusal in refusals {
7106 assert!(
7109 refusal
7110 .class()
7111 .chars()
7112 .all(|c| c.is_ascii_lowercase() || c == '_'),
7113 "{}",
7114 refusal.class()
7115 );
7116 assert!(
7117 refusal.remediation().contains("slot lease"),
7118 "every refusal has to say the lease is still held: {}",
7119 refusal.class()
7120 );
7121 }
7122 }
7123
7124 #[test]
7125 fn copy_package_tree_copies_files_and_preserves_paths_with_spaces() {
7126 let root = tempfile::tempdir().unwrap();
7127 let source = root.path().join("source with spaces");
7128 let dest = root.path().join("dest with spaces");
7129
7130 fs::create_dir_all(&source).unwrap();
7131 fs::write(source.join("file1.txt"), b"hello").unwrap();
7132
7133 let nested = source.join("nested dir");
7134 fs::create_dir_all(&nested).unwrap();
7135 fs::write(nested.join("file2.txt"), b"world").unwrap();
7136
7137 let nested_work = nested.join(DEFAULT_WORK_FOLDER);
7139 fs::create_dir_all(&nested_work).unwrap();
7140 fs::write(nested_work.join("allowed.txt"), b"allowed").unwrap();
7141
7142 copy_package_tree(&source, &dest).unwrap();
7143
7144 assert_eq!(fs::read_to_string(dest.join("file1.txt")).unwrap(), "hello");
7145 assert_eq!(
7146 fs::read_to_string(dest.join("nested dir").join("file2.txt")).unwrap(),
7147 "world"
7148 );
7149 assert_eq!(
7150 fs::read_to_string(
7151 dest.join("nested dir")
7152 .join(DEFAULT_WORK_FOLDER)
7153 .join("allowed.txt")
7154 )
7155 .unwrap(),
7156 "allowed"
7157 );
7158 }
7159
7160 #[test]
7161 fn every_field_name_the_attempt_sink_emits_is_allowed() {
7162 use runner_manager_platform::logging::is_field_allowed;
7163
7164 for field in [
7166 "event",
7167 "attempt_id",
7168 "reason",
7169 "retry_in_ms",
7170 "duration_ms",
7171 "materialize_ms",
7172 "register_ms",
7173 "spawn_ms",
7174 ] {
7175 assert!(
7176 is_field_allowed(field),
7177 "`{field}` would be logged as `[redacted]`"
7178 );
7179 }
7180 }
7181
7182 #[cfg(unix)]
7183 fn package_entry(root: &Path, version: &str, manifest: &str) -> PathBuf {
7184 let entry = root.join("packages").join(version);
7185 fs::create_dir_all(entry.join("bin")).unwrap();
7186 fs::write(entry.join(crate::package::MANIFEST_FILE), manifest).unwrap();
7187 fs::write(entry.join("bin").join("Runner.Listener"), version).unwrap();
7188 entry
7189 }
7190
7191 #[cfg(unix)]
7192 #[test]
7193 fn a_package_seed_is_built_once_and_reused_while_its_manifest_matches() {
7194 let root = tempfile::tempdir().unwrap();
7195 let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
7196 let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
7197
7198 let seed = refresh_package_seed(&source, &seeds).unwrap();
7199 assert_eq!(seed, seeds.join("2.336.0"));
7200 assert_eq!(
7201 fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
7202 "2.336.0"
7203 );
7204
7205 fs::write(seed.join("marker"), b"kept").unwrap();
7207 assert_eq!(refresh_package_seed(&source, &seeds).unwrap(), seed);
7208 assert!(seed.join("marker").exists(), "a matching seed is reused");
7209 }
7210
7211 #[cfg(unix)]
7212 #[test]
7213 fn a_package_seed_is_rebuilt_for_a_new_entry_and_drops_every_other() {
7214 let root = tempfile::tempdir().unwrap();
7215 let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
7216 let old = package_entry(root.path(), "2.335.0", "{\"digest\":\"old\"}");
7217 refresh_package_seed(&old, &seeds).unwrap();
7218 fs::create_dir_all(seeds.join(".staging-interrupted")).unwrap();
7219 fs::write(seeds.join("stray"), b"").unwrap();
7220
7221 let new = package_entry(root.path(), "2.336.0", "{\"digest\":\"new\"}");
7222 let seed = refresh_package_seed(&new, &seeds).unwrap();
7223
7224 let mut left: Vec<_> = fs::read_dir(&seeds)
7225 .unwrap()
7226 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
7227 .collect();
7228 left.sort();
7229 assert_eq!(left, ["2.336.0"]);
7230
7231 fs::write(
7233 new.join(crate::package::MANIFEST_FILE),
7234 "{\"digest\":\"again\"}",
7235 )
7236 .unwrap();
7237 fs::write(seed.join("marker"), b"stale").unwrap();
7238 refresh_package_seed(&new, &seeds).unwrap();
7239 assert!(!seed.join("marker").exists(), "a stale seed is rebuilt");
7240 }
7241
7242 #[cfg(unix)]
7243 #[test]
7244 fn a_package_on_the_runtime_volume_is_its_own_seed() {
7245 let root = tempfile::tempdir().unwrap();
7246 let source = package_entry(root.path(), "2.336.0", "{}");
7247 let runtime = root.path().join("runners").join("attempt");
7248 fs::create_dir_all(&runtime).unwrap();
7249
7250 assert_eq!(package_seed(&source, &runtime).unwrap(), source);
7251 copy_package_tree(&source, &runtime).unwrap();
7252 assert!(runtime.join("bin").join("Runner.Listener").exists());
7253 assert!(!root.path().join("runners").join(PACKAGE_SEED_DIR).exists());
7254 }
7255
7256 #[test]
7257 fn copy_package_tree_refuses_top_level_work_folder() {
7258 let root = tempfile::tempdir().unwrap();
7259 let source = root.path().join("source");
7260 let dest = root.path().join("dest");
7261
7262 fs::create_dir_all(&source).unwrap();
7263 fs::write(source.join("file1.txt"), b"hello").unwrap();
7264
7265 let top_work = source.join(DEFAULT_WORK_FOLDER);
7267 fs::create_dir_all(&top_work).unwrap();
7268
7269 let err = copy_package_tree(&source, &dest).unwrap_err();
7270 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
7271 }
7272}