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 Adopted {
151 attempt: AttemptId,
152 },
153 RemoteIdentityRecovered {
154 attempt: AttemptId,
155 runner_id: u64,
156 },
157 TerminateIntent {
158 attempt: AttemptId,
159 },
160 Terminated {
161 attempt: AttemptId,
162 },
163 Deregistered {
167 attempt: AttemptId,
168 runner_id: u64,
169 },
170 Concluded {
171 attempt: AttemptId,
172 outcome: OutcomeKind,
173 },
174 Cleaned {
175 attempt: AttemptId,
176 outcome: OutcomeKind,
177 },
178}
179
180pub trait AttemptEventSink: fmt::Debug + Send + Sync {
181 fn emit(&self, event: AttemptEvent);
182}
183
184#[derive(Debug, Default)]
185pub struct AttemptEventLog(Mutex<Vec<AttemptEvent>>);
186
187impl AttemptEventLog {
188 #[must_use]
189 pub fn events(&self) -> Vec<AttemptEvent> {
190 self.0
191 .lock()
192 .map(|events| events.clone())
193 .unwrap_or_default()
194 }
195}
196
197impl AttemptEventSink for AttemptEventLog {
198 fn emit(&self, event: AttemptEvent) {
199 if let Ok(mut events) = self.0.lock() {
200 events.push(event);
201 }
202 }
203}
204
205#[derive(Debug, Clone, Copy, Default)]
206pub struct NoAttemptEvents;
207
208impl AttemptEventSink for NoAttemptEvents {
209 fn emit(&self, _event: AttemptEvent) {}
210}
211
212#[async_trait]
214pub trait DemandPersistence: fmt::Debug + Send + Sync {
215 async fn persists(&self, policy: PolicyId) -> bool;
216}
217
218#[derive(Debug, Clone, Copy, Default)]
219pub struct PersistentDemand;
220
221#[async_trait]
222impl DemandPersistence for PersistentDemand {
223 async fn persists(&self, _policy: PolicyId) -> bool {
224 true
225 }
226}
227
228#[async_trait]
229pub trait RetryDelay: fmt::Debug + Send + Sync {
230 async fn wait(&self, duration: Duration);
231}
232
233#[derive(Debug, Clone, Copy, Default)]
234pub struct TokioRetryDelay;
235
236#[async_trait]
237impl RetryDelay for TokioRetryDelay {
238 async fn wait(&self, duration: Duration) {
239 tokio::time::sleep(duration).await;
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct JitRequestFailure {
245 pub terminal: bool,
246 pub reason: FailureReason,
247 pub retry_after: Option<Duration>,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub struct LifecycleGithubObservation {
255 pub status: GithubRunnerObservation,
256 pub runner_id: Option<u64>,
257}
258
259impl LifecycleGithubObservation {
260 #[must_use]
261 pub const fn unreachable() -> Self {
262 Self {
263 status: GithubRunnerObservation::Unreachable,
264 runner_id: None,
265 }
266 }
267
268 #[must_use]
269 pub const fn not_registered() -> Self {
270 Self {
271 status: GithubRunnerObservation::NotRegistered,
272 runner_id: None,
273 }
274 }
275
276 #[must_use]
277 pub const fn registered(runner_id: u64, busy: bool) -> Self {
278 Self {
279 status: GithubRunnerObservation::Registered { busy },
280 runner_id: Some(runner_id),
281 }
282 }
283}
284
285#[async_trait]
288pub trait LifecycleGithub: fmt::Debug + Send + Sync {
289 async fn register(
290 &self,
291 target: &ScaleTarget,
292 request: &JitRunnerRequest,
293 cancel: &CancelToken,
294 ) -> Result<JitRegistration, JitRequestFailure>;
295
296 async fn observe(
297 &self,
298 target: &ScaleTarget,
299 attempt: AttemptId,
300 cancel: &CancelToken,
301 ) -> LifecycleGithubObservation;
302
303 async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool;
310}
311
312#[async_trait]
313impl<T> LifecycleGithub for T
314where
315 T: JitGateway + InventoryGateway + fmt::Debug + Send + Sync,
316{
317 async fn register(
318 &self,
319 target: &ScaleTarget,
320 request: &JitRunnerRequest,
321 cancel: &CancelToken,
322 ) -> Result<JitRegistration, JitRequestFailure> {
323 self.generate_jit_config(target, request, cancel)
324 .await
325 .map_err(|error| {
326 let reason = if matches!(&error, JitError::Forbidden { .. }) {
327 FailureReason::Other(
328 "GitHub refused JIT registration with 403; check the App's runner permission and runner-group access"
329 .into(),
330 )
331 } else {
332 FailureReason::JitRequestFailed
333 };
334 JitRequestFailure {
335 terminal: error.is_terminal(),
336 reason,
337 retry_after: error
338 .rate_limited()
339 .map(|limit| limit.delay_from(self.now())),
340 }
341 })
342 }
343
344 async fn observe(
345 &self,
346 target: &ScaleTarget,
347 attempt: AttemptId,
348 cancel: &CancelToken,
349 ) -> LifecycleGithubObservation {
350 let expected_name = runner_name(attempt);
351 match self.list_runners(target, cancel).await {
352 Ok(inventory) => inventory
353 .runners()
354 .iter()
355 .find(|runner| runner.name == expected_name)
356 .map_or(LifecycleGithubObservation::not_registered(), |runner| {
357 LifecycleGithubObservation::registered(runner.id, runner.busy)
358 }),
359 Err(_) => LifecycleGithubObservation::unreachable(),
360 }
361 }
362
363 async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool {
364 self.remove_runner(target, runner_id, cancel).await.is_ok()
365 }
366}
367
368#[async_trait]
370pub trait RuntimePackages: fmt::Debug + Send + Sync {
371 async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason>;
372 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason>;
373 fn prune_obsolete_guarded(
374 &self,
375 authority: PruneAuthority<'_>,
376 current: &RunnerVersion,
377 attempts: &[RunnerAttempt],
378 ) -> Result<(), FailureReason>;
379}
380
381pub struct PruneAuthority<'a> {
386 _guard: &'a AllocationGuard,
387}
388
389impl fmt::Debug for PruneAuthority<'_> {
390 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
391 f.write_str("PruneAuthority")
392 }
393}
394
395impl<'a> PruneAuthority<'a> {
396 fn from_launch_request(guard: &'a AllocationGuard) -> Self {
397 Self { _guard: guard }
398 }
399}
400
401#[derive(Debug)]
404pub struct CachedRuntimePackages {
405 cache: Arc<PackageCache>,
406}
407
408impl CachedRuntimePackages {
409 #[must_use]
410 pub fn new(cache: Arc<PackageCache>) -> Self {
411 Self { cache }
412 }
413}
414
415#[async_trait]
416impl RuntimePackages for CachedRuntimePackages {
417 async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason> {
418 let installed = self
419 .cache
420 .ensure_installed()
421 .await
422 .map_err(package_failure)?;
423 copy_package_tree(installed.root(), attempt.runtime_path())
424 .map_err(|_| FailureReason::ProcessStartFailed)?;
425 if let Err(error) = self.cache.lease(attempt, installed.version()) {
426 let _ = remove_materialized_package(attempt);
430 return Err(package_failure(error));
431 }
432 Ok(installed.version().clone())
433 }
434
435 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
436 self.cache.release(attempt).map_err(package_failure)
437 }
438
439 fn prune_obsolete_guarded(
440 &self,
441 _authority: PruneAuthority<'_>,
442 current: &RunnerVersion,
443 attempts: &[RunnerAttempt],
444 ) -> Result<(), FailureReason> {
445 for installed in self.cache.installed().map_err(package_failure)? {
446 if installed.version() != current {
447 match self.cache.prune(installed.version(), attempts) {
448 Ok(()) | Err(PackageError::VersionInUse { .. }) => {}
449 Err(error) => return Err(package_failure(error)),
450 }
451 }
452 }
453 Ok(())
454 }
455}
456
457fn package_failure(error: PackageError) -> FailureReason {
458 error.failure_reason().unwrap_or(FailureReason::Other(
459 "runner package cache operation failed".into(),
460 ))
461}
462
463fn package_failure_is_terminal(reason: &FailureReason) -> bool {
464 matches!(
465 reason,
466 FailureReason::RunnerPackageUnverified | FailureReason::RunnerVersionRejected
467 )
468}
469
470const WORKSPACE_NAME_LEN: usize = 12;
493
494fn workspace_name(id: AttemptId) -> String {
496 let full = id.to_string();
497 full.chars()
498 .filter(|c| *c != '-')
499 .take(WORKSPACE_NAME_LEN)
500 .collect()
501}
502
503#[derive(Debug, Clone)]
510struct Placement {
511 runtime: PathBuf,
512 workspace: AttemptWorkspace,
513}
514
515fn root_failure(error: RunnerRootError) -> LifecycleError {
522 tracing::warn!(
539 error_kind = error.kind(),
540 "the runner root refused this launch, so no attempt was created; the host will \
541 retry every poll until the cause is resolved. Re-running `host set-runtime-root` \
542 with the same path re-runs this check and prints the directory and the \
543 remediation in full"
544 );
545 LifecycleError::Failed(FailureReason::Other(error.to_string()))
546}
547
548fn lowest_free_slot(leases: &[RunnerAttempt], ceiling: NonZeroU16) -> Option<NonZeroU16> {
557 let held: BTreeSet<u16> = leases
558 .iter()
559 .filter_map(|attempt| attempt.workspace().slot_number())
560 .collect();
561 (1..=ceiling.get())
562 .find(|slot| !held.contains(slot))
563 .and_then(NonZeroU16::new)
564}
565
566fn create_or_validate_slot(slot: &Path) -> Result<(), LifecycleError> {
574 match fs::symlink_metadata(slot) {
575 Ok(metadata) if is_link_like(&metadata) => Err(slot_refusal(
581 slot,
582 "is a symbolic link, junction or other reparse point, which could place runner \
583 files outside the configured root",
584 )),
585 Ok(metadata) if !metadata.is_dir() => Err(slot_refusal(slot, "is not a directory")),
586 Ok(_) => Ok(()),
587 Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(slot)
588 .map_err(|source| slot_refusal(slot, format!("could not be created: {source}"))),
589 Err(source) => Err(slot_refusal(
590 slot,
591 format!("could not be inspected: {source}"),
592 )),
593 }
594}
595
596fn accept_reusable_slot(slot: &Path) -> Result<(), LifecycleError> {
608 let unreadable =
609 |source: std::io::Error| slot_refusal(slot, format!("could not be read: {source}"));
610 let entries = fs::read_dir(slot).map_err(unreadable)?;
611 let mut refused: Vec<String> = Vec::new();
612 for entry in entries {
613 let entry = entry.map_err(unreadable)?;
614 let name = entry.file_name();
615 let metadata = fs::symlink_metadata(entry.path()).map_err(|source| {
616 slot_refusal(
617 slot,
618 format!("entry {name:?} could not be inspected: {source}"),
619 )
620 })?;
621 if is_retainable_work_folder(&name, &metadata) {
626 continue;
627 }
628 refused.push(name.to_string_lossy().into_owned());
629 }
630 if refused.is_empty() {
631 return Ok(());
632 }
633 refused.sort();
634 Err(slot_refusal(
635 slot,
636 format!(
637 "holds {} that this attempt may not reuse: [{}]. A reusable slot is empty or holds \
638 one real `{DEFAULT_WORK_FOLDER}` directory and nothing else; remove or move the \
639 entries listed, or let cleanup and recovery resolve them",
640 if refused.len() == 1 {
641 "an entry"
642 } else {
643 "entries"
644 },
645 refused.join(", ")
646 ),
647 ))
648}
649
650fn slot_refusal(slot: &Path, detail: impl fmt::Display) -> LifecycleError {
651 LifecycleError::Failed(FailureReason::Other(format!(
652 "the persistent slot {} {detail}",
653 slot.display()
654 )))
655}
656
657fn is_work_folder(name: &OsStr) -> bool {
667 if cfg!(windows) {
668 name.eq_ignore_ascii_case(DEFAULT_WORK_FOLDER)
669 } else {
670 name == OsStr::new(DEFAULT_WORK_FOLDER)
671 }
672}
673
674fn is_link_like(metadata: &fs::Metadata) -> bool {
683 if metadata.file_type().is_symlink() {
684 return true;
685 }
686 #[cfg(windows)]
687 {
688 use std::os::windows::fs::MetadataExt;
689
690 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
691 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
692 }
693 #[cfg(not(windows))]
694 false
695}
696
697fn is_retainable_work_folder(name: &OsStr, metadata: &fs::Metadata) -> bool {
705 is_work_folder(name) && metadata.is_dir() && !is_link_like(metadata)
706}
707
708fn remove_materialized_package(attempt: &RunnerAttempt) -> std::io::Result<()> {
715 match attempt.workspace() {
716 AttemptWorkspace::Ephemeral => remove_dir_all::remove_dir_all(attempt.runtime_path()),
717 AttemptWorkspace::PersistentSlot { .. } => scrub_slot_entries(attempt.runtime_path())
718 .map_err(|quarantine| std::io::Error::other(quarantine.to_string())),
719 }
720}
721
722#[derive(Debug, Clone, Copy, PartialEq, Eq)]
737enum SlotRefusal {
738 NotTheJournalledSlot,
740 PolicyRootDisagrees,
742 Containment,
744 SlotNotADirectory,
746 Enumeration,
748 WorkNotADirectory,
750 Deletion,
752 Residue,
754}
755
756impl SlotRefusal {
757 const fn class(self) -> &'static str {
759 match self {
760 Self::NotTheJournalledSlot => "slot_path_is_not_the_journalled_slot",
761 Self::PolicyRootDisagrees => "slot_root_disagrees_with_policy",
762 Self::Containment => "slot_escapes_its_root",
763 Self::SlotNotADirectory => "slot_is_not_a_directory",
764 Self::Enumeration => "slot_could_not_be_enumerated",
765 Self::WorkNotADirectory => "retained_work_is_not_a_directory",
766 Self::Deletion => "slot_entry_could_not_be_removed",
767 Self::Residue => "slot_still_holds_runner_state",
768 }
769 }
770
771 const fn remediation(self) -> &'static str {
773 match self {
774 Self::NotTheJournalledSlot | Self::PolicyRootDisagrees | Self::Containment => {
775 "the attempt keeps its slot lease and nothing was removed; correct the \
776 repository's persistent workspace path, or remove the slot directory by hand \
777 once you have confirmed what is in it"
778 }
779 Self::SlotNotADirectory | Self::WorkNotADirectory => {
780 "the attempt keeps its slot lease and nothing was removed; a job replaced the \
781 slot or its `_work` with a link, so inspect it before deleting anything and \
782 treat the retained workspace as untrusted"
783 }
784 Self::Enumeration | Self::Deletion | Self::Residue => {
785 "the attempt keeps its slot lease and will be cleaned again on the next pass; \
786 release whatever is holding the files open, or remove the slot's contents by \
787 hand leaving only `_work`"
788 }
789 }
790 }
791}
792
793#[derive(Debug, Clone, PartialEq, Eq)]
800struct SlotQuarantine {
801 refusal: SlotRefusal,
802 detail: String,
803}
804
805impl SlotQuarantine {
806 fn new(refusal: SlotRefusal, detail: impl Into<String>) -> Self {
807 Self {
808 refusal,
809 detail: detail.into(),
810 }
811 }
812}
813
814impl fmt::Display for SlotQuarantine {
815 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
816 write!(f, "{}; {}", self.detail, self.refusal.remediation())
817 }
818}
819
820fn verify_journalled_slot(
834 runtime: &Path,
835 slot: NonZeroU16,
836 configured: Option<&LocalAbsolutePath>,
837) -> Result<(), SlotQuarantine> {
838 let mislaid = || {
839 SlotQuarantine::new(
840 SlotRefusal::NotTheJournalledSlot,
841 format!(
842 "the journalled runtime {} is not the slot s{slot} this attempt was allocated as",
843 runtime.display()
844 ),
845 )
846 };
847 let local = |path: &Path| {
848 path.to_str()
849 .and_then(|raw| LocalAbsolutePath::new(raw).ok())
850 .ok_or_else(mislaid)
851 };
852
853 let runtime_path = local(runtime)?;
854 let root = local(runtime.parent().ok_or_else(mislaid)?)?;
855 let name = AttemptWorkspace::persistent_slot(slot)
862 .slot_directory_name()
863 .expect("a persistent workspace names its slot directory");
864 let derived = runner_root::derive_child(&root, &name).map_err(|_| mislaid())?;
865 if derived != runtime_path {
866 return Err(mislaid());
867 }
868 if let Some(configured) = configured
869 && configured != &root
870 {
871 return Err(SlotQuarantine::new(
872 SlotRefusal::PolicyRootDisagrees,
873 format!(
874 "the journalled slot {} is not under the repository's configured persistent root \
875 {}",
876 runtime.display(),
877 configured.as_str()
878 ),
879 ));
880 }
881 runner_root::verify_containment(&root, &derived).map_err(|source| {
884 SlotQuarantine::new(
885 SlotRefusal::Containment,
886 format!("the journalled slot is not inside the root it was allocated from: {source}"),
887 )
888 })
889}
890
891fn slot_is_present(slot: &Path) -> Result<bool, SlotQuarantine> {
902 match fs::symlink_metadata(slot) {
903 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
904 Err(source) => Err(SlotQuarantine::new(
905 SlotRefusal::SlotNotADirectory,
906 format!(
907 "the slot {} could not be inspected: {:?}",
908 slot.display(),
909 source.kind()
910 ),
911 )),
912 Ok(metadata) if !metadata.is_dir() || is_link_like(&metadata) => Err(SlotQuarantine::new(
913 SlotRefusal::SlotNotADirectory,
914 format!(
915 "the slot {} is a link or a file rather than a real directory",
916 slot.display()
917 ),
918 )),
919 Ok(_) => Ok(true),
920 }
921}
922
923fn scrub_slot_entries(slot: &Path) -> Result<(), SlotQuarantine> {
938 let unreadable = |source: std::io::Error| {
939 SlotQuarantine::new(
940 SlotRefusal::Enumeration,
941 format!(
942 "the entries of {} could not be listed: {:?}",
943 slot.display(),
944 source.kind()
945 ),
946 )
947 };
948 for entry in fs::read_dir(slot).map_err(unreadable)? {
949 let name = entry.map_err(unreadable)?.file_name();
950 let path = slot.join(&name);
951 let Some(metadata) = listed_entry_metadata(&path).map_err(unreadable)? else {
954 continue;
955 };
956 if is_work_folder(&name) {
957 if is_retainable_work_folder(&name, &metadata) {
958 continue;
959 }
960 return Err(SlotQuarantine::new(
961 SlotRefusal::WorkNotADirectory,
962 format!(
963 "the retained `{DEFAULT_WORK_FOLDER}` in {} is a link or a file rather than a \
964 real directory",
965 slot.display()
966 ),
967 ));
968 }
969 remove_slot_entry(&path, &metadata).map_err(|source| {
970 SlotQuarantine::new(
971 SlotRefusal::Deletion,
972 format!(
973 "an entry of {} could not be removed: {:?}",
974 slot.display(),
975 source.kind()
976 ),
977 )
978 })?;
979 }
980 Ok(())
981}
982
983fn listed_entry_metadata(path: &Path) -> std::io::Result<Option<fs::Metadata>> {
989 match fs::symlink_metadata(path) {
990 Ok(metadata) => Ok(Some(metadata)),
991 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
992 Err(source) => Err(source),
993 }
994}
995
996fn remove_slot_entry(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
1001 let removed = if is_link_like(metadata) {
1002 fs::remove_file(path).or_else(|_| fs::remove_dir(path))
1005 } else if metadata.is_dir() {
1006 remove_dir_all::remove_dir_all(path)
1007 } else {
1008 fs::remove_file(path)
1009 };
1010 match removed {
1011 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1012 other => other,
1013 }
1014}
1015
1016fn verify_slot_scrubbed(slot: &Path) -> Result<(), SlotQuarantine> {
1033 let unreadable = |source: std::io::Error| {
1034 SlotQuarantine::new(
1035 SlotRefusal::Enumeration,
1036 format!(
1037 "the entries of {} could not be listed to verify the scrub: {:?}",
1038 slot.display(),
1039 source.kind()
1040 ),
1041 )
1042 };
1043 let mut residue = 0_usize;
1044 let mut named: Vec<String> = Vec::new();
1045 for entry in fs::read_dir(slot).map_err(unreadable)? {
1046 let name = entry.map_err(unreadable)?.file_name();
1047 let Some(metadata) = listed_entry_metadata(&slot.join(&name)).map_err(unreadable)? else {
1051 continue;
1052 };
1053 if is_retainable_work_folder(&name, &metadata) {
1054 continue;
1055 }
1056 residue = residue.saturating_add(1);
1057 if name
1058 .to_string_lossy()
1059 .starts_with(RestrictiveHandoff::NAME_PREFIX)
1060 {
1061 named.push("an encoded JIT handoff".to_owned());
1062 }
1063 }
1064 named.extend(
1065 SENSITIVE_SLOT_ENTRIES
1066 .iter()
1067 .filter(|entry| fs::symlink_metadata(slot.join(entry)).is_ok())
1068 .map(|entry| format!("`{entry}`")),
1069 );
1070 if residue == 0 && named.is_empty() {
1071 return Ok(());
1072 }
1073 named.sort_unstable();
1074 named.dedup();
1075 Err(SlotQuarantine::new(
1076 SlotRefusal::Residue,
1077 residue_detail(slot, residue, &named),
1078 ))
1079}
1080
1081fn residue_detail(slot: &Path, residue: usize, named: &[String]) -> String {
1091 if residue == 0 {
1092 format!(
1096 "the listing of {} reported nothing but `{DEFAULT_WORK_FOLDER}`, yet {} survived \
1097 cleanup",
1098 slot.display(),
1099 named.join(", ")
1100 )
1101 } else {
1102 format!(
1103 "{residue} entr{} other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}{}",
1104 if residue == 1 { "y" } else { "ies" },
1105 slot.display(),
1106 if named.is_empty() {
1107 String::new()
1108 } else {
1109 format!(", including {}", named.join(", "))
1110 }
1111 )
1112 }
1113}
1114
1115fn replacement_operation(outcome: &AttemptOutcome) -> Option<&'static str> {
1116 match outcome {
1117 AttemptOutcome::Failed {
1118 reason: FailureReason::JitExpired,
1119 } => Some("jit_expired_replacement"),
1120 AttemptOutcome::Failed {
1121 reason: FailureReason::ProcessExitedUnexpectedly,
1122 } => Some("exit_before_acceptance_replacement"),
1123 _ => None,
1124 }
1125}
1126
1127fn copy_package_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
1142 if source.join(DEFAULT_WORK_FOLDER).exists() {
1143 return Err(std::io::Error::new(
1144 std::io::ErrorKind::InvalidData,
1145 "a cached runner package contains a _work folder, which means it was used \
1146 to run a job before it was archived; the cache must only contain clean \
1147 extracts to prevent data leakage",
1148 ));
1149 }
1150
1151 #[cfg(unix)]
1152 {
1153 let status = std::process::Command::new("cp")
1154 .arg("-a")
1155 .arg(format!("{}/.", source.display()))
1156 .arg(destination)
1157 .status()?;
1158 if status.success() {
1159 Ok(())
1160 } else {
1161 Err(std::io::Error::other("cp failed"))
1162 }
1163 }
1164 #[cfg(not(unix))]
1165 copy_package_entries(source, destination, true)
1166}
1167
1168#[cfg(not(unix))]
1169fn copy_package_entries(source: &Path, destination: &Path, top_level: bool) -> std::io::Result<()> {
1170 fs::create_dir_all(destination)?;
1171 for entry in fs::read_dir(source)? {
1172 let entry = entry?;
1173 if top_level && is_work_folder(&entry.file_name()) {
1174 return Err(std::io::Error::new(
1175 std::io::ErrorKind::InvalidData,
1176 format!(
1177 "the runner package holds a top-level `{DEFAULT_WORK_FOLDER}`; copying \
1178 it would overwrite the job workspace a persistent slot retains"
1179 ),
1180 ));
1181 }
1182 let target = destination.join(entry.file_name());
1183 if entry.file_type()?.is_dir() {
1184 copy_package_entries(&entry.path(), &target, false)?;
1185 } else {
1186 fs::copy(entry.path(), target)?;
1187 }
1188 }
1189 Ok(())
1190}
1191
1192#[derive(Debug, Clone, PartialEq, Eq)]
1195pub struct ProcessStartFailure {
1196 pub reason: FailureReason,
1197 pub retryable: bool,
1200 pub live_pid: Option<u32>,
1203}
1204
1205impl ProcessStartFailure {
1206 fn before_spawn(reason: FailureReason) -> Self {
1207 Self {
1208 reason,
1209 retryable: true,
1210 live_pid: None,
1211 }
1212 }
1213
1214 fn after_spawn_stopped() -> Self {
1215 Self {
1216 reason: FailureReason::ProcessStartFailed,
1217 retryable: false,
1218 live_pid: None,
1219 }
1220 }
1221
1222 fn after_spawn_live(pid: u32) -> Self {
1223 Self::after_spawn_live_with_reason(pid, FailureReason::ProcessStartFailed)
1224 }
1225
1226 fn after_spawn_live_with_reason(pid: u32, reason: FailureReason) -> Self {
1227 Self {
1228 reason,
1229 retryable: false,
1230 live_pid: Some(pid),
1231 }
1232 }
1233}
1234
1235pub trait ProcessSupervisor: fmt::Debug + Send + Sync {
1236 fn spawn(
1237 &self,
1238 attempt: &RunnerAttempt,
1239 config: &EncodedJitConfig,
1240 ) -> Result<u32, ProcessStartFailure>;
1241 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason>;
1242 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason>;
1245 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool;
1248 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
1249 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool;
1250 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
1251}
1252
1253#[derive(Debug, Default)]
1256pub struct NativeProcesses {
1257 children: Mutex<BTreeMap<AttemptId, ChildProcess>>,
1258 successful_exits: Mutex<BTreeMap<AttemptId, bool>>,
1259 #[cfg(test)]
1260 post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
1261 #[cfg(test)]
1262 post_spawn_reaps: std::sync::atomic::AtomicUsize,
1263 #[cfg(test)]
1264 post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
1265 #[cfg(test)]
1266 use_long_lived_test_listener: std::sync::atomic::AtomicBool,
1267}
1268
1269#[cfg(test)]
1270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1271enum PostSpawnBoundary {
1272 HandoffDelete,
1273 IdentitySerialize,
1274 IdentityWrite,
1275 ChildMapInsert,
1276}
1277
1278impl NativeProcesses {
1279 #[must_use]
1280 pub fn new() -> Self {
1281 Self::default()
1282 }
1283
1284 #[cfg(test)]
1285 fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
1286 self.post_spawn_faults.lock().unwrap().push_back(boundary);
1287 }
1288
1289 #[cfg(test)]
1290 fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
1291 let mut faults = self.post_spawn_faults.lock().unwrap();
1292 if faults.front() == Some(&boundary) {
1293 faults.pop_front();
1294 true
1295 } else {
1296 false
1297 }
1298 }
1299
1300 #[cfg(test)]
1301 fn fail_post_spawn_stops(&self, count: usize) {
1302 self.post_spawn_stop_failures
1303 .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
1304 }
1305
1306 #[cfg(test)]
1307 fn fail_next_post_spawn_stop(&self) {
1308 self.fail_post_spawn_stops(1);
1309 }
1310
1311 #[cfg(test)]
1312 fn use_long_lived_test_listener(&self) {
1313 self.use_long_lived_test_listener
1314 .store(true, std::sync::atomic::Ordering::SeqCst);
1315 }
1316
1317 fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
1318 #[cfg(test)]
1319 if self
1320 .post_spawn_stop_failures
1321 .fetch_update(
1322 std::sync::atomic::Ordering::SeqCst,
1323 std::sync::atomic::Ordering::SeqCst,
1324 |left| if left > 0 { Some(left - 1) } else { None },
1325 )
1326 .is_ok()
1327 {
1328 return Err(FailureReason::Other("injected runner stop failure".into()));
1329 }
1330 child
1331 .stop(Duration::from_secs(1))
1332 .map(|_| ())
1333 .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
1334 }
1335
1336 fn abort_spawned_child(
1337 &self,
1338 mut child: ChildProcess,
1339 attempt: &RunnerAttempt,
1340 remove_identity: bool,
1341 ) -> ProcessStartFailure {
1342 let mut reaped = self.stop_spawned_child(&mut child).is_ok();
1343 if reaped {
1344 if remove_identity {
1345 Self::remove_identity_files(attempt);
1346 }
1347 } else {
1348 let identity_durable =
1351 serde_json::to_vec(child.identity())
1352 .ok()
1353 .is_some_and(|identity| {
1354 self.persist_identity(attempt, &identity).is_ok()
1355 || self.persist_fallback_identity(attempt, &identity).is_ok()
1356 });
1357 if !identity_durable {
1358 for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
1363 if self.stop_spawned_child(&mut child).is_ok() {
1364 reaped = true;
1365 break;
1366 }
1367 }
1368 if !reaped {
1369 let pid = child.pid();
1374 let marker = write_durable_file(
1375 &Self::unresolved_process_path(attempt),
1376 pid.to_string().as_bytes(),
1377 );
1378 self.children
1379 .lock()
1380 .unwrap_or_else(std::sync::PoisonError::into_inner)
1381 .insert(attempt.id, child);
1382 let reason = if marker.is_ok() {
1383 FailureReason::Other(
1384 "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
1385 .into(),
1386 )
1387 } else {
1388 FailureReason::Other(
1389 "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
1390 .into(),
1391 )
1392 };
1393 return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
1394 }
1395 } else {
1396 let pid = child.pid();
1397 self.children
1398 .lock()
1399 .unwrap_or_else(std::sync::PoisonError::into_inner)
1400 .insert(attempt.id, child);
1401 return ProcessStartFailure::after_spawn_live(pid);
1402 }
1403 }
1404 #[cfg(test)]
1405 if reaped {
1406 self.post_spawn_reaps
1407 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1408 }
1409 #[cfg(not(test))]
1410 let _ = reaped;
1411 ProcessStartFailure::after_spawn_stopped()
1412 }
1413
1414 fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
1415 attempt.runtime_path().join(IDENTITY_FILE)
1416 }
1417
1418 fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
1419 attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
1420 }
1421
1422 fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
1423 attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
1424 }
1425
1426 fn remove_identity_files(attempt: &RunnerAttempt) {
1427 let _ = fs::remove_file(Self::identity_path(attempt));
1428 let _ = fs::remove_file(Self::fallback_identity_path(attempt));
1429 let _ = fs::remove_file(Self::unresolved_process_path(attempt));
1430 }
1431
1432 fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
1433 self.persist_identity_at(&Self::identity_path(attempt), bytes)
1434 }
1435
1436 fn persist_fallback_identity(
1437 &self,
1438 attempt: &RunnerAttempt,
1439 bytes: &[u8],
1440 ) -> std::io::Result<()> {
1441 self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
1442 }
1443
1444 fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1445 #[cfg(test)]
1446 if self.faults_at(PostSpawnBoundary::IdentityWrite) {
1447 return Err(std::io::Error::other("injected identity write failure"));
1448 }
1449 write_durable_file(path, bytes)
1450 }
1451
1452 fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
1453 attempt.runtime_path().join(TERMINATE_INTENT_FILE)
1454 }
1455
1456 fn read_identity(attempt: &RunnerAttempt) -> Result<Option<ProcessIdentity>, FailureReason> {
1457 match Self::read_identity_at(&Self::identity_path(attempt))? {
1458 Some(identity) => Ok(Some(identity)),
1459 None => Self::read_identity_at(&Self::fallback_identity_path(attempt)),
1460 }
1461 }
1462
1463 fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
1464 match fs::read(path) {
1465 Ok(bytes) => serde_json::from_slice(&bytes)
1466 .map(Some)
1467 .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
1468 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1469 Err(_) => Err(FailureReason::Other(
1470 "process identity journal could not be read".into(),
1471 )),
1472 }
1473 }
1474}
1475
1476impl ProcessSupervisor for NativeProcesses {
1477 fn spawn(
1478 &self,
1479 attempt: &RunnerAttempt,
1480 config: &EncodedJitConfig,
1481 ) -> Result<u32, ProcessStartFailure> {
1482 let handoff = RestrictiveHandoff::create(
1483 attempt.runtime_path(),
1484 SecretString::from(config.expose().to_owned()),
1485 )
1486 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
1487 #[cfg(windows)]
1488 let program = attempt
1489 .runtime_path()
1490 .join("bin")
1491 .join("Runner.Listener.exe");
1492 #[cfg(not(windows))]
1493 let program = attempt.runtime_path().join("bin").join("Runner.Listener");
1494 if !program.is_file() {
1497 return Err(ProcessStartFailure::before_spawn(
1498 FailureReason::ProcessStartFailed,
1499 ));
1500 }
1501 #[cfg(test)]
1502 let spec = if self
1503 .use_long_lived_test_listener
1504 .load(std::sync::atomic::Ordering::SeqCst)
1505 {
1506 SpawnSpec::new(program)
1507 .args([
1508 "--ignored",
1509 "--exact",
1510 "lifecycle::tests::long_lived_native_listener_helper",
1511 "--nocapture",
1512 ])
1513 .env(
1514 "RUNNER_MANAGER_TEST_LISTENER_READY",
1515 attempt.runtime_path().join(TEST_LISTENER_READY),
1516 )
1517 .working_dir(attempt.runtime_path())
1518 } else {
1519 runner_listener_spec(program, attempt.runtime_path())
1520 };
1521 #[cfg(not(test))]
1522 let spec = runner_listener_spec(program, attempt.runtime_path());
1523 let child = spec
1524 .spawn_runner_with_handoff(&handoff)
1525 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
1526 #[cfg(test)]
1528 if self.faults_at(PostSpawnBoundary::HandoffDelete) {
1529 drop(handoff);
1530 return Err(self.abort_spawned_child(child, attempt, false));
1531 }
1532 if handoff.delete().is_err() {
1533 return Err(self.abort_spawned_child(child, attempt, false));
1534 }
1535 #[cfg(test)]
1536 if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
1537 return Err(self.abort_spawned_child(child, attempt, false));
1538 }
1539 let identity = match serde_json::to_vec(child.identity()) {
1540 Ok(identity) => identity,
1541 Err(_) => {
1542 return Err(self.abort_spawned_child(child, attempt, false));
1543 }
1544 };
1545 if self.persist_identity(attempt, &identity).is_err() {
1546 return Err(self.abort_spawned_child(child, attempt, true));
1547 }
1548 let pid = child.pid();
1549 #[cfg(test)]
1550 if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
1551 return Err(self.abort_spawned_child(child, attempt, true));
1552 }
1553 let mut children = self
1554 .children
1555 .lock()
1556 .unwrap_or_else(std::sync::PoisonError::into_inner);
1557 children.insert(attempt.id, child);
1558 Ok(pid)
1559 }
1560
1561 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
1562 let mut children = self
1563 .children
1564 .lock()
1565 .unwrap_or_else(std::sync::PoisonError::into_inner);
1566 if let Some(child) = children.get_mut(&attempt.id) {
1567 return match child
1568 .try_exit_status()
1569 .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
1570 {
1571 None => Ok(true),
1572 Some(status) => {
1573 if let Ok(mut exits) = self.successful_exits.lock() {
1574 exits.insert(attempt.id, status.success());
1575 }
1576 Ok(false)
1577 }
1578 };
1579 }
1580 let Some(identity) = Self::read_identity(attempt)? else {
1581 if attempt.process_id().is_some() || Self::unresolved_process_path(attempt).is_file() {
1582 return Err(FailureReason::Other(
1583 "runner process identity is missing; refusing recovery until the process is resolved"
1584 .into(),
1585 ));
1586 }
1587 return Ok(false);
1588 };
1589 match identity.recheck() {
1590 Ok(Adoption::Live) => Ok(true),
1591 Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
1592 Err(_) => Ok(false),
1593 }
1594 }
1595
1596 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
1597 Ok(Self::read_identity(attempt)?.map(|identity| identity.pid()))
1598 }
1599
1600 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
1601 self.successful_exits
1602 .lock()
1603 .ok()
1604 .and_then(|exits| exits.get(&attempt.id).copied())
1605 .unwrap_or(false)
1606 }
1607
1608 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
1609 let path = Self::intent_path(attempt);
1610 write_durable_file(&path, b"registration-timeout\n")
1611 .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
1612 }
1613
1614 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
1615 Self::intent_path(attempt).is_file()
1616 }
1617
1618 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
1619 let mut children = self
1620 .children
1621 .lock()
1622 .unwrap_or_else(std::sync::PoisonError::into_inner);
1623 if let Some(child) = children.get_mut(&attempt.id) {
1624 child
1625 .stop(Duration::from_secs(10))
1626 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
1627 return Ok(());
1628 }
1629 let Some(identity) = Self::read_identity(attempt)? else {
1630 return Ok(());
1631 };
1632 match identity
1633 .terminate(Duration::from_secs(10))
1634 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
1635 {
1636 Termination::Terminated | Termination::AlreadyGone => Ok(()),
1637 Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
1638 "runner PID was recycled; refusing to signal it".into(),
1639 )),
1640 }
1641 }
1642}
1643
1644pub struct LifecyclePorts {
1645 pub store: Arc<dyn Store>,
1646 pub github: Arc<dyn LifecycleGithub>,
1647 pub packages: Arc<dyn RuntimePackages>,
1648 pub processes: Arc<dyn ProcessSupervisor>,
1649 pub clock: Arc<dyn Clock>,
1650 pub demand: Arc<dyn DemandPersistence>,
1651 pub delay: Arc<dyn RetryDelay>,
1652 pub events: Arc<dyn AttemptEventSink>,
1653 pub reconcile_events: Arc<dyn EventSink>,
1654}
1655
1656impl fmt::Debug for LifecyclePorts {
1657 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1658 f.debug_struct("LifecyclePorts")
1659 .field("store", &self.store)
1660 .field("github", &self.github)
1661 .field("packages", &self.packages)
1662 .field("processes", &self.processes)
1663 .finish_non_exhaustive()
1664 }
1665}
1666
1667#[derive(Debug, thiserror::Error)]
1668pub enum LifecycleError {
1669 #[error("attempt journal operation failed")]
1670 Journal,
1671 #[error("attempt {0} is not in the journal")]
1672 Missing(AttemptId),
1673 #[error("attempt lifecycle transition was refused")]
1674 Transition,
1675 #[error("startup recovery has not completed")]
1676 RecoveryIncomplete,
1677 #[error("the persistent slot was not cleaned: {detail}")]
1687 SlotQuarantined {
1688 class: &'static str,
1690 detail: String,
1691 },
1692 #[error("runner lifecycle failed: {0}")]
1693 Failed(FailureReason),
1694}
1695
1696impl LifecycleError {
1697 fn reason(&self) -> FailureReason {
1698 match self {
1699 Self::Failed(reason) => reason.clone(),
1700 Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
1701 Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
1702 Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
1703 Self::Transition => FailureReason::Other("attempt transition was refused".into()),
1704 Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
1707 }
1708 }
1709}
1710
1711#[derive(Debug)]
1713pub struct LifecycleLauncher {
1714 host_id: HostId,
1715 app_paths: runner_manager_platform::paths::AppPaths,
1716 diagnostics_root: PathBuf,
1717 runner_group_id: u64,
1718 timeouts: RecoveryTimeouts,
1719 retry: RetryPolicy,
1720 cancel: CancelToken,
1721 ports: LifecyclePorts,
1722 recovery_complete: Mutex<bool>,
1723 versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
1724 pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
1725}
1726
1727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1728enum ReconcileProgress {
1729 Reconciled,
1730 Deferred,
1731 Replacement {
1732 attempt: AttemptId,
1733 operation: &'static str,
1734 },
1735}
1736
1737impl LifecycleLauncher {
1738 #[must_use]
1739 pub fn new(
1740 host_id: HostId,
1741 app_paths: runner_manager_platform::paths::AppPaths,
1742 diagnostics_root: impl Into<PathBuf>,
1743 runner_group_id: u64,
1744 timeouts: RecoveryTimeouts,
1745 retry: RetryPolicy,
1746 ports: LifecyclePorts,
1747 ) -> Self {
1748 Self {
1749 host_id,
1750 app_paths,
1751 diagnostics_root: diagnostics_root.into(),
1752 runner_group_id,
1753 timeouts,
1754 retry,
1755 cancel: CancelToken::new(),
1756 ports,
1757 recovery_complete: Mutex::new(false),
1758 versions: Mutex::new(BTreeMap::new()),
1759 pending_replacements: Mutex::new(BTreeMap::new()),
1760 }
1761 }
1762
1763 pub async fn recover_startup(
1767 &self,
1768 policies: &[ScalePolicy],
1769 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
1770 let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
1771 let attempts = self
1772 .ports
1773 .store
1774 .attempts()
1775 .map_err(|_| LifecycleError::Journal)?;
1776 let mut unresolved = false;
1777 for attempt in attempts {
1778 let Some(policy) = by_id.get(&attempt.policy_id) else {
1779 if !attempt.is_terminal() && attempt.state() != AttemptState::Cleaned {
1780 unresolved = true;
1781 }
1782 continue;
1783 };
1784 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
1785 match self.reconcile_one(policy, attempt).await? {
1786 ReconcileProgress::Deferred => unresolved = true,
1787 ReconcileProgress::Replacement { attempt, operation } => {
1788 self.pending_replacements
1789 .lock()
1790 .map_err(|_| LifecycleError::Journal)?
1791 .insert(
1792 attempt,
1793 ReplacementIntent {
1794 policy: policy.id,
1795 previous_attempt: attempt,
1796 operation,
1797 },
1798 );
1799 }
1800 ReconcileProgress::Reconciled => {}
1801 }
1802 }
1803 if unresolved {
1804 return Err(LifecycleError::RecoveryIncomplete);
1805 }
1806 *self
1807 .recovery_complete
1808 .lock()
1809 .map_err(|_| LifecycleError::Journal)? = true;
1810 Ok(self
1811 .pending_replacements
1812 .lock()
1813 .map_err(|_| LifecycleError::Journal)?
1814 .values()
1815 .copied()
1816 .collect())
1817 }
1818
1819 pub async fn supervise(
1821 &self,
1822 policy: &ScalePolicy,
1823 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
1824 let mut replacements = Vec::new();
1825 self.pending_replacements
1826 .lock()
1827 .map_err(|_| LifecycleError::Journal)?
1828 .retain(|_, intent| {
1829 if intent.policy == policy.id {
1830 replacements.push(*intent);
1831 false
1832 } else {
1833 true
1834 }
1835 });
1836 let attempts = self
1837 .ports
1838 .store
1839 .attempts_for_policy(policy.id)
1840 .map_err(|_| LifecycleError::Journal)?;
1841 for attempt in attempts {
1842 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
1843 if let ReconcileProgress::Replacement { attempt, operation } =
1844 self.reconcile_one(policy, attempt).await?
1845 {
1846 replacements.push(ReplacementIntent {
1847 policy: policy.id,
1848 previous_attempt: attempt,
1849 operation,
1850 });
1851 }
1852 }
1853 Ok(replacements)
1854 }
1855
1856 async fn reconcile_one(
1857 &self,
1858 policy: &ScalePolicy,
1859 mut attempt: RunnerAttempt,
1860 ) -> Result<ReconcileProgress, LifecycleError> {
1861 if attempt.state() == AttemptState::Cleaned {
1862 return Ok(ReconcileProgress::Reconciled);
1863 }
1864 if attempt.is_terminal() {
1865 self.clean_or_quarantine(&mut attempt)?;
1866 return Ok(ReconcileProgress::Reconciled);
1867 }
1868 let process_alive = self
1869 .ports
1870 .processes
1871 .is_alive(&attempt)
1872 .map_err(LifecycleError::Failed)?;
1873 let github = self
1874 .ports
1875 .github
1876 .observe(&policy.target, attempt.id, &self.cancel)
1877 .await;
1878
1879 if let Some(runner_id) = github.runner_id
1884 && read_runner_id(attempt.runtime_path()).is_none()
1885 {
1886 write_runner_id(attempt.runtime_path(), runner_id)?;
1887 self.ports
1888 .events
1889 .emit(AttemptEvent::RemoteIdentityRecovered {
1890 attempt: attempt.id,
1891 runner_id,
1892 });
1893 }
1894
1895 if attempt.state() == AttemptState::JitReceived
1900 && process_alive
1901 && let Some(pid) = self
1902 .ports
1903 .processes
1904 .recovered_pid(&attempt)
1905 .map_err(LifecycleError::Failed)?
1906 {
1907 attempt
1908 .started(pid, self.ports.clock.now())
1909 .map_err(|_| LifecycleError::Transition)?;
1910 self.record(&attempt)?;
1911 }
1912
1913 if attempt.state() == AttemptState::Busy
1918 && !process_alive
1919 && github.status == GithubRunnerObservation::NotRegistered
1920 && self.ports.processes.completed_successfully(&attempt)
1921 {
1922 self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
1923 self.clean_or_quarantine(&mut attempt)?;
1924 return Ok(ReconcileProgress::Reconciled);
1925 }
1926
1927 if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
1930 self.deregister_runner(policy, &attempt).await;
1931 self.conclude(
1932 &mut attempt,
1933 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
1934 )?;
1935 self.clean_or_quarantine(&mut attempt)?;
1936 return Ok(ReconcileProgress::Replacement {
1937 attempt: attempt.id,
1938 operation: "registration_timeout_replacement",
1939 });
1940 }
1941
1942 if matches!(
1948 attempt.state(),
1949 AttemptState::Allocated | AttemptState::JitReceived
1950 ) && !process_alive
1951 && matches!(github.status, GithubRunnerObservation::Registered { .. })
1952 {
1953 if attempt.state() == AttemptState::Allocated {
1954 attempt
1955 .jit_received(self.ports.clock.now())
1956 .map_err(|_| LifecycleError::Transition)?;
1957 self.record(&attempt)?;
1958 }
1959 self.deregister_runner(policy, &attempt).await;
1960 self.conclude(
1961 &mut attempt,
1962 AttemptOutcome::failed(FailureReason::JitExpired),
1963 )?;
1964 self.clean_or_quarantine(&mut attempt)?;
1965 return Ok(ReconcileProgress::Replacement {
1966 attempt: attempt.id,
1967 operation: "jit_expired_replacement",
1968 });
1969 }
1970
1971 match recovery_decision(
1972 &attempt,
1973 RecoveryObservation {
1974 process_alive,
1975 github: github.status,
1976 },
1977 self.timeouts,
1978 self.ports.clock.as_ref(),
1979 ) {
1980 RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
1981 RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
1982 RecoveryDecision::Adopt => {
1983 self.ports.events.emit(AttemptEvent::Adopted {
1984 attempt: attempt.id,
1985 });
1986 Ok(ReconcileProgress::Reconciled)
1987 }
1988 RecoveryDecision::Clean => {
1989 self.clean_or_quarantine(&mut attempt)?;
1990 Ok(ReconcileProgress::Reconciled)
1991 }
1992 RecoveryDecision::Observe(state) => {
1993 let runner_id = attempt
1994 .github_runner_id()
1995 .or(github.runner_id)
1996 .or_else(|| read_runner_id(attempt.runtime_path()))
1997 .ok_or(LifecycleError::Transition)?;
1998 match state {
1999 AttemptState::JitReceived => attempt
2000 .jit_received(self.ports.clock.now())
2001 .map_err(|_| LifecycleError::Transition)?,
2002 AttemptState::Starting => {
2003 let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
2004 attempt
2005 .started(pid, self.ports.clock.now())
2006 .map_err(|_| LifecycleError::Transition)?;
2007 }
2008 AttemptState::Idle => attempt
2009 .registered_idle(runner_id, self.ports.clock.now())
2010 .map_err(|_| LifecycleError::Transition)?,
2011 AttemptState::Busy => attempt
2012 .assigned_job(runner_id, self.ports.clock.now())
2013 .map_err(|_| LifecycleError::Transition)?,
2014 _ => return Err(LifecycleError::Transition),
2015 }
2016 self.record(&attempt)?;
2017 Ok(ReconcileProgress::Reconciled)
2018 }
2019 RecoveryDecision::Conclude(outcome) => {
2020 let replacement = replacement_operation(&outcome);
2021 if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
2026 self.deregister_runner(policy, &attempt).await;
2027 }
2028 self.conclude(&mut attempt, outcome)?;
2029 self.clean_or_quarantine(&mut attempt)?;
2030 Ok(
2031 replacement.map_or(ReconcileProgress::Reconciled, |operation| {
2032 ReconcileProgress::Replacement {
2033 attempt: attempt.id,
2034 operation,
2035 }
2036 }),
2037 )
2038 }
2039 RecoveryDecision::Terminate(payload) => {
2040 let idle_exit = payload.is_idle_exit();
2050 self.ports
2051 .processes
2052 .record_terminate_intent(&attempt)
2053 .map_err(LifecycleError::Failed)?;
2054 self.ports.events.emit(AttemptEvent::TerminateIntent {
2055 attempt: attempt.id,
2056 });
2057 self.ports
2058 .processes
2059 .terminate(&attempt)
2060 .map_err(LifecycleError::Failed)?;
2061 if self
2062 .ports
2063 .processes
2064 .is_alive(&attempt)
2065 .map_err(LifecycleError::Failed)?
2066 {
2067 return Ok(ReconcileProgress::Deferred);
2068 }
2069 self.ports.events.emit(AttemptEvent::Terminated {
2070 attempt: attempt.id,
2071 });
2072 let outcome = if idle_exit {
2078 AttemptOutcome::ExitedIdleWithoutWork
2079 } else {
2080 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
2081 };
2082 self.deregister_runner(policy, &attempt).await;
2083 self.conclude(&mut attempt, outcome)?;
2084 self.clean_or_quarantine(&mut attempt)?;
2085 if idle_exit {
2089 Ok(ReconcileProgress::Reconciled)
2090 } else {
2091 Ok(ReconcileProgress::Replacement {
2092 attempt: attempt.id,
2093 operation: "registration_timeout_replacement",
2094 })
2095 }
2096 }
2097 }
2098 }
2099
2100 fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2101 self.ports
2102 .store
2103 .record_attempt(attempt)
2104 .map_err(|_| LifecycleError::Journal)?;
2105 self.ports.events.emit(AttemptEvent::State {
2106 attempt: attempt.id,
2107 state: attempt.state(),
2108 });
2109 Ok(())
2110 }
2111
2112 async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
2133 let Some(runner_id) = attempt
2134 .github_runner_id()
2135 .or_else(|| read_runner_id(attempt.runtime_path()))
2136 else {
2137 return;
2138 };
2139 if self
2140 .ports
2141 .github
2142 .deregister(&policy.target, runner_id, &self.cancel)
2143 .await
2144 {
2145 self.ports.events.emit(AttemptEvent::Deregistered {
2146 attempt: attempt.id,
2147 runner_id,
2148 });
2149 } else {
2150 tracing::warn!(
2151 attempt = %attempt.id,
2152 runner_id,
2153 "the runner registration could not be removed from GitHub; it will show in the \
2154 target's runner settings until GitHub retires it or a later pass removes it"
2155 );
2156 }
2157 }
2158
2159 fn conclude(
2160 &self,
2161 attempt: &mut RunnerAttempt,
2162 outcome: AttemptOutcome,
2163 ) -> Result<(), LifecycleError> {
2164 attempt
2165 .conclude(outcome.clone(), self.ports.clock.now())
2166 .map_err(|_| LifecycleError::Transition)?;
2167 self.record(attempt)?;
2168 self.ports.events.emit(AttemptEvent::Concluded {
2169 attempt: attempt.id,
2170 outcome: OutcomeKind::of(&outcome),
2171 });
2172 Ok(())
2173 }
2174
2175 fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
2190 match self.clean_attempt(attempt) {
2191 Err(LifecycleError::SlotQuarantined { class, .. }) => {
2192 self.ports
2193 .reconcile_events
2194 .emit(LifecycleEvent::AttemptCleanFailed {
2195 policy: attempt.policy_id,
2196 attempt: attempt.id,
2197 reason: class,
2198 });
2199 Ok(())
2200 }
2201 other => other,
2202 }
2203 }
2204
2205 fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
2206 let outcome = attempt
2207 .outcome()
2208 .cloned()
2209 .ok_or(LifecycleError::Transition)?;
2210 self.preserve_diagnostics(attempt, &outcome)?;
2211 self.scrub_workspace(attempt)?;
2212 self.ports
2213 .packages
2214 .release(attempt.id)
2215 .map_err(LifecycleError::Failed)?;
2216 attempt
2217 .clean(self.ports.clock.now())
2218 .map_err(|_| LifecycleError::Transition)?;
2219 self.record(attempt)?;
2220 let kind = OutcomeKind::of(&outcome);
2221 self.ports.events.emit(AttemptEvent::Cleaned {
2222 attempt: attempt.id,
2223 outcome: kind,
2224 });
2225 self.ports
2226 .reconcile_events
2227 .emit(LifecycleEvent::AttemptCleaned {
2228 policy: attempt.policy_id,
2229 attempt: attempt.id,
2230 outcome: kind,
2231 });
2232 Ok(())
2233 }
2234
2235 fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2245 #[cfg(test)]
2246 {
2247 if matches!(
2251 std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
2252 Ok("skip_workspace_cleanup" | "reuse_job_workspace")
2253 ) {
2254 return Ok(());
2255 }
2256 }
2257 match attempt.workspace() {
2258 AttemptWorkspace::Ephemeral => {
2259 match remove_dir_all::remove_dir_all(attempt.runtime_path()) {
2260 Ok(()) => Ok(()),
2261 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2262 Err(_) => Err(LifecycleError::Failed(FailureReason::Other(
2263 "attempt workspace could not be removed".into(),
2264 ))),
2265 }
2266 }
2267 AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
2268 }
2269 }
2270
2271 fn scrub_persistent_slot(
2284 &self,
2285 attempt: &RunnerAttempt,
2286 slot: NonZeroU16,
2287 ) -> Result<(), LifecycleError> {
2288 let configured = self
2293 .ports
2294 .store
2295 .policy(attempt.policy_id)
2296 .map_err(|_| LifecycleError::Journal)?
2297 .and_then(|policy| match policy.workspace_policy() {
2298 WorkspacePolicy::Persistent { root } => Some(root.clone()),
2299 WorkspacePolicy::Ephemeral => None,
2300 });
2301 let runtime = attempt.runtime_path();
2302 self.quarantine_on_refusal(
2303 attempt,
2304 verify_journalled_slot(runtime, slot, configured.as_ref())
2305 .and_then(|()| slot_is_present(runtime))
2306 .and_then(|present| {
2307 if present {
2308 scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
2309 } else {
2310 Ok(())
2311 }
2312 }),
2313 )
2314 }
2315
2316 fn quarantine_on_refusal(
2325 &self,
2326 attempt: &RunnerAttempt,
2327 outcome: Result<(), SlotQuarantine>,
2328 ) -> Result<(), LifecycleError> {
2329 let Err(quarantine) = outcome else {
2330 return Ok(());
2331 };
2332 let detail = quarantine.to_string();
2333 tracing::warn!(
2334 attempt = %attempt.id,
2335 policy = %attempt.policy_id,
2336 slot = attempt.workspace().slot_number(),
2337 refusal = quarantine.refusal.class(),
2338 "{detail}"
2339 );
2340 Err(LifecycleError::SlotQuarantined {
2341 class: quarantine.refusal.class(),
2342 detail,
2343 })
2344 }
2345
2346 fn preserve_diagnostics(
2347 &self,
2348 attempt: &RunnerAttempt,
2349 outcome: &AttemptOutcome,
2350 ) -> Result<(), LifecycleError> {
2351 fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
2352 LifecycleError::Failed(FailureReason::Other(
2353 "diagnostics directory could not be created".into(),
2354 ))
2355 })?;
2356 let diagnostic = format!(
2359 "attempt_id={}\npolicy_id={}\noutcome={}\n",
2360 attempt.id,
2361 attempt.policy_id,
2362 OutcomeKind::of(outcome).as_str()
2363 );
2364 fs::write(
2365 self.diagnostics_root.join(format!("{}.log", attempt.id)),
2366 diagnostic,
2367 )
2368 .map_err(|_| {
2369 LifecycleError::Failed(FailureReason::Other(
2370 "redacted diagnostics could not be preserved".into(),
2371 ))
2372 })
2373 }
2374
2375 async fn materialize_with_retry(
2376 &self,
2377 policy: &ScalePolicy,
2378 attempt: &RunnerAttempt,
2379 ) -> Result<RunnerVersion, FailureReason> {
2380 let mut issued = 0_u32;
2381 loop {
2382 issued = issued.saturating_add(1);
2383 match self.ports.packages.materialize(attempt).await {
2384 Ok(version) => return Ok(version),
2385 Err(reason)
2386 if package_failure_is_terminal(&reason)
2387 || issued >= self.retry.max_attempts.max(1) =>
2388 {
2389 return Err(reason);
2390 }
2391 Err(reason) => {
2392 if !self.ports.demand.persists(policy.id).await {
2393 return Err(reason);
2394 }
2395 let delay = self.retry.delay(issued);
2396 self.ports.events.emit(AttemptEvent::Retry {
2397 attempt: attempt.id,
2398 operation: "package_materialization",
2399 delay,
2400 });
2401 self.ports.delay.wait(delay).await;
2402 if !self.ports.demand.persists(policy.id).await {
2403 return Err(reason);
2404 }
2405 }
2406 }
2407 }
2408 }
2409
2410 async fn register_with_retry(
2411 &self,
2412 policy: &ScalePolicy,
2413 attempt: AttemptId,
2414 request: &JitRunnerRequest,
2415 ) -> Result<JitRegistration, LifecycleError> {
2416 let mut issued = 0_u32;
2417 loop {
2418 issued = issued.saturating_add(1);
2419 match self
2420 .ports
2421 .github
2422 .register(&policy.target, request, &self.cancel)
2423 .await
2424 {
2425 Ok(registration) => return Ok(registration),
2426 Err(error) if error.terminal => {
2427 return Err(LifecycleError::Failed(error.reason));
2428 }
2429 Err(error) => {
2430 if issued >= self.retry.max_attempts.max(1)
2431 || !self.ports.demand.persists(policy.id).await
2432 {
2433 return Err(LifecycleError::Failed(error.reason));
2434 }
2435 let delay = error
2436 .retry_after
2437 .unwrap_or_else(|| self.retry.delay(issued));
2438 self.ports.events.emit(AttemptEvent::Retry {
2439 attempt,
2440 operation: "jit_request",
2441 delay,
2442 });
2443 self.ports.delay.wait(delay).await;
2444 if !self.ports.demand.persists(policy.id).await {
2445 return Err(LifecycleError::Failed(error.reason));
2446 }
2447 }
2448 }
2449 }
2450 }
2451
2452 fn allocate_workspace(
2462 &self,
2463 policy: &ScalePolicy,
2464 id: AttemptId,
2465 ) -> Result<Placement, LifecycleError> {
2466 let placement = match policy.workspace_policy() {
2467 WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
2472 WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
2473 };
2474 if placement.is_ok() {
2477 self.root_accepted(policy.id);
2478 }
2479 placement
2480 }
2481
2482 fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
2489 let host = self
2490 .ports
2491 .store
2492 .host(self.host_id)
2493 .map_err(|_| LifecycleError::Journal)?
2494 .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
2495 Ok(host.runner_root_override.clone())
2496 }
2497
2498 fn effective_host_root(
2503 &self,
2504 policy: &ScalePolicy,
2505 ) -> Result<LocalAbsolutePath, LifecycleError> {
2506 match self.configured_host_root()? {
2507 Some(configured) => Ok(configured),
2508 None => default_runner_root(&self.app_paths).map_err(|error| {
2509 self.root_refused(policy.id, "the platform default runner root", error)
2512 }),
2513 }
2514 }
2515
2516 fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
2529 let _ = runner_manager_platform::service::record_runner_root_refusal(
2530 &self.app_paths,
2531 &policy.to_string(),
2532 self.ports.clock.now(),
2533 error.kind(),
2534 root,
2535 &error.to_string(),
2536 );
2537 root_failure(error)
2538 }
2539
2540 fn root_accepted(&self, policy: PolicyId) {
2547 let _ = runner_manager_platform::service::clear_runner_root_refusal(
2548 &self.app_paths,
2549 &policy.to_string(),
2550 );
2551 }
2552
2553 fn allocate_disposable(
2556 &self,
2557 policy: &ScalePolicy,
2558 id: AttemptId,
2559 ) -> Result<Placement, LifecycleError> {
2560 let effective_root = self.effective_host_root(policy)?;
2561 RootPreflight::new(&self.app_paths)
2562 .check(&RootOwner::Host, &effective_root)
2563 .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
2564 let runtime = effective_root.as_path().join({
2565 #[cfg(test)]
2566 {
2567 if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
2568 == Ok("reuse_job_workspace")
2569 {
2570 "mutant-shared-workspace".to_owned()
2571 } else {
2572 workspace_name(id)
2573 }
2574 }
2575 #[cfg(not(test))]
2576 {
2577 workspace_name(id)
2578 }
2579 });
2580 fs::create_dir_all(&runtime)
2581 .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
2582 Ok(Placement {
2583 runtime,
2584 workspace: AttemptWorkspace::Ephemeral,
2585 })
2586 }
2587
2588 fn allocate_persistent_slot(
2600 &self,
2601 policy: &ScalePolicy,
2602 root: &LocalAbsolutePath,
2603 ) -> Result<Placement, LifecycleError> {
2604 let ceiling = policy.max_capacity().ok_or_else(|| {
2607 LifecycleError::Failed(FailureReason::Other(
2608 "a persistent workspace needs the policy's max_capacity to bound its slots"
2609 .to_string(),
2610 ))
2611 })?;
2612 let leases = self
2613 .ports
2614 .store
2615 .slot_leases_for_policy(policy.id)
2616 .map_err(|_| LifecycleError::Journal)?;
2617 let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
2618 LifecycleError::Failed(FailureReason::Other(format!(
2619 "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
2620 not been cleaned, so no slot is free; raise the repository's max capacity, or \
2621 finish cleaning a concluded attempt",
2622 policy.target
2623 )))
2624 })?;
2625 let workspace = AttemptWorkspace::persistent_slot(slot);
2626 let name = workspace
2627 .slot_directory_name()
2628 .expect("a persistent allocation names its slot directory");
2629
2630 let host_root = self
2642 .configured_host_root()?
2643 .or_else(|| default_runner_root(&self.app_paths).ok());
2644 let mut preflight = RootPreflight::new(&self.app_paths);
2645 if let Some(host_root) = host_root {
2646 preflight = preflight.against(RootOwner::Host, host_root);
2647 }
2648 let checked = preflight
2649 .check(&RootOwner::Repository(policy.target.to_string()), root)
2650 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
2651 if let Some(leaf) = checked.leaf_to_create() {
2652 fs::create_dir(leaf).map_err(|source| {
2653 LifecycleError::Failed(FailureReason::Other(format!(
2654 "the persistent workspace root {} could not be created: {source}",
2655 leaf.display()
2656 )))
2657 })?;
2658 }
2659
2660 let slot_path = runner_root::derive_child(root, &name)
2664 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
2665 create_or_validate_slot(slot_path.as_path())?;
2666 runner_root::verify_containment(root, &slot_path)
2667 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
2668 accept_reusable_slot(slot_path.as_path())?;
2669 Ok(Placement {
2670 runtime: slot_path.as_path().to_path_buf(),
2671 workspace,
2672 })
2673 }
2674
2675 fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2687 match self.ports.store.record_attempt(attempt) {
2688 Ok(()) => {
2689 self.ports.events.emit(AttemptEvent::State {
2690 attempt: attempt.id,
2691 state: attempt.state(),
2692 });
2693 Ok(())
2694 }
2695 Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
2696 FailureReason::Other(error.to_string()),
2697 )),
2698 Err(_) => Err(LifecycleError::Journal),
2699 }
2700 }
2701
2702 async fn launch_attempt(
2703 &self,
2704 policy: &ScalePolicy,
2705 allocation_guard: &AllocationGuard,
2706 ) -> Result<RunnerAttempt, LifecycleError> {
2707 if !*self
2708 .recovery_complete
2709 .lock()
2710 .map_err(|_| LifecycleError::Journal)?
2711 {
2712 return Err(LifecycleError::RecoveryIncomplete);
2713 }
2714 let labels = policy
2715 .routing_labels()
2716 .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
2717 let id = AttemptId::new_random();
2718 let placement = self.allocate_workspace(policy, id)?;
2719 let mut attempt = RunnerAttempt::allocate_in(
2720 id,
2721 policy.id,
2722 placement.runtime,
2723 placement.workspace,
2724 self.ports.clock.now(),
2725 );
2726 self.record_allocation(&attempt)?;
2731
2732 let version = match self.materialize_with_retry(policy, &attempt).await {
2733 Ok(version) => version,
2734 Err(reason) => return self.fail_launch(&mut attempt, reason),
2735 };
2736 self.prune_under_allocation_lock(allocation_guard, &version)?;
2737 self.versions
2738 .lock()
2739 .map_err(|_| LifecycleError::Journal)?
2740 .insert(id, version);
2741
2742 let jit_request =
2743 JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
2744 let registration = match self.register_with_retry(policy, id, &jit_request).await {
2745 Ok(registration) => registration,
2746 Err(error) => return self.fail_launch(&mut attempt, error.reason()),
2747 };
2748 let runner_id = registration.runner().id;
2749 write_runner_id(attempt.runtime_path(), runner_id)?;
2750 attempt
2751 .jit_received(self.ports.clock.now())
2752 .map_err(|_| LifecycleError::Transition)?;
2753 self.record(&attempt)?;
2754 let config = registration.into_config();
2755 let mut issued = 0_u32;
2756 let pid = loop {
2757 issued = issued.saturating_add(1);
2758 match self.ports.processes.spawn(&attempt, &config) {
2759 Ok(pid) => break pid,
2760 Err(error) => {
2761 if let Some(pid) = error.live_pid {
2762 attempt
2763 .started(pid, self.ports.clock.now())
2764 .map_err(|_| LifecycleError::Transition)?;
2765 self.record(&attempt)?;
2766 return Err(LifecycleError::Failed(error.reason));
2767 }
2768 if !error.retryable
2769 || issued >= self.retry.max_attempts.max(1)
2770 || !self.ports.demand.persists(policy.id).await
2771 {
2772 return self.fail_launch(&mut attempt, error.reason);
2773 }
2774 let delay = self.retry.delay(issued);
2775 self.ports.events.emit(AttemptEvent::Retry {
2776 attempt: attempt.id,
2777 operation: "process_start",
2778 delay,
2779 });
2780 self.ports.delay.wait(delay).await;
2781 if !self.ports.demand.persists(policy.id).await {
2782 return self.fail_launch(&mut attempt, error.reason);
2783 }
2784 }
2785 }
2786 };
2787 attempt
2788 .started(pid, self.ports.clock.now())
2789 .map_err(|_| LifecycleError::Transition)?;
2790 self.record(&attempt)?;
2791 Ok(attempt)
2792 }
2793
2794 fn fail_launch<T>(
2795 &self,
2796 attempt: &mut RunnerAttempt,
2797 reason: FailureReason,
2798 ) -> Result<T, LifecycleError> {
2799 self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
2800 Err(LifecycleError::Failed(reason))
2801 }
2802
2803 fn prune_under_allocation_lock(
2806 &self,
2807 guard: &AllocationGuard,
2808 version: &RunnerVersion,
2809 ) -> Result<(), LifecycleError> {
2810 let attempts = self
2811 .ports
2812 .store
2813 .attempts()
2814 .map_err(|_| LifecycleError::Journal)?;
2815 self.ports
2816 .packages
2817 .prune_obsolete_guarded(
2818 PruneAuthority::from_launch_request(guard),
2819 version,
2820 &attempts,
2821 )
2822 .map_err(LifecycleError::Failed)
2823 }
2824}
2825
2826#[async_trait]
2827impl RunnerLauncher for LifecycleLauncher {
2828 async fn supervise(
2829 &self,
2830 policy: &ScalePolicy,
2831 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
2832 LifecycleLauncher::supervise(self, policy)
2833 .await
2834 .map_err(|error| LaunchFailure::new(error.reason()))
2835 }
2836
2837 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
2838 self.ports.store.attempts().map_err(|_| {
2839 LaunchFailure::new(FailureReason::Other(
2840 "attempt journal could not be read".into(),
2841 ))
2842 })
2843 }
2844
2845 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
2846 self.launch_attempt(request.policy, request.allocation_guard)
2847 .await
2848 .map_err(|error| LaunchFailure::new(error.reason()))
2849 }
2850
2851 async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
2852 let mut attempt = self
2853 .ports
2854 .store
2855 .attempt(id)
2856 .map_err(|_| {
2857 LaunchFailure::new(FailureReason::Other(
2858 "attempt journal could not be read".into(),
2859 ))
2860 })?
2861 .ok_or_else(|| {
2862 LaunchFailure::new(FailureReason::Other(
2863 "attempt disappeared from the journal".into(),
2864 ))
2865 })?;
2866 self.clean_attempt(&mut attempt)
2867 .map_err(|error| LaunchFailure::new(error.reason()))
2868 }
2869}
2870
2871fn runner_name(attempt: AttemptId) -> String {
2872 format!("runner-manager-{attempt}")
2873}
2874
2875fn read_runner_id(runtime: &Path) -> Option<u64> {
2876 fs::read_to_string(runtime.join(RUNNER_ID_FILE))
2877 .ok()?
2878 .trim()
2879 .parse()
2880 .ok()
2881}
2882
2883fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
2884 let target = runtime.join(RUNNER_ID_FILE);
2885 if let Some(existing) = read_runner_id(runtime) {
2886 return (existing == runner_id)
2887 .then_some(())
2888 .ok_or(LifecycleError::Journal);
2889 }
2890 let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
2891 write_durable_file(&temporary, runner_id.to_string().as_bytes())
2892 .map_err(|_| LifecycleError::Journal)?;
2893 match fs::rename(&temporary, &target) {
2894 Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
2895 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
2896 let _ = fs::remove_file(&temporary);
2897 (read_runner_id(runtime) == Some(runner_id))
2898 .then_some(())
2899 .ok_or(LifecycleError::Journal)
2900 }
2901 Err(_) => {
2902 let _ = fs::remove_file(&temporary);
2903 Err(LifecycleError::Journal)
2904 }
2905 }
2906}
2907
2908fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2909 let mut file = fs::OpenOptions::new()
2910 .create(true)
2911 .truncate(true)
2912 .write(true)
2913 .open(path)?;
2914 file.write_all(bytes)?;
2915 file.sync_all()?;
2916 let parent = path.parent().ok_or_else(|| {
2917 std::io::Error::new(
2918 std::io::ErrorKind::InvalidInput,
2919 "file has no parent directory",
2920 )
2921 })?;
2922 sync_directory(parent)
2923}
2924
2925#[cfg(unix)]
2926fn sync_directory(path: &Path) -> std::io::Result<()> {
2927 fs::File::open(path)?.sync_all()
2928}
2929
2930#[cfg(windows)]
2931fn sync_directory(path: &Path) -> std::io::Result<()> {
2932 use std::os::windows::fs::OpenOptionsExt;
2933
2934 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
2935 const FILE_SHARE_ALL: u32 = 0x0000_0007;
2936 const GENERIC_WRITE: u32 = 0x4000_0000;
2937 fs::OpenOptions::new()
2938 .access_mode(GENERIC_WRITE)
2939 .share_mode(FILE_SHARE_ALL)
2940 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
2941 .open(path)?
2942 .sync_all()
2943}
2944
2945#[cfg(test)]
2946mod tests {
2947 use super::*;
2948 use std::collections::BTreeSet;
2949 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
2950
2951 use crate::reconcile::{AllocationLock, InProcessAllocationLock};
2952 use runner_manager_domain::model::{Elapsed, TargetScope};
2953 use runner_manager_domain::store::SqliteStore;
2954 use runner_manager_github::jit::JitRunner;
2955 use runner_manager_testkit::clock::FakeClock;
2956 use runner_manager_testkit::fixtures;
2957
2958 type CapturedFields = Vec<(String, String)>;
2960
2961 #[derive(Clone, Default)]
2969 struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
2970
2971 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
2972 fn on_event(
2973 &self,
2974 event: &tracing::Event<'_>,
2975 _context: tracing_subscriber::layer::Context<'_, S>,
2976 ) {
2977 struct Collect(Vec<(String, String)>);
2978 impl tracing::field::Visit for Collect {
2979 fn record_debug(
2980 &mut self,
2981 field: &tracing::field::Field,
2982 value: &dyn std::fmt::Debug,
2983 ) {
2984 self.0.push((
2987 field.name().to_owned(),
2988 format!("{value:?}").trim_matches('"').to_owned(),
2989 ));
2990 }
2991 }
2992 let mut collected = Collect(Vec::new());
2993 event.record(&mut collected);
2994 self.0
2995 .lock()
2996 .expect("the capture mutex is not poisoned")
2997 .push(collected.0);
2998 }
2999 }
3000
3001 #[test]
3019 fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
3020 use runner_manager_platform::logging;
3021 use tracing_subscriber::layer::SubscriberExt as _;
3022
3023 let captured = CapturedEvents::default();
3024 let error = RunnerRootError::DeniedByPrivacyPolicy {
3025 requested: PathBuf::from("/Volumes/NVME/runners"),
3026 refused: PathBuf::from("/Volumes/NVME"),
3027 remediation: RootOwner::Host.remediation(),
3028 };
3029 let kind = error.kind();
3030
3031 let failure = tracing::subscriber::with_default(
3032 tracing_subscriber::registry().with(captured.clone()),
3033 || root_failure(error),
3034 );
3035
3036 assert!(
3041 matches!(
3042 &failure,
3043 LifecycleError::Failed(FailureReason::Other(detail))
3044 if detail.contains("/Volumes/NVME/runners")
3045 && detail.contains("Full Disk Access")
3046 ),
3047 "the reason must still carry the detail: {failure:?}"
3048 );
3049
3050 let events = captured
3051 .0
3052 .lock()
3053 .expect("the capture mutex is not poisoned")
3054 .clone();
3055 let event = events
3056 .iter()
3057 .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
3058 .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
3059
3060 for (name, value) in event {
3065 assert!(
3066 logging::is_field_allowed(name),
3067 "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
3068 logging::REDACTION
3069 );
3070 assert_eq!(
3071 &logging::redact(value),
3072 value,
3073 "`{name}` does not survive value-shape scrubbing: {event:?}"
3074 );
3075 }
3076 }
3077
3078 fn nz(slot: u16) -> NonZeroU16 {
3080 NonZeroU16::new(slot).expect("a positive slot")
3081 }
3082
3083 const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
3084
3085 #[derive(Debug, Default)]
3086 struct FakeGithubLifecycle {
3087 registration_failures: Mutex<VecDeque<bool>>,
3088 observations: Mutex<VecDeque<LifecycleGithubObservation>>,
3089 registrations: AtomicUsize,
3090 remaining_runners: AtomicUsize,
3091 deregistrations: Mutex<Vec<u64>>,
3095 deregistration_fails: AtomicBool,
3098 journal: Mutex<Option<Arc<SqliteStore>>>,
3103 registration_facts: Mutex<Vec<RegistrationFact>>,
3105 }
3106
3107 #[derive(Debug, Clone)]
3114 struct RegistrationFact {
3115 leased_slots: Vec<u16>,
3117 work_folder: String,
3119 runner_name: String,
3122 }
3123
3124 impl FakeGithubLifecycle {
3125 fn fail(mut self, terminal: bool) -> Self {
3126 self.registration_failures
3127 .get_mut()
3128 .expect("unpoisoned")
3129 .push_back(terminal);
3130 self
3131 }
3132
3133 fn watch_journal(&self, store: Arc<SqliteStore>) {
3134 *self.journal.lock().unwrap() = Some(store);
3135 }
3136
3137 fn registration_facts(&self) -> Vec<RegistrationFact> {
3138 self.registration_facts.lock().unwrap().clone()
3139 }
3140
3141 fn observe(&self, observation: GithubRunnerObservation) {
3142 let observation = match observation {
3143 GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
3144 GithubRunnerObservation::NotRegistered => {
3145 LifecycleGithubObservation::not_registered()
3146 }
3147 GithubRunnerObservation::Registered { busy } => {
3148 LifecycleGithubObservation::registered(73, busy)
3149 }
3150 };
3151 self.observations.lock().unwrap().push_back(observation);
3152 }
3153 }
3154
3155 #[async_trait]
3156 impl LifecycleGithub for FakeGithubLifecycle {
3157 async fn register(
3158 &self,
3159 _target: &ScaleTarget,
3160 request: &JitRunnerRequest,
3161 _cancel: &CancelToken,
3162 ) -> Result<JitRegistration, JitRequestFailure> {
3163 self.registrations.fetch_add(1, Ordering::SeqCst);
3164 if let Some(store) = self.journal.lock().unwrap().as_ref() {
3165 let slots = store
3166 .attempts()
3167 .expect("the journal is readable")
3168 .iter()
3169 .filter_map(|attempt| attempt.workspace().slot_number())
3170 .collect();
3171 self.registration_facts
3172 .lock()
3173 .unwrap()
3174 .push(RegistrationFact {
3175 leased_slots: slots,
3176 work_folder: request.work_folder().to_string(),
3177 runner_name: request.name().to_string(),
3178 });
3179 }
3180 if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
3181 return Err(JitRequestFailure {
3182 terminal,
3183 reason: if terminal {
3184 FailureReason::Other("GitHub refused JIT registration with 403".into())
3185 } else {
3186 FailureReason::JitRequestFailed
3187 },
3188 retry_after: None,
3189 });
3190 }
3191 self.remaining_runners.store(1, Ordering::SeqCst);
3192 Ok(JitRegistration::new(
3193 EncodedJitConfig::new(JIT),
3194 JitRunner {
3195 id: 73,
3196 name: request.name().to_string(),
3197 os: "windows".into(),
3198 status: "offline".into(),
3199 busy: false,
3200 runner_group_id: Some(1),
3201 labels: request.labels().to_vec(),
3202 },
3203 ))
3204 }
3205
3206 async fn observe(
3207 &self,
3208 _target: &ScaleTarget,
3209 _attempt: AttemptId,
3210 _cancel: &CancelToken,
3211 ) -> LifecycleGithubObservation {
3212 let observation = self
3213 .observations
3214 .lock()
3215 .unwrap()
3216 .pop_front()
3217 .unwrap_or(LifecycleGithubObservation::not_registered());
3218 if observation.status == GithubRunnerObservation::NotRegistered {
3219 self.remaining_runners.store(0, Ordering::SeqCst);
3220 }
3221 observation
3222 }
3223
3224 async fn deregister(
3225 &self,
3226 _target: &ScaleTarget,
3227 runner_id: u64,
3228 _cancel: &CancelToken,
3229 ) -> bool {
3230 self.deregistrations.lock().unwrap().push(runner_id);
3231 if self.deregistration_fails.load(Ordering::SeqCst) {
3232 return false;
3233 }
3234 self.remaining_runners.store(0, Ordering::SeqCst);
3235 true
3236 }
3237 }
3238
3239 #[derive(Debug)]
3240 struct FakePackages {
3241 version: RunnerVersion,
3242 leases: Mutex<BTreeSet<AttemptId>>,
3243 materializations: AtomicUsize,
3244 materialization_failures: AtomicUsize,
3245 releases: AtomicUsize,
3246 prunes: AtomicUsize,
3247 prune_currents: Mutex<Vec<RunnerVersion>>,
3248 }
3249
3250 impl Default for FakePackages {
3251 fn default() -> Self {
3252 Self {
3253 version: RunnerVersion::parse("2.330.0").unwrap(),
3254 leases: Mutex::new(BTreeSet::new()),
3255 materializations: AtomicUsize::new(0),
3256 materialization_failures: AtomicUsize::new(0),
3257 releases: AtomicUsize::new(0),
3258 prunes: AtomicUsize::new(0),
3259 prune_currents: Mutex::new(Vec::new()),
3260 }
3261 }
3262 }
3263
3264 impl FakePackages {
3265 fn fail_materializations(&self, count: usize) {
3266 self.materialization_failures.store(count, Ordering::SeqCst);
3267 }
3268 }
3269
3270 #[async_trait]
3271 impl RuntimePackages for FakePackages {
3272 async fn materialize(
3273 &self,
3274 attempt: &RunnerAttempt,
3275 ) -> Result<RunnerVersion, FailureReason> {
3276 self.materializations.fetch_add(1, Ordering::SeqCst);
3277 if self
3278 .materialization_failures
3279 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
3280 if left > 0 { Some(left - 1) } else { None }
3281 })
3282 .is_ok()
3283 {
3284 return Err(FailureReason::Other(
3285 "runner package materialization failed transiently".into(),
3286 ));
3287 }
3288 fs::create_dir_all(attempt.runtime_path()).unwrap();
3289 fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
3290 self.leases.lock().unwrap().insert(attempt.id);
3291 Ok(self.version.clone())
3292 }
3293
3294 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
3295 self.leases.lock().unwrap().remove(&attempt);
3296 self.releases.fetch_add(1, Ordering::SeqCst);
3297 Ok(())
3298 }
3299
3300 fn prune_obsolete_guarded(
3301 &self,
3302 _authority: PruneAuthority<'_>,
3303 current: &RunnerVersion,
3304 _attempts: &[RunnerAttempt],
3305 ) -> Result<(), FailureReason> {
3306 self.prunes.fetch_add(1, Ordering::SeqCst);
3307 self.prune_currents.lock().unwrap().push(current.clone());
3308 Ok(())
3309 }
3310 }
3311
3312 #[derive(Debug, Default)]
3313 struct FakeProcesses {
3314 alive: AtomicBool,
3315 completed_successfully: AtomicBool,
3316 spawns: AtomicUsize,
3317 spawn_failures: AtomicUsize,
3318 live_spawn_failure: AtomicBool,
3319 terminations: AtomicUsize,
3320 intent: AtomicBool,
3321 intent_failure: AtomicBool,
3322 actions: Mutex<Vec<&'static str>>,
3323 saw_secret: AtomicBool,
3324 }
3325
3326 impl FakeProcesses {
3327 fn fail_spawns(&self, count: usize) {
3328 self.spawn_failures.store(count, Ordering::SeqCst);
3329 }
3330
3331 fn fail_spawn_with_live_child(&self) {
3332 self.live_spawn_failure.store(true, Ordering::SeqCst);
3333 }
3334
3335 fn set_alive(&self, alive: bool) {
3336 self.alive.store(alive, Ordering::SeqCst);
3337 }
3338
3339 fn finish_successfully(&self) {
3340 self.completed_successfully.store(true, Ordering::SeqCst);
3341 self.alive.store(false, Ordering::SeqCst);
3342 }
3343
3344 fn fail_intent(&self) {
3345 self.intent_failure.store(true, Ordering::SeqCst);
3346 }
3347 }
3348
3349 impl ProcessSupervisor for FakeProcesses {
3350 fn spawn(
3351 &self,
3352 attempt: &RunnerAttempt,
3353 config: &EncodedJitConfig,
3354 ) -> Result<u32, ProcessStartFailure> {
3355 self.spawns.fetch_add(1, Ordering::SeqCst);
3356 let handoff = RestrictiveHandoff::create(
3359 attempt.runtime_path(),
3360 SecretString::from(config.expose().to_owned()),
3361 )
3362 .unwrap();
3363 self.saw_secret
3364 .store(config.expose() == JIT, Ordering::SeqCst);
3365 let handoff_path = handoff.path().to_path_buf();
3366 let failing = self
3367 .spawn_failures
3368 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
3369 if left > 0 { Some(left - 1) } else { None }
3370 })
3371 .is_ok();
3372 drop(handoff);
3373 assert!(!handoff_path.exists(), "handoff must be absent on return");
3374 if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
3375 self.alive.store(true, Ordering::SeqCst);
3376 return Err(ProcessStartFailure::after_spawn_live(4242));
3377 }
3378 if failing {
3379 return Err(ProcessStartFailure::before_spawn(
3380 FailureReason::ProcessStartFailed,
3381 ));
3382 }
3383 self.alive.store(true, Ordering::SeqCst);
3384 Ok(4242)
3385 }
3386
3387 fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
3388 self.actions.lock().unwrap().push("observe_process");
3389 Ok(self.alive.load(Ordering::SeqCst))
3390 }
3391
3392 fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
3393 Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
3394 }
3395
3396 fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
3397 self.completed_successfully.load(Ordering::SeqCst)
3398 }
3399
3400 fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3401 self.actions.lock().unwrap().push("terminate_intent");
3402 if self.intent_failure.load(Ordering::SeqCst) {
3403 return Err(FailureReason::Other(
3404 "terminate intent directory sync failed".into(),
3405 ));
3406 }
3407 self.intent.store(true, Ordering::SeqCst);
3408 Ok(())
3409 }
3410
3411 fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
3412 self.intent.load(Ordering::SeqCst)
3413 }
3414
3415 fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3416 assert!(
3417 self.intent.load(Ordering::SeqCst),
3418 "the durable intent must exist before signalling"
3419 );
3420 self.actions.lock().unwrap().push("terminate");
3421 self.terminations.fetch_add(1, Ordering::SeqCst);
3422 self.alive.store(false, Ordering::SeqCst);
3423 Ok(())
3424 }
3425 }
3426
3427 #[derive(Debug, Default)]
3428 struct FakeDemand {
3429 answers: Mutex<VecDeque<bool>>,
3430 }
3431
3432 impl FakeDemand {
3433 fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
3434 Self {
3435 answers: Mutex::new(answers.into_iter().collect()),
3436 }
3437 }
3438 }
3439
3440 #[async_trait]
3441 impl DemandPersistence for FakeDemand {
3442 async fn persists(&self, _policy: PolicyId) -> bool {
3443 self.answers.lock().unwrap().pop_front().unwrap_or(true)
3444 }
3445 }
3446
3447 #[derive(Debug, Default)]
3448 struct FakeDelay(Mutex<Vec<Duration>>);
3449
3450 #[async_trait]
3451 impl RetryDelay for FakeDelay {
3452 async fn wait(&self, duration: Duration) {
3453 self.0.lock().unwrap().push(duration);
3454 }
3455 }
3456
3457 struct Harness {
3458 _root: tempfile::TempDir,
3459 app_paths: runner_manager_platform::paths::AppPaths,
3460 launcher: LifecycleLauncher,
3461 demand: Arc<dyn DemandPersistence>,
3462 store: Arc<SqliteStore>,
3463 github: Arc<FakeGithubLifecycle>,
3464 packages: Arc<FakePackages>,
3465 processes: Arc<FakeProcesses>,
3466 clock: Arc<FakeClock>,
3467 events: Arc<AttemptEventLog>,
3468 reconcile_events: Arc<crate::reconcile::EventLog>,
3469 delay: Arc<FakeDelay>,
3470 host: runner_manager_domain::model::Host,
3471 policy: ScalePolicy,
3472 allocation_lock: InProcessAllocationLock,
3473 workspace_root: Option<LocalAbsolutePath>,
3475 }
3476
3477 impl Harness {
3478 fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
3479 let root = tempfile::tempdir().unwrap();
3480 let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
3481 paths.create_all().unwrap();
3482 let policy = fixtures::policy()
3483 .repository("octo/repo")
3484 .autoscale("home", 2)
3485 .active()
3486 .build();
3487 let host = fixtures::host().build();
3488 let store = Arc::new(SqliteStore::open_in_memory().unwrap());
3489 store.put_host(&host).unwrap();
3490 let github = Arc::new(github);
3491 let packages = Arc::new(FakePackages::default());
3492 let processes = Arc::new(FakeProcesses::default());
3493 let clock = Arc::new(FakeClock::default());
3494 let events = Arc::new(AttemptEventLog::default());
3495 let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
3496 let delay = Arc::new(FakeDelay::default());
3497 let ports = LifecyclePorts {
3498 store: Arc::clone(&store) as Arc<dyn Store>,
3499 github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
3500 packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
3501 processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
3502 clock: Arc::clone(&clock) as Arc<dyn Clock>,
3503 demand: Arc::clone(&demand),
3504 delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
3505 events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
3506 reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
3507 };
3508 let launcher = Self::launcher_over(policy.host_id, &paths, ports);
3509 Self {
3510 _root: root,
3511 app_paths: paths,
3512 launcher,
3513 demand,
3514 store,
3515 github,
3516 packages,
3517 processes,
3518 clock,
3519 events,
3520 reconcile_events,
3521 delay,
3522 host,
3523 policy,
3524 allocation_lock: InProcessAllocationLock::new(),
3525 workspace_root: None,
3526 }
3527 }
3528
3529 fn with_host_runner_root(mut self) -> Self {
3536 let host_root = self.host_root();
3537 fs::create_dir_all(&host_root).unwrap();
3538 self.host.runner_root_override = Some(
3539 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
3540 .expect("a local absolute host root"),
3541 );
3542 self.store.put_host(&self.host).unwrap();
3543 self
3544 }
3545
3546 fn with_persistent_workspace(mut self, capacity: u16) -> Self {
3548 self = self.with_host_runner_root();
3549 let root = self._root.path().join("persist");
3550 let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
3551 .expect("a local absolute workspace root");
3552 self.policy = fixtures::policy()
3553 .repository("octo/repo")
3554 .autoscale("home", capacity)
3555 .active()
3556 .build();
3557 self.policy
3558 .set_workspace_policy(
3559 WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
3560 .expect("a repository may be persistent"),
3561 )
3562 .expect("a repository may be persistent");
3563 self.workspace_root = Some(root);
3564 self.store.insert_policy(&self.policy).unwrap();
3567 self
3568 }
3569
3570 fn workspace_root(&self) -> &LocalAbsolutePath {
3571 self.workspace_root
3572 .as_ref()
3573 .expect("this harness configured a persistent workspace")
3574 }
3575
3576 fn slot_path(&self, slot: u16) -> PathBuf {
3577 self.workspace_root().as_path().join(format!("s{slot}"))
3578 }
3579
3580 fn host_root(&self) -> PathBuf {
3581 self._root.path().join("host-root")
3582 }
3583
3584 fn attempt(&self, id: AttemptId) -> RunnerAttempt {
3585 self.store
3586 .attempt(id)
3587 .unwrap()
3588 .expect("the attempt is journalled")
3589 }
3590
3591 fn conclude(&self, id: AttemptId) -> RunnerAttempt {
3593 let mut attempt = self.attempt(id);
3594 attempt
3595 .conclude(
3596 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
3597 self.clock.now(),
3598 )
3599 .unwrap();
3600 self.store.record_attempt(&attempt).unwrap();
3601 attempt
3602 }
3603
3604 async fn cleanup_retaining_work(&self, id: AttemptId) {
3605 self.conclude(id);
3606 self.launcher
3607 .clean(id)
3608 .await
3609 .expect("the slot is scrubbed and the lease released");
3610 }
3611
3612 fn launcher_over(
3615 host: HostId,
3616 paths: &runner_manager_platform::paths::AppPaths,
3617 ports: LifecyclePorts,
3618 ) -> LifecycleLauncher {
3619 LifecycleLauncher::new(
3620 host,
3621 paths.clone(),
3622 paths.logs_dir(),
3623 1,
3624 RecoveryTimeouts::new(
3625 Elapsed::seconds(10),
3626 Elapsed::seconds(10),
3627 Elapsed::seconds(10),
3628 ),
3629 RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
3630 ports,
3631 )
3632 }
3633
3634 fn restart(&self) -> LifecycleLauncher {
3637 Self::launcher_over(
3638 self.policy.host_id,
3639 &self.app_paths,
3640 LifecyclePorts {
3641 store: Arc::clone(&self.store) as Arc<dyn Store>,
3642 github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
3643 packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
3644 processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
3645 clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
3646 demand: Arc::clone(&self.demand),
3647 delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
3648 events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
3649 reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
3650 },
3651 )
3652 }
3653
3654 async fn ready(&self) {
3655 self.launcher
3656 .recover_startup(std::slice::from_ref(&self.policy))
3657 .await
3658 .unwrap();
3659 }
3660
3661 async fn launch(&self) -> RunnerAttempt {
3662 self.launch_result().await.unwrap()
3663 }
3664
3665 async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
3666 let guard = self.allocation_lock.acquire().await.unwrap();
3667 self.launcher
3668 .launch(LaunchRequest {
3669 host: &self.host,
3670 policy: &self.policy,
3671 allocation_guard: &guard,
3672 })
3673 .await
3674 }
3675
3676 fn only_attempt(&self) -> RunnerAttempt {
3677 self.store.attempts().unwrap().into_iter().next().unwrap()
3678 }
3679 }
3680
3681 #[tokio::test]
3690 async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
3691 use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
3692
3693 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
3694 .with_host_runner_root();
3695 harness.ready().await;
3696
3697 let unusable = harness
3701 ._root
3702 .path()
3703 .join("absent")
3704 .join("deeper")
3705 .join("runners");
3706 let mut host = harness.host.clone();
3707 host.runner_root_override = Some(
3708 LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
3709 .expect("a local absolute host root"),
3710 );
3711 harness.store.put_host(&host).unwrap();
3712
3713 let failure = harness
3714 .launch_result()
3715 .await
3716 .expect_err("a root whose parents are missing cannot hold a runner");
3717 assert!(
3718 matches!(failure.reason, FailureReason::Other(_)),
3719 "{failure:?}"
3720 );
3721
3722 let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
3723 let refusal = refusals
3724 .first()
3725 .expect("the refusal reached the one surface that can hold it");
3726 assert_eq!(refusal.policy, harness.policy.id.to_string());
3727 assert_eq!(refusal.kind, "missing_parents");
3728 assert!(
3729 refusal.root.contains("runners") && refusal.detail.contains("runners"),
3730 "the directory must be named in full: {refusal:?}"
3731 );
3732
3733 harness.store.put_host(&harness.host).unwrap();
3736 harness.launch().await;
3737 assert!(
3738 runner_root_refusals(&harness.app_paths)
3739 .expect("readable")
3740 .is_empty(),
3741 "a successful placement clears that policy's record"
3742 );
3743
3744 clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
3745 .expect("cleanup");
3746 }
3747
3748 #[tokio::test]
3749 async fn a_job_walks_every_state_and_cleans_every_artifact() {
3750 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3751 harness.ready().await;
3752 let started = harness.launch().await;
3753 assert_eq!(started.state(), AttemptState::Starting);
3754 assert_eq!(read_runner_id(started.runtime_path()), Some(73));
3755
3756 harness
3757 .github
3758 .observe(GithubRunnerObservation::Registered { busy: false });
3759 harness.launcher.supervise(&harness.policy).await.unwrap();
3760 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
3761
3762 harness
3763 .github
3764 .observe(GithubRunnerObservation::Registered { busy: true });
3765 harness.launcher.supervise(&harness.policy).await.unwrap();
3766 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
3767
3768 harness.processes.finish_successfully();
3769 harness
3770 .github
3771 .observe(GithubRunnerObservation::NotRegistered);
3772 harness.launcher.supervise(&harness.policy).await.unwrap();
3773 let cleaned = harness.only_attempt();
3774 assert_eq!(cleaned.state(), AttemptState::Cleaned);
3775 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
3776 assert!(!started.runtime_path().exists());
3777 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
3778 assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
3779
3780 let states: Vec<_> = harness
3781 .events
3782 .events()
3783 .into_iter()
3784 .filter_map(|event| match event {
3785 AttemptEvent::State { state, .. } => Some(state),
3786 _ => None,
3787 })
3788 .collect();
3789 assert_eq!(
3790 states,
3791 vec![
3792 AttemptState::Allocated,
3793 AttemptState::JitReceived,
3794 AttemptState::Starting,
3795 AttemptState::Idle,
3796 AttemptState::Busy,
3797 AttemptState::Finished,
3798 AttemptState::Cleaned,
3799 ]
3800 );
3801 }
3802
3803 #[tokio::test]
3804 async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
3805 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3806 harness.ready().await;
3807 let started = harness.launch().await;
3808 harness
3809 .github
3810 .observe(GithubRunnerObservation::Registered { busy: false });
3811 harness.launcher.supervise(&harness.policy).await.unwrap();
3812 harness.clock.advance_secs(11);
3813 harness.processes.set_alive(false);
3814 harness
3815 .github
3816 .observe(GithubRunnerObservation::NotRegistered);
3817 harness.launcher.supervise(&harness.policy).await.unwrap();
3818
3819 let cleaned = harness.only_attempt();
3820 assert!(cleaned.outcome().unwrap().is_idle_exit());
3821 assert!(!cleaned.outcome().unwrap().is_failure());
3822 assert!(!started.runtime_path().exists());
3823 assert!(
3824 harness
3825 .reconcile_events
3826 .events()
3827 .iter()
3828 .any(|event| matches!(
3829 event,
3830 LifecycleEvent::AttemptCleaned {
3831 outcome: OutcomeKind::IdleExit,
3832 ..
3833 }
3834 ))
3835 );
3836 assert!(!harness.events.events().iter().any(|event| matches!(
3837 event,
3838 AttemptEvent::Concluded {
3839 outcome: OutcomeKind::Failed,
3840 ..
3841 }
3842 )));
3843 }
3844
3845 #[tokio::test]
3846 async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
3847 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3848 harness.processes.fail_spawns(2);
3849 harness.ready().await;
3850 let attempt = harness.launch().await;
3851 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
3852 assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
3853 let names: Vec<_> = fs::read_dir(attempt.runtime_path())
3854 .unwrap()
3855 .map(|entry| entry.unwrap().file_name())
3856 .collect();
3857 assert!(
3858 names.iter().all(|name| {
3859 !name
3860 .to_string_lossy()
3861 .starts_with(RestrictiveHandoff::NAME_PREFIX)
3862 }),
3863 "JIT artifact survived: {names:?}"
3864 );
3865 assert_eq!(
3866 *harness.delay.0.lock().unwrap(),
3867 vec![Duration::from_millis(10), Duration::from_millis(20)]
3868 );
3869 }
3870
3871 #[tokio::test]
3872 async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
3873 let gone = Harness::new(
3874 FakeGithubLifecycle::default().fail(false),
3875 Arc::new(FakeDemand::answering([false])),
3876 );
3877 gone.ready().await;
3878 assert!(gone.launch_result().await.is_err());
3879 assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
3880 assert!(gone.delay.0.lock().unwrap().is_empty());
3881
3882 let forbidden = Harness::new(
3883 FakeGithubLifecycle::default().fail(true),
3884 Arc::new(PersistentDemand),
3885 );
3886 forbidden.ready().await;
3887 assert!(forbidden.launch_result().await.is_err());
3888 assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
3889 assert!(forbidden.delay.0.lock().unwrap().is_empty());
3890 assert!(matches!(
3891 forbidden.only_attempt().outcome(),
3892 Some(AttemptOutcome::Failed {
3893 reason: FailureReason::Other(action)
3894 }) if action.contains("403")
3895 ));
3896
3897 let transient = Harness::new(
3898 FakeGithubLifecycle::default().fail(false).fail(false),
3899 Arc::new(PersistentDemand),
3900 );
3901 transient.ready().await;
3902 transient.launch().await;
3903 assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
3904 assert_eq!(
3905 *transient.delay.0.lock().unwrap(),
3906 vec![Duration::from_millis(10), Duration::from_millis(20)]
3907 );
3908 }
3909
3910 #[test]
3918 fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
3919 const MAX_PATH: usize = 260;
3920 let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
3922 let repo = "GitHub-Runner-Scaler-UI";
3926 let deepest = format!(
3927 r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
3928 "0".repeat(40)
3929 );
3930
3931 let name = workspace_name(AttemptId::new_random());
3932 assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
3933 assert!(
3934 name.chars().all(|c| c.is_ascii_hexdigit()),
3935 "a directory name must not carry the identifier's dashes: {name}"
3936 );
3937
3938 let full = format!(r"{root}\{name}\{deepest}");
3939 assert!(
3940 full.len() < MAX_PATH,
3941 "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
3942 full.len()
3943 );
3944
3945 let old = format!(
3948 r"{root}\{}\{}\{deepest}",
3949 PolicyId::new_random(),
3950 AttemptId::new_random()
3951 );
3952 assert!(
3953 old.len() > MAX_PATH,
3954 "the old layout is supposed to be the thing that did not fit: {} characters",
3955 old.len()
3956 );
3957 }
3958
3959 #[tokio::test]
3960 async fn two_attempts_never_share_a_workspace_even_after_failure() {
3961 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3962 harness.ready().await;
3963 let first = harness.launch().await;
3964 fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
3965 harness
3966 .github
3967 .observe(GithubRunnerObservation::Registered { busy: false });
3968 harness.launcher.supervise(&harness.policy).await.unwrap();
3969 harness.clock.advance_secs(11);
3970 harness.processes.set_alive(false);
3971 harness
3972 .github
3973 .observe(GithubRunnerObservation::NotRegistered);
3974 harness.launcher.supervise(&harness.policy).await.unwrap();
3975 assert!(!first.runtime_path().exists());
3976
3977 let second = harness.launch().await;
3978 assert_ne!(first.runtime_path(), second.runtime_path());
3979 assert!(!second.runtime_path().join("hostile-leftover").exists());
3980
3981 fs::write(
3982 second.runtime_path().join("hostile-on-failure"),
3983 b"second job",
3984 )
3985 .unwrap();
3986 harness.processes.set_alive(false);
3987 harness
3988 .github
3989 .observe(GithubRunnerObservation::NotRegistered);
3990 harness.launcher.supervise(&harness.policy).await.unwrap();
3991 assert!(
3992 !second.runtime_path().exists(),
3993 "failed workspace was retained"
3994 );
3995 }
3996
3997 #[tokio::test]
3998 async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
3999 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4000 harness.ready().await;
4001 let attempt = harness.launch().await;
4002
4003 harness
4007 .github
4008 .observe(GithubRunnerObservation::Registered { busy: false });
4009 harness.launcher.supervise(&harness.policy).await.unwrap();
4010 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
4011
4012 harness.clock.advance_secs(9);
4015 harness
4016 .github
4017 .observe(GithubRunnerObservation::Registered { busy: false });
4018 let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
4019 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
4020 assert!(none_yet.is_empty());
4021 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
4022
4023 harness.clock.advance_secs(1);
4025 harness
4026 .github
4027 .observe(GithubRunnerObservation::Registered { busy: false });
4028 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4029
4030 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
4031 assert_eq!(
4032 concluded.outcome(),
4033 Some(&AttemptOutcome::ExitedIdleWithoutWork),
4034 "a surplus runner did not fail; recording one as a failure sends an operator \
4035 hunting a fault that does not exist"
4036 );
4037 assert_eq!(concluded.state(), AttemptState::Cleaned);
4038 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
4039 assert!(!attempt.runtime_path().exists());
4040
4041 assert_eq!(
4044 *harness.github.deregistrations.lock().unwrap(),
4045 vec![73],
4046 "the attempt's own runner id, deleted exactly once"
4047 );
4048
4049 assert!(
4052 replacements.is_empty(),
4053 "a surplus exit must not request a replacement"
4054 );
4055 }
4056
4057 #[tokio::test]
4058 async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
4059 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4063 harness.ready().await;
4064 let attempt = harness.launch().await;
4065 harness
4066 .github
4067 .observe(GithubRunnerObservation::Registered { busy: false });
4068 harness.launcher.supervise(&harness.policy).await.unwrap();
4069
4070 harness
4071 .github
4072 .deregistration_fails
4073 .store(true, Ordering::SeqCst);
4074 harness.clock.advance_secs(11);
4075 harness
4076 .github
4077 .observe(GithubRunnerObservation::Registered { busy: false });
4078 harness.launcher.supervise(&harness.policy).await.unwrap();
4079
4080 assert_eq!(
4081 *harness.github.deregistrations.lock().unwrap(),
4082 vec![73],
4083 "the delete was attempted"
4084 );
4085 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
4086 assert_eq!(
4087 concluded.outcome(),
4088 Some(&AttemptOutcome::ExitedIdleWithoutWork),
4089 "the attempt concluded anyway"
4090 );
4091 assert_eq!(concluded.state(), AttemptState::Cleaned);
4092 assert!(!attempt.runtime_path().exists());
4093 }
4094
4095 #[tokio::test]
4096 async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
4097 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4098 harness.ready().await;
4099 let first = harness.launch().await;
4100 harness.processes.set_alive(false);
4101 harness
4102 .github
4103 .observe(GithubRunnerObservation::NotRegistered);
4104 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4105 let failed = harness.store.attempt(first.id).unwrap().unwrap();
4106 assert!(matches!(
4107 failed.outcome(),
4108 Some(AttemptOutcome::Failed {
4109 reason: FailureReason::ProcessExitedUnexpectedly
4110 })
4111 ));
4112 assert!(!first.runtime_path().exists());
4113
4114 assert_eq!(
4115 replacements,
4116 vec![ReplacementIntent {
4117 policy: harness.policy.id,
4118 previous_attempt: first.id,
4119 operation: "exit_before_acceptance_replacement",
4120 }]
4121 );
4122 assert_eq!(harness.store.attempts().unwrap().len(), 1);
4123 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
4124 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
4125 assert!(harness.delay.0.lock().unwrap().is_empty());
4126 }
4127
4128 #[tokio::test]
4129 async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
4130 let harness = Harness::new(
4131 FakeGithubLifecycle::default(),
4132 Arc::new(FakeDemand::answering([false])),
4133 );
4134 let id = AttemptId::new_random();
4135 let runtime = harness
4136 .launcher
4137 .app_paths
4138 .runtime_dir()
4139 .join(harness.policy.id.to_string())
4140 .join(id.to_string());
4141 fs::create_dir_all(&runtime).unwrap();
4142 let mut attempt =
4143 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4144 attempt.jit_received(harness.clock.now()).unwrap();
4145 harness.store.record_attempt(&attempt).unwrap();
4146 harness.clock.advance_secs(11);
4147 let replacements = harness
4148 .launcher
4149 .recover_startup(std::slice::from_ref(&harness.policy))
4150 .await
4151 .unwrap();
4152 assert_eq!(
4153 replacements,
4154 vec![ReplacementIntent {
4155 policy: harness.policy.id,
4156 previous_attempt: id,
4157 operation: "jit_expired_replacement",
4158 }]
4159 );
4160
4161 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4162 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4163 assert!(matches!(
4164 cleaned.outcome(),
4165 Some(AttemptOutcome::Failed {
4166 reason: FailureReason::JitExpired
4167 })
4168 ));
4169 assert!(!runtime.exists());
4170 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
4171 assert!(harness.delay.0.lock().unwrap().is_empty());
4172 }
4173
4174 #[tokio::test]
4175 async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
4176 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4177 let id = AttemptId::new_random();
4178 let runtime = harness
4179 .launcher
4180 .app_paths
4181 .runtime_dir()
4182 .join("expired-with-demand");
4183 fs::create_dir_all(&runtime).unwrap();
4184 let mut attempt =
4185 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4186 attempt.jit_received(harness.clock.now()).unwrap();
4187 harness.store.record_attempt(&attempt).unwrap();
4188 harness.clock.advance_secs(11);
4189 let replacements = harness
4190 .launcher
4191 .recover_startup(std::slice::from_ref(&harness.policy))
4192 .await
4193 .unwrap();
4194
4195 let attempts = harness.store.attempts().unwrap();
4196 assert_eq!(attempts.len(), 1);
4197 assert_eq!(
4198 attempts
4199 .iter()
4200 .find(|attempt| attempt.id == id)
4201 .unwrap()
4202 .state(),
4203 AttemptState::Cleaned
4204 );
4205 assert_eq!(
4206 replacements,
4207 vec![ReplacementIntent {
4208 policy: harness.policy.id,
4209 previous_attempt: id,
4210 operation: "jit_expired_replacement",
4211 }]
4212 );
4213 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
4214 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
4215 assert!(harness.delay.0.lock().unwrap().is_empty());
4216 }
4217
4218 #[tokio::test]
4219 async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
4220 let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4221 persistent.packages.fail_materializations(2);
4222 persistent.ready().await;
4223 persistent.launch().await;
4224 assert_eq!(
4225 persistent.packages.materializations.load(Ordering::SeqCst),
4226 3
4227 );
4228 assert_eq!(
4229 *persistent.delay.0.lock().unwrap(),
4230 vec![Duration::from_millis(10), Duration::from_millis(20)]
4231 );
4232
4233 let gone_before_wait = Harness::new(
4234 FakeGithubLifecycle::default(),
4235 Arc::new(FakeDemand::answering([false])),
4236 );
4237 gone_before_wait.packages.fail_materializations(3);
4238 gone_before_wait.ready().await;
4239 assert!(gone_before_wait.launch_result().await.is_err());
4240 assert_eq!(
4241 gone_before_wait
4242 .packages
4243 .materializations
4244 .load(Ordering::SeqCst),
4245 1
4246 );
4247 assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
4248
4249 let gone_during_wait = Harness::new(
4250 FakeGithubLifecycle::default(),
4251 Arc::new(FakeDemand::answering([true, false])),
4252 );
4253 gone_during_wait.packages.fail_materializations(3);
4254 gone_during_wait.ready().await;
4255 assert!(gone_during_wait.launch_result().await.is_err());
4256 assert_eq!(
4257 gone_during_wait
4258 .packages
4259 .materializations
4260 .load(Ordering::SeqCst),
4261 1
4262 );
4263 assert_eq!(
4264 *gone_during_wait.delay.0.lock().unwrap(),
4265 vec![Duration::from_millis(10)]
4266 );
4267 }
4268
4269 #[tokio::test]
4270 async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
4271 let harness = Harness::new(
4272 FakeGithubLifecycle::default(),
4273 Arc::new(FakeDemand::answering([true, false])),
4274 );
4275 harness.ready().await;
4276 let first = harness.launch().await;
4277 harness.processes.set_alive(false);
4278 harness
4279 .github
4280 .observe(GithubRunnerObservation::NotRegistered);
4281 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4282
4283 assert_eq!(harness.store.attempts().unwrap().len(), 1);
4284 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
4285 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
4286 assert!(harness.delay.0.lock().unwrap().is_empty());
4287 assert_eq!(
4288 replacements,
4289 vec![ReplacementIntent {
4290 policy: harness.policy.id,
4291 previous_attempt: first.id,
4292 operation: "exit_before_acceptance_replacement",
4293 }]
4294 );
4295 assert_eq!(
4296 harness.store.attempt(first.id).unwrap().unwrap().state(),
4297 AttemptState::Cleaned
4298 );
4299 }
4300
4301 #[tokio::test]
4302 async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
4303 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4304 let before = harness.launch_result().await;
4305 assert!(before.is_err());
4306 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
4307
4308 let id = AttemptId::new_random();
4309 let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
4310 fs::create_dir_all(&runtime).unwrap();
4311 let mut attempt =
4312 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
4313 attempt.jit_received(harness.clock.now()).unwrap();
4314 attempt.started(4242, harness.clock.now()).unwrap();
4315 harness.store.record_attempt(&attempt).unwrap();
4316 harness.processes.set_alive(true);
4317 harness
4318 .github
4319 .observe(GithubRunnerObservation::NotRegistered);
4320 let replacements = harness
4321 .launcher
4322 .recover_startup(std::slice::from_ref(&harness.policy))
4323 .await
4324 .unwrap();
4325 assert!(replacements.is_empty());
4326 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
4327 assert!(
4328 harness
4329 .events
4330 .events()
4331 .contains(&AttemptEvent::Adopted { attempt: id })
4332 );
4333 }
4334
4335 #[tokio::test]
4336 async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
4337 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4338 let id = AttemptId::new_random();
4339 let runtime = harness
4340 .launcher
4341 .app_paths
4342 .runtime_dir()
4343 .join("spawn-before-starting");
4344 fs::create_dir_all(&runtime).unwrap();
4345 let mut attempt =
4346 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4347 attempt.jit_received(harness.clock.now()).unwrap();
4348 harness.store.record_attempt(&attempt).unwrap();
4349 harness.processes.set_alive(true);
4350 harness
4351 .github
4352 .observe(GithubRunnerObservation::Registered { busy: true });
4353
4354 let replacements = harness
4355 .launcher
4356 .recover_startup(std::slice::from_ref(&harness.policy))
4357 .await
4358 .unwrap();
4359 assert!(replacements.is_empty());
4360 let recovered = harness.store.attempt(id).unwrap().unwrap();
4361 assert_eq!(recovered.state(), AttemptState::Busy);
4362 assert_eq!(recovered.process_id(), Some(4242));
4363 assert_eq!(recovered.github_runner_id(), Some(73));
4364 let events = harness.events.events();
4365 let starting = events
4366 .iter()
4367 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
4368 .unwrap();
4369 let busy = events
4370 .iter()
4371 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
4372 .unwrap();
4373 assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
4374
4375 harness.processes.finish_successfully();
4376 harness
4377 .github
4378 .observe(GithubRunnerObservation::NotRegistered);
4379 assert!(
4380 harness
4381 .launcher
4382 .supervise(&harness.policy)
4383 .await
4384 .unwrap()
4385 .is_empty()
4386 );
4387 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4388 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4389 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
4390 assert!(!runtime.exists());
4391 }
4392
4393 #[tokio::test]
4394 async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
4395 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4396 harness.processes.fail_spawn_with_live_child();
4397 harness.ready().await;
4398 assert!(harness.launch_result().await.is_err());
4399
4400 let attempt = harness.only_attempt();
4401 assert_eq!(attempt.state(), AttemptState::Starting);
4402 assert_eq!(attempt.process_id(), Some(4242));
4403 assert!(attempt.outcome().is_none());
4404 assert!(attempt.state().counts_against_capacity());
4405 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
4406 assert!(harness.delay.0.lock().unwrap().is_empty());
4407
4408 harness.processes.set_alive(false);
4409 harness
4410 .github
4411 .observe(GithubRunnerObservation::NotRegistered);
4412 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4413 assert_eq!(replacements.len(), 1);
4414 assert_eq!(
4415 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
4416 AttemptState::Cleaned
4417 );
4418 }
4419
4420 #[tokio::test]
4421 async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
4422 for sidecar_already_present in [false, true] {
4423 let harness = Harness::new(
4424 FakeGithubLifecycle::default(),
4425 Arc::new(FakeDemand::answering([false])),
4426 );
4427 let id = AttemptId::new_random();
4428 let runtime =
4429 harness
4430 .launcher
4431 .app_paths
4432 .runtime_dir()
4433 .join(if sidecar_already_present {
4434 "after-id-sidecar"
4435 } else {
4436 "before-id-sidecar"
4437 });
4438 fs::create_dir_all(&runtime).unwrap();
4439 let mut attempt =
4440 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4441 if sidecar_already_present {
4442 write_runner_id(&runtime, 73).unwrap();
4443 attempt.jit_received(harness.clock.now()).unwrap();
4444 }
4445 harness.store.record_attempt(&attempt).unwrap();
4446 harness.processes.set_alive(true);
4447 harness
4448 .github
4449 .observe(GithubRunnerObservation::Registered { busy: false });
4450 harness
4451 .launcher
4452 .recover_startup(std::slice::from_ref(&harness.policy))
4453 .await
4454 .unwrap();
4455
4456 assert_eq!(read_runner_id(&runtime), Some(73));
4457 assert!(
4458 harness
4459 .store
4460 .attempt(id)
4461 .unwrap()
4462 .unwrap()
4463 .outcome()
4464 .is_none()
4465 );
4466 let events = harness.events.events();
4467 let recovered = events.iter().position(|event| {
4468 matches!(
4469 event,
4470 AttemptEvent::RemoteIdentityRecovered {
4471 attempt,
4472 runner_id: 73
4473 } if *attempt == id
4474 )
4475 });
4476 assert_eq!(recovered.is_some(), !sidecar_already_present);
4477 if let Some(recovered) = recovered {
4478 let adopted = events
4479 .iter()
4480 .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
4481 .unwrap();
4482 assert!(
4483 recovered < adopted,
4484 "identity was not durable before adoption: {events:?}"
4485 );
4486 }
4487 assert!(runtime.exists());
4488 }
4489 }
4490
4491 #[tokio::test]
4492 async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
4493 let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4494 let unknown_attempt = RunnerAttempt::allocate(
4495 AttemptId::new_random(),
4496 PolicyId::from_u128(0xfeed),
4497 unknown
4498 .launcher
4499 .app_paths
4500 .runtime_dir()
4501 .join("unknown-policy"),
4502 unknown.clock.now(),
4503 );
4504 unknown.store.record_attempt(&unknown_attempt).unwrap();
4505 let expired_id = AttemptId::new_random();
4506 let expired_runtime = unknown
4507 .launcher
4508 .app_paths
4509 .runtime_dir()
4510 .join("expired-beside-unknown");
4511 fs::create_dir_all(&expired_runtime).unwrap();
4512 let mut expired = RunnerAttempt::allocate(
4513 expired_id,
4514 unknown.policy.id,
4515 expired_runtime,
4516 unknown.clock.now(),
4517 );
4518 expired.jit_received(unknown.clock.now()).unwrap();
4519 unknown.store.record_attempt(&expired).unwrap();
4520 unknown.clock.advance_secs(11);
4521 assert!(matches!(
4522 unknown
4523 .launcher
4524 .recover_startup(std::slice::from_ref(&unknown.policy))
4525 .await,
4526 Err(LifecycleError::RecoveryIncomplete)
4527 ));
4528 assert!(unknown.launch_result().await.is_err());
4529 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
4530 let recovered_policy = fixtures::policy()
4531 .id(PolicyId::from_u128(0xfeed))
4532 .repository("octo/repo")
4533 .autoscale("home", 2)
4534 .active()
4535 .build();
4536 let pending = unknown
4537 .launcher
4538 .recover_startup(&[unknown.policy.clone(), recovered_policy])
4539 .await
4540 .unwrap();
4541 assert_eq!(
4542 pending,
4543 vec![ReplacementIntent {
4544 policy: unknown.policy.id,
4545 previous_attempt: expired_id,
4546 operation: "jit_expired_replacement",
4547 }]
4548 );
4549 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
4550
4551 let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4552 let id = AttemptId::new_random();
4553 let runtime = unreachable
4554 .launcher
4555 .app_paths
4556 .runtime_dir()
4557 .join("unreachable");
4558 fs::create_dir_all(&runtime).unwrap();
4559 unreachable
4560 .store
4561 .record_attempt(&RunnerAttempt::allocate(
4562 id,
4563 unreachable.policy.id,
4564 runtime,
4565 unreachable.clock.now(),
4566 ))
4567 .unwrap();
4568 unreachable
4569 .github
4570 .observe(GithubRunnerObservation::Unreachable);
4571 assert!(matches!(
4572 unreachable
4573 .launcher
4574 .recover_startup(std::slice::from_ref(&unreachable.policy))
4575 .await,
4576 Err(LifecycleError::RecoveryIncomplete)
4577 ));
4578 assert!(unreachable.launch_result().await.is_err());
4579 assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
4580 }
4581
4582 #[tokio::test]
4583 async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
4584 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4585 let id = AttemptId::new_random();
4586 let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
4587 fs::create_dir_all(&runtime).unwrap();
4588 let mut attempt =
4589 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4590 attempt.jit_received(harness.clock.now()).unwrap();
4591 attempt.started(4242, harness.clock.now()).unwrap();
4592 attempt.assigned_job(73, harness.clock.now()).unwrap();
4593 harness.store.record_attempt(&attempt).unwrap();
4594 harness.processes.set_alive(false);
4595 harness
4596 .github
4597 .observe(GithubRunnerObservation::NotRegistered);
4598 harness
4599 .launcher
4600 .recover_startup(std::slice::from_ref(&harness.policy))
4601 .await
4602 .unwrap();
4603 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4604 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4605 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
4606 assert!(!runtime.exists());
4607 }
4608
4609 #[tokio::test]
4610 async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
4611 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4612 harness.ready().await;
4613 let id = AttemptId::new_random();
4614 let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
4615 fs::create_dir_all(&runtime).unwrap();
4616 let mut attempt =
4617 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
4618 attempt.jit_received(harness.clock.now()).unwrap();
4619 attempt.started(4242, harness.clock.now()).unwrap();
4620 harness.store.record_attempt(&attempt).unwrap();
4621 harness.clock.advance_secs(11);
4622 harness.processes.set_alive(true);
4623 harness
4624 .github
4625 .observe(GithubRunnerObservation::NotRegistered);
4626 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4627 assert_eq!(
4628 replacements,
4629 vec![ReplacementIntent {
4630 policy: harness.policy.id,
4631 previous_attempt: id,
4632 operation: "registration_timeout_replacement",
4633 }]
4634 );
4635
4636 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
4637 assert!(!harness.processes.alive.load(Ordering::SeqCst));
4638 let actions = harness.processes.actions.lock().unwrap().clone();
4639 let intent = actions
4640 .iter()
4641 .position(|action| *action == "terminate_intent")
4642 .unwrap();
4643 let signal = actions
4644 .iter()
4645 .position(|action| *action == "terminate")
4646 .unwrap();
4647 assert!(
4648 intent < signal,
4649 "intent was not durable before signal: {actions:?}"
4650 );
4651
4652 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4653 assert!(matches!(
4654 cleaned.outcome(),
4655 Some(AttemptOutcome::Failed {
4656 reason: FailureReason::TerminatedAfterRegistrationTimeout
4657 })
4658 ));
4659 let events = harness.events.events();
4660 let intent = events
4661 .iter()
4662 .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
4663 .unwrap();
4664 let stopped = events
4665 .iter()
4666 .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
4667 .unwrap();
4668 let concluded = events
4669 .iter()
4670 .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
4671 .unwrap();
4672 assert!(intent < stopped && stopped < concluded, "{events:?}");
4673 }
4674
4675 #[tokio::test]
4676 async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
4677 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4678 let id = AttemptId::new_random();
4679 let runtime = harness
4680 .launcher
4681 .app_paths
4682 .runtime_dir()
4683 .join("timeout-after-crash");
4684 fs::create_dir_all(&runtime).unwrap();
4685 let mut attempt =
4686 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
4687 attempt.jit_received(harness.clock.now()).unwrap();
4688 attempt.started(4242, harness.clock.now()).unwrap();
4689 harness.store.record_attempt(&attempt).unwrap();
4690 harness.processes.intent.store(true, Ordering::SeqCst);
4691 harness.processes.set_alive(false);
4692 harness
4693 .github
4694 .observe(GithubRunnerObservation::NotRegistered);
4695
4696 let replacements = harness
4697 .launcher
4698 .recover_startup(std::slice::from_ref(&harness.policy))
4699 .await
4700 .unwrap();
4701 assert_eq!(
4702 replacements,
4703 vec![ReplacementIntent {
4704 policy: harness.policy.id,
4705 previous_attempt: id,
4706 operation: "registration_timeout_replacement",
4707 }]
4708 );
4709 let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
4710 .await
4711 .unwrap();
4712 assert_eq!(consumed, replacements);
4713 assert!(
4714 RunnerLauncher::supervise(&harness.launcher, &harness.policy)
4715 .await
4716 .unwrap()
4717 .is_empty(),
4718 "startup replacement evidence must be consumed exactly once by e1"
4719 );
4720 assert!(matches!(
4721 harness.store.attempt(id).unwrap().unwrap().outcome(),
4722 Some(AttemptOutcome::Failed {
4723 reason: FailureReason::TerminatedAfterRegistrationTimeout
4724 })
4725 ));
4726 }
4727
4728 #[tokio::test]
4729 async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
4730 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4731 let id = AttemptId::new_random();
4732 let runtime = harness
4733 .launcher
4734 .app_paths
4735 .runtime_dir()
4736 .join("timeout-sync-failure");
4737 fs::create_dir_all(&runtime).unwrap();
4738 let mut attempt =
4739 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4740 attempt.jit_received(harness.clock.now()).unwrap();
4741 attempt.started(4242, harness.clock.now()).unwrap();
4742 harness.store.record_attempt(&attempt).unwrap();
4743 harness.clock.advance_secs(11);
4744 harness.processes.set_alive(true);
4745 harness.processes.fail_intent();
4746 harness
4747 .github
4748 .observe(GithubRunnerObservation::NotRegistered);
4749
4750 assert!(
4751 harness
4752 .launcher
4753 .recover_startup(std::slice::from_ref(&harness.policy))
4754 .await
4755 .is_err()
4756 );
4757 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
4758 assert!(harness.processes.alive.load(Ordering::SeqCst));
4759 assert_eq!(
4760 harness.store.attempt(id).unwrap().unwrap().state(),
4761 AttemptState::Starting
4762 );
4763 assert!(!harness.events.events().iter().any(|event| matches!(
4764 event,
4765 AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
4766 if *attempt == id
4767 )));
4768 }
4769
4770 #[tokio::test]
4771 async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
4772 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4773 harness.ready().await;
4774 let attempt = harness.launch().await;
4775 harness
4776 .github
4777 .observe(GithubRunnerObservation::Registered { busy: false });
4778 harness.launcher.supervise(&harness.policy).await.unwrap();
4779 harness.clock.advance_secs(11);
4780 harness.processes.set_alive(false);
4781 harness
4782 .github
4783 .observe(GithubRunnerObservation::NotRegistered);
4784 harness.launcher.supervise(&harness.policy).await.unwrap();
4785 let diagnostic = fs::read_to_string(
4786 harness
4787 .launcher
4788 .diagnostics_root
4789 .join(format!("{}.log", attempt.id)),
4790 )
4791 .unwrap();
4792 assert!(diagnostic.contains("exited_idle_without_work"));
4793 assert!(!diagnostic.contains(JIT));
4794 assert!(!diagnostic.contains("ghp_"));
4795 assert!(!attempt.runtime_path().exists());
4796 }
4797
4798 #[test]
4799 fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
4800 let root = tempfile::tempdir().unwrap();
4801 let policy = fixtures::policy()
4802 .repository("octo/repo")
4803 .autoscale("home", 1)
4804 .active()
4805 .build();
4806 let runtime = root.path().join("successful");
4807 fs::create_dir_all(&runtime).unwrap();
4808 let processes = NativeProcesses::new();
4809 let config = EncodedJitConfig::new(JIT);
4810 let handoff =
4811 RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
4812 .unwrap();
4813 let mut child = native_inspection_spec()
4814 .spawn_runner_with_handoff(&handoff)
4815 .expect("native child starts");
4816 let pid = child.pid();
4817 handoff.delete().unwrap();
4818 let command_line = native_command_line(pid);
4819 assert!(
4820 !command_line.contains(JIT),
4821 "the encoded JIT configuration appeared in the native process listing"
4822 );
4823 assert_no_jit_file(&runtime);
4824 child
4825 .stop(Duration::from_secs(1))
4826 .expect("native child stops");
4827
4828 let failed_runtime = root.path().join("failed");
4829 fs::create_dir_all(&failed_runtime).unwrap();
4830 let failed = RunnerAttempt::allocate(
4831 AttemptId::new_random(),
4832 policy.id,
4833 &failed_runtime,
4834 FakeClock::default().now(),
4835 );
4836 assert!(
4837 processes
4838 .spawn(&failed, &EncodedJitConfig::new(JIT))
4839 .is_err(),
4840 "a runtime with no runner executable must fail"
4841 );
4842 assert_no_jit_file(&failed_runtime);
4843 processes
4844 .record_terminate_intent(&failed)
4845 .expect("the intent file and its directory entry are durably synced");
4846 assert_eq!(
4847 fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
4848 b"registration-timeout\n"
4849 );
4850 }
4851
4852 #[test]
4853 fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
4854 let root = tempfile::tempdir().unwrap();
4855 let policy = fixtures::policy()
4856 .repository("octo/repo")
4857 .autoscale("home", 1)
4858 .active()
4859 .build();
4860 let processes = NativeProcesses::new();
4861 processes.use_long_lived_test_listener();
4862 for (index, boundary) in [
4863 PostSpawnBoundary::HandoffDelete,
4864 PostSpawnBoundary::IdentitySerialize,
4865 PostSpawnBoundary::IdentityWrite,
4866 PostSpawnBoundary::ChildMapInsert,
4867 ]
4868 .into_iter()
4869 .enumerate()
4870 {
4871 let runtime = root.path().join(format!("post-spawn-{index}"));
4872 let bin = runtime.join("bin");
4873 fs::create_dir_all(&bin).unwrap();
4874 #[cfg(windows)]
4875 let listener = bin.join("Runner.Listener.exe");
4876 #[cfg(not(windows))]
4877 let listener = bin.join("Runner.Listener");
4878 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
4879 let attempt = RunnerAttempt::allocate(
4880 AttemptId::new_random(),
4881 policy.id,
4882 &runtime,
4883 FakeClock::default().now(),
4884 );
4885 processes.fail_post_spawn_at(boundary);
4886 let failure = processes
4887 .spawn(&attempt, &EncodedJitConfig::new(JIT))
4888 .expect_err("fault must cross the post-spawn cleanup path");
4889 assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
4890 assert!(
4891 !processes.is_alive(&attempt).unwrap(),
4892 "{boundary:?} left a child"
4893 );
4894 assert!(!NativeProcesses::identity_path(&attempt).exists());
4895 assert_no_jit_file(&runtime);
4896 }
4897 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
4898
4899 let runtime = root.path().join("identity-and-stop-fail");
4900 let bin = runtime.join("bin");
4901 fs::create_dir_all(&bin).unwrap();
4902 #[cfg(windows)]
4903 let listener = bin.join("Runner.Listener.exe");
4904 #[cfg(not(windows))]
4905 let listener = bin.join("Runner.Listener");
4906 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
4907 let attempt = RunnerAttempt::allocate(
4908 AttemptId::new_random(),
4909 policy.id,
4910 &runtime,
4911 FakeClock::default().now(),
4912 );
4913 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
4917 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
4918 processes.fail_next_post_spawn_stop();
4919 let failure = processes
4920 .spawn(&attempt, &EncodedJitConfig::new(JIT))
4921 .expect_err("the identity boundary must fail closed");
4922 assert!(failure.live_pid.is_some());
4923 assert_long_lived_listener_ready(&processes, &attempt);
4924 assert!(processes.is_alive(&attempt).unwrap());
4925 assert!(!NativeProcesses::identity_path(&attempt).exists());
4926 assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
4927 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
4928 processes.terminate(&attempt).unwrap();
4929
4930 let runtime = root.path().join("persistent-stop-and-identity-failures");
4931 let bin = runtime.join("bin");
4932 fs::create_dir_all(&bin).unwrap();
4933 #[cfg(windows)]
4934 let listener = bin.join("Runner.Listener.exe");
4935 #[cfg(not(windows))]
4936 let listener = bin.join("Runner.Listener");
4937 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
4938 let mut unresolved = RunnerAttempt::allocate(
4939 AttemptId::new_random(),
4940 policy.id,
4941 &runtime,
4942 FakeClock::default().now(),
4943 );
4944 for _ in 0..3 {
4945 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
4946 }
4947 processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
4948 let failure = processes
4949 .spawn(&unresolved, &EncodedJitConfig::new(JIT))
4950 .expect_err("bounded cleanup must return even when every stop errors");
4951 let pid = failure
4952 .live_pid
4953 .expect("the owned child remains supervised in this invocation");
4954 assert!(matches!(failure.reason, FailureReason::Other(_)));
4955 assert_long_lived_listener_ready(&processes, &unresolved);
4956 unresolved.jit_received(FakeClock::default().now()).unwrap();
4957 unresolved.started(pid, FakeClock::default().now()).unwrap();
4958 let journal = SqliteStore::open_in_memory().unwrap();
4959 journal.record_attempt(&unresolved).unwrap();
4960 let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
4961 assert_eq!(recovered.process_id(), Some(pid));
4962 assert_eq!(recovered.state(), AttemptState::Starting);
4963 assert!(processes.is_alive(&unresolved).unwrap());
4964 assert!(!NativeProcesses::identity_path(&unresolved).exists());
4965 assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
4966 assert_eq!(
4967 fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
4968 pid.to_string(),
4969 "bounded cleanup must leave durable unresolved-process evidence before returning"
4970 );
4971 assert!(
4972 NativeProcesses::new().is_alive(&recovered).is_err(),
4973 "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
4974 );
4975 processes.terminate(&unresolved).unwrap();
4976
4977 let runtime = root.path().join("post-spawn-stop-failed");
4978 let bin = runtime.join("bin");
4979 fs::create_dir_all(&bin).unwrap();
4980 #[cfg(windows)]
4981 let listener = bin.join("Runner.Listener.exe");
4982 #[cfg(not(windows))]
4983 let listener = bin.join("Runner.Listener");
4984 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
4985 let attempt = RunnerAttempt::allocate(
4986 AttemptId::new_random(),
4987 policy.id,
4988 &runtime,
4989 FakeClock::default().now(),
4990 );
4991 processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
4992 processes.fail_next_post_spawn_stop();
4993 let failure = processes
4994 .spawn(&attempt, &EncodedJitConfig::new(JIT))
4995 .expect_err("the injected stop failure must preserve supervision");
4996 let live_pid = failure
4997 .live_pid
4998 .expect("live PID is returned to the journal");
4999 assert!(!failure.retryable);
5000 assert_long_lived_listener_ready(&processes, &attempt);
5001 assert!(NativeProcesses::identity_path(&attempt).is_file());
5002 assert_eq!(
5003 NativeProcesses::read_identity(&attempt)
5004 .unwrap()
5005 .unwrap()
5006 .pid(),
5007 live_pid
5008 );
5009 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
5010 processes.terminate(&attempt).unwrap();
5011 }
5012
5013 #[test]
5014 #[ignore = "spawned only as the platform-stable native listener fixture"]
5015 fn long_lived_native_listener_helper() {
5016 let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
5017 .map(PathBuf::from)
5018 .expect("the parent supplies the readiness path");
5019 fs::write(ready, b"ready\n").expect("the listener publishes readiness");
5020 std::thread::sleep(Duration::from_secs(30));
5021 }
5022
5023 fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
5024 let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
5025 let deadline = std::time::Instant::now() + Duration::from_secs(5);
5026 loop {
5027 if ready.is_file() {
5028 assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
5029 return;
5030 }
5031 assert!(
5032 processes.is_alive(attempt).unwrap(),
5033 "the native listener exited before publishing readiness"
5034 );
5035 assert!(
5036 std::time::Instant::now() < deadline,
5037 "the native listener stayed alive but never published readiness"
5038 );
5039 std::thread::sleep(Duration::from_millis(10));
5040 }
5041 }
5042
5043 #[tokio::test]
5044 async fn every_production_launch_prunes_under_the_same_allocation_guard() {
5045 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5046 harness.ready().await;
5047 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
5048 harness.launch().await;
5049 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
5050 assert_eq!(
5051 *harness.packages.prune_currents.lock().unwrap(),
5052 vec![harness.packages.version.clone()],
5053 "the leased current version is an exclusion, never the prune target"
5054 );
5055 }
5056
5057 fn assert_no_jit_file(runtime: &Path) {
5058 for entry in fs::read_dir(runtime).unwrap() {
5059 let path = entry.unwrap().path();
5060 if path.is_file() {
5061 let bytes = fs::read(&path).unwrap();
5062 assert!(
5063 !bytes
5064 .windows(JIT.len())
5065 .any(|window| window == JIT.as_bytes()),
5066 "a JIT payload survived in a runtime file"
5067 );
5068 }
5069 }
5070 }
5071
5072 #[test]
5073 fn production_listener_command_uses_the_supported_jit_contract() {
5074 let runtime = Path::new("runtime");
5075 let spec = runner_listener_spec(PathBuf::from("Runner.Listener"), runtime);
5076 let arguments: Vec<_> = spec
5077 .arguments()
5078 .iter()
5079 .map(|argument| argument.to_string_lossy().into_owned())
5080 .collect();
5081
5082 assert_eq!(arguments, ["run"]);
5083 assert!(
5084 !arguments
5085 .iter()
5086 .any(|argument| argument == "--jit-config-file"),
5087 "the obsolete file option would be rejected by Runner.Listener 2.336.0"
5088 );
5089 }
5090
5091 #[cfg(windows)]
5092 fn native_inspection_spec() -> SpawnSpec {
5093 SpawnSpec::new("powershell.exe").args([
5094 "-NoProfile",
5095 "-NonInteractive",
5096 "-Command",
5097 "Start-Sleep -Seconds 30",
5098 ])
5099 }
5100
5101 #[cfg(unix)]
5102 fn native_inspection_spec() -> SpawnSpec {
5103 SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
5104 }
5105
5106 #[cfg(windows)]
5107 fn native_command_line(pid: u32) -> String {
5108 let output = std::process::Command::new("powershell.exe")
5109 .args([
5110 "-NoProfile",
5111 "-NonInteractive",
5112 "-Command",
5113 &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
5114 ])
5115 .output()
5116 .expect("PowerShell can inspect the native child");
5117 assert!(output.status.success(), "native process inspection failed");
5118 String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
5119 }
5120
5121 #[cfg(target_os = "linux")]
5122 fn native_command_line(pid: u32) -> String {
5123 fs::read(format!("/proc/{pid}/cmdline"))
5124 .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
5125 .expect("/proc exposes the native child command line")
5126 }
5127
5128 #[cfg(target_os = "macos")]
5129 fn native_command_line(pid: u32) -> String {
5130 let output = std::process::Command::new("ps")
5131 .args(["-o", "command=", "-p", &pid.to_string()])
5132 .output()
5133 .expect("ps can inspect the native child");
5134 assert!(output.status.success(), "native process inspection failed");
5135 String::from_utf8(output.stdout).expect("the command line is UTF-8")
5136 }
5137
5138 #[tokio::test]
5141 async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
5142 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5143 .with_persistent_workspace(2);
5144 harness.github.watch_journal(Arc::clone(&harness.store));
5145 harness.ready().await;
5146
5147 let attempt = harness.launch().await;
5148
5149 assert_eq!(
5150 attempt.workspace(),
5151 AttemptWorkspace::persistent_slot(nz(1)),
5152 "the lowest free slot is leased"
5153 );
5154 assert_eq!(attempt.runtime_path(), harness.slot_path(1));
5155 assert!(attempt.holds_slot_lease());
5156 assert_eq!(
5158 harness.attempt(attempt.id).runtime_path(),
5159 harness.slot_path(1)
5160 );
5161
5162 let facts = harness.github.registration_facts();
5166 assert_eq!(facts.len(), 1);
5167 assert_eq!(
5168 facts[0].leased_slots,
5169 vec![1],
5170 "the lease was journalled first"
5171 );
5172 assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
5173 }
5174
5175 #[tokio::test]
5176 async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
5177 let harness = Harness::new(
5178 FakeGithubLifecycle::default().fail(true),
5179 Arc::new(PersistentDemand),
5180 )
5181 .with_persistent_workspace(2);
5182 harness.ready().await;
5183
5184 harness.launch_result().await.unwrap_err();
5186 let first = harness.store.attempts().unwrap().remove(0);
5187 assert_eq!(first.state(), AttemptState::Failed);
5188 assert!(
5189 !first.state().counts_against_capacity(),
5190 "a concluded attempt is invisible to host capacity"
5191 );
5192 assert!(
5193 first.holds_slot_lease(),
5194 "and still owns its directory, so its slot is not free"
5195 );
5196
5197 let second = harness.launch().await;
5198 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
5199 assert_eq!(second.runtime_path(), harness.slot_path(2));
5200 assert_eq!(
5201 harness
5202 .store
5203 .slot_leases_for_policy(harness.policy.id)
5204 .unwrap()
5205 .len(),
5206 2
5207 );
5208 }
5209
5210 #[tokio::test]
5211 async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
5212 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5213 .with_persistent_workspace(1);
5214 harness.ready().await;
5215
5216 let first = harness.launch().await;
5217 assert_eq!(first.runtime_path(), harness.slot_path(1));
5218
5219 let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
5221 fs::create_dir_all(&checkout).unwrap();
5222 fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
5223
5224 harness.cleanup_retaining_work(first.id).await;
5225
5226 let second = harness.launch().await;
5227 assert_ne!(second.id, first.id);
5228 assert_eq!(
5229 second.workspace(),
5230 AttemptWorkspace::persistent_slot(nz(1)),
5231 "a released slot is leased again rather than skipped"
5232 );
5233 assert_eq!(
5234 second.runtime_path(),
5235 first.runtime_path(),
5236 "the same slot is the same exact path"
5237 );
5238 assert_eq!(
5239 fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
5240 "from the first job",
5241 "the retained job workspace survived the second allocation"
5242 );
5243 assert!(harness.slot_path(1).join("runner-package").exists());
5245 }
5246
5247 #[tokio::test]
5248 async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
5249 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5250 .with_persistent_workspace(2);
5251 harness.ready().await;
5252
5253 let first = harness.launch().await;
5254 let second = harness.launch().await;
5255 assert_eq!(second.runtime_path(), harness.slot_path(2));
5256 let kept = harness
5257 .slot_path(2)
5258 .join(DEFAULT_WORK_FOLDER)
5259 .join("kept.txt");
5260 fs::create_dir_all(kept.parent().unwrap()).unwrap();
5261 fs::write(&kept, b"s2 was here").unwrap();
5262 harness.cleanup_retaining_work(second.id).await;
5263
5264 harness.policy.set_max_capacity(nz(1)).unwrap();
5266 let refusal = harness.launch_result().await.unwrap_err().to_string();
5267 assert!(
5268 refusal.contains("s1 to s1"),
5269 "the refusal names the ceiling it reached: {refusal}"
5270 );
5271 assert!(
5272 harness.slot_path(2).exists() && kept.exists(),
5273 "lowering capacity deletes nothing; the higher slot is merely unusable"
5274 );
5275
5276 harness.policy.set_max_capacity(nz(2)).unwrap();
5278 let third = harness.launch().await;
5279 assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
5280 assert_eq!(third.runtime_path(), harness.slot_path(2));
5281 assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
5282 assert!(first.holds_slot_lease(), "s1 was never disturbed");
5283 }
5284
5285 #[tokio::test]
5286 async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
5287 for policy in [
5288 fixtures::policy()
5289 .organization("octo")
5290 .autoscale("home", 2)
5291 .active()
5292 .build(),
5293 fixtures::policy()
5294 .repository("octo/repo")
5295 .autoscale("home", 2)
5296 .active()
5297 .build(),
5298 ] {
5299 let mut harness =
5300 Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5301 .with_host_runner_root();
5302 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
5303 harness.policy = policy;
5304 harness.ready().await;
5305
5306 let attempt = harness.launch().await;
5307 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
5308 assert_eq!(attempt.workspace().slot_number(), None);
5309 assert!(!attempt.holds_slot_lease());
5310 assert_eq!(
5311 attempt.runtime_path().parent().unwrap(),
5312 harness.host_root(),
5313 "a disposable attempt is a child of the host root, never of a slot"
5314 );
5315 assert!(
5316 harness
5317 .store
5318 .slot_leases_for_policy(harness.policy.id)
5319 .unwrap()
5320 .is_empty()
5321 );
5322 }
5323 }
5324
5325 #[tokio::test]
5326 async fn two_concurrent_allocations_never_share_a_slot() {
5327 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5328 .with_persistent_workspace(2);
5329 harness.github.watch_journal(Arc::clone(&harness.store));
5330 harness.ready().await;
5331
5332 let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
5336 let first = first.unwrap();
5337 let second = second.unwrap();
5338
5339 let slots: BTreeSet<u16> = [&first, &second]
5340 .iter()
5341 .map(|attempt| {
5342 attempt
5343 .workspace()
5344 .slot_number()
5345 .expect("a persistent attempt leases a slot")
5346 })
5347 .collect();
5348 assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
5349 assert_ne!(first.runtime_path(), second.runtime_path());
5350 assert_eq!(
5351 harness
5352 .store
5353 .slot_leases_for_policy(harness.policy.id)
5354 .unwrap()
5355 .len(),
5356 2
5357 );
5358
5359 let facts = harness.github.registration_facts();
5364 assert_eq!(facts.len(), 2);
5365 for fact in facts {
5366 let attempt = [&first, &second]
5367 .into_iter()
5368 .find(|attempt| runner_name(attempt.id) == fact.runner_name)
5369 .expect("every registration belongs to one of the two attempts");
5370 let slot = attempt
5371 .workspace()
5372 .slot_number()
5373 .expect("a persistent attempt leases a slot");
5374 assert!(
5375 fact.leased_slots.contains(&slot),
5376 "a JIT request never precedes its own lease: s{slot} not in {:?}",
5377 fact.leased_slots
5378 );
5379 assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
5380 }
5381 }
5382
5383 #[tokio::test]
5384 async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
5385 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5386 .with_persistent_workspace(2);
5387 harness.ready().await;
5388 let first = harness.launch().await;
5389
5390 let clash = RunnerAttempt::allocate_in(
5394 AttemptId::new_random(),
5395 harness.policy.id,
5396 first.runtime_path(),
5397 AttemptWorkspace::persistent_slot(nz(1)),
5398 harness.clock.now(),
5399 );
5400 assert!(matches!(
5401 harness.store.record_attempt(&clash).unwrap_err(),
5402 StoreError::SlotAlreadyLeased { slot: 1, .. }
5403 ));
5404
5405 let error = harness.launcher.record_allocation(&clash).unwrap_err();
5408 let rendered = error.to_string();
5409 assert!(rendered.contains("slot s1"), "{rendered}");
5410 assert!(rendered.contains("nothing was written"), "{rendered}");
5411 assert_eq!(
5412 harness.store.attempts().unwrap().len(),
5413 1,
5414 "the losing allocator journalled nothing"
5415 );
5416 }
5417
5418 #[test]
5419 fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
5420 let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
5421 slots
5422 .iter()
5423 .map(|slot| {
5424 RunnerAttempt::allocate_in(
5425 AttemptId::new_random(),
5426 fixtures::POLICY_ID,
5427 format!("/srv/rman/acme/s{slot}"),
5428 AttemptWorkspace::persistent_slot(nz(*slot)),
5429 fixtures::created_at(),
5430 )
5431 })
5432 .collect()
5433 };
5434
5435 assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
5436 assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
5437 assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
5439 assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
5441 assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
5442 let ephemeral = vec![RunnerAttempt::allocate(
5444 AttemptId::new_random(),
5445 fixtures::POLICY_ID,
5446 "/srv/rman/host/abc",
5447 fixtures::created_at(),
5448 )];
5449 assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
5450 }
5451
5452 #[test]
5453 fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
5454 let root = tempfile::tempdir().unwrap();
5455 let slot = root.path().join("s1");
5456 fs::create_dir(&slot).unwrap();
5457 accept_reusable_slot(&slot).expect("an empty slot is reusable");
5458
5459 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
5460 accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
5461
5462 fs::create_dir(slot.join("bin")).unwrap();
5465 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
5466 let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
5467 assert!(refusal.contains("bin"), "{refusal}");
5468 assert!(refusal.contains(".github-runner-id"), "{refusal}");
5469
5470 let file_work = root.path().join("s2");
5472 fs::create_dir(&file_work).unwrap();
5473 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
5474 assert!(accept_reusable_slot(&file_work).is_err());
5475 }
5476
5477 #[cfg(unix)]
5478 #[test]
5479 fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
5480 let root = tempfile::tempdir().unwrap();
5484 let elsewhere = root.path().join("elsewhere");
5485 fs::create_dir(&elsewhere).unwrap();
5486
5487 let slot = root.path().join("s1");
5488 fs::create_dir(&slot).unwrap();
5489 std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
5490 assert!(accept_reusable_slot(&slot).is_err());
5491
5492 let linked_slot = root.path().join("s2");
5493 std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
5494 assert!(create_or_validate_slot(&linked_slot).is_err());
5495 }
5496
5497 #[test]
5498 fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
5499 let root = tempfile::tempdir().unwrap();
5500 let occupied = root.path().join("s1");
5501 fs::write(&occupied, b"an operator's file").unwrap();
5502 let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
5503 assert!(refusal.contains("is not a directory"), "{refusal}");
5504 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
5505
5506 let fresh = root.path().join("s2");
5507 create_or_validate_slot(&fresh).expect("a missing slot is created");
5508 assert!(fresh.is_dir());
5509 create_or_validate_slot(&fresh).expect("an existing directory is accepted");
5510 }
5511
5512 #[test]
5513 fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
5514 assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
5515 assert!(!is_work_folder(OsStr::new("_work2")));
5516 assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
5520 }
5521
5522 #[test]
5523 fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
5524 let root = tempfile::tempdir().unwrap();
5525 let package = root.path().join("package");
5526 fs::create_dir_all(package.join("bin")).unwrap();
5527 fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
5528 fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
5530
5531 let slot = root.path().join("s1");
5532 let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
5533 fs::create_dir_all(&retained).unwrap();
5534 fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
5535
5536 copy_package_tree(&package, &slot).expect("the package lays out around `_work`");
5537 assert!(slot.join("bin").join("Runner.Listener").exists());
5538 assert!(
5539 slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
5540 "the guard is top-level only"
5541 );
5542 assert_eq!(
5543 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
5544 "from the first job"
5545 );
5546
5547 fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
5549 let error = copy_package_tree(&package, &slot).unwrap_err();
5550 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
5551 assert_eq!(
5552 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
5553 "from the first job"
5554 );
5555 }
5556
5557 #[test]
5558 fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
5559 let root = tempfile::tempdir().unwrap();
5560
5561 let slot = root.path().join("s1");
5562 let retained = slot.join(DEFAULT_WORK_FOLDER);
5563 fs::create_dir_all(retained.join("repo")).unwrap();
5564 fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
5565 fs::create_dir_all(slot.join("bin")).unwrap();
5566 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
5567 let persistent = RunnerAttempt::allocate_in(
5568 AttemptId::new_random(),
5569 fixtures::POLICY_ID,
5570 &slot,
5571 AttemptWorkspace::persistent_slot(nz(1)),
5572 fixtures::created_at(),
5573 );
5574
5575 remove_materialized_package(&persistent).unwrap();
5576 assert!(slot.is_dir(), "the slot itself is not removed");
5577 assert!(!slot.join("bin").exists());
5578 assert!(!slot.join(".github-runner-id").exists());
5579 assert_eq!(
5580 fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
5581 "kept"
5582 );
5583
5584 let disposable_path = root.path().join("abcdef012345");
5585 fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
5586 let disposable = RunnerAttempt::allocate(
5587 AttemptId::new_random(),
5588 fixtures::POLICY_ID,
5589 &disposable_path,
5590 fixtures::created_at(),
5591 );
5592 remove_materialized_package(&disposable).unwrap();
5593 assert!(
5594 !disposable_path.exists(),
5595 "a disposable directory is still removed whole"
5596 );
5597 }
5598
5599 fn litter_the_slot(slot: &Path) {
5609 for directory in ["bin", "externals", "_diag"] {
5610 fs::create_dir_all(slot.join(directory)).unwrap();
5611 }
5612 fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
5613 for file in SENSITIVE_SLOT_ENTRIES
5614 .iter()
5615 .filter(|entry| !slot.join(entry).is_dir())
5616 {
5617 fs::write(slot.join(file), b"runner state").unwrap();
5618 }
5619 fs::write(
5621 slot.join(format!(
5622 "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
5623 RestrictiveHandoff::NAME_PREFIX
5624 )),
5625 JIT.as_bytes(),
5626 )
5627 .unwrap();
5628 }
5629
5630 fn retain_under_work(slot: &Path) -> PathBuf {
5632 let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
5633 fs::create_dir_all(&checkout).unwrap();
5634 let marker = checkout.join("build-output.bin");
5635 fs::write(&marker, RETAINED).unwrap();
5636 marker
5637 }
5638
5639 const RETAINED: &str = "a Git-ignored build output the next job reuses";
5641
5642 fn entries_of(directory: &Path) -> Vec<String> {
5644 let mut names: Vec<String> = fs::read_dir(directory)
5645 .unwrap()
5646 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
5647 .collect();
5648 names.sort();
5649 names
5650 }
5651
5652 fn only_the_job_workspace() -> Vec<String> {
5654 vec![DEFAULT_WORK_FOLDER.to_owned()]
5655 }
5656
5657 struct BlockedDeletion {
5673 directory: PathBuf,
5674 #[cfg(windows)]
5675 _handle: fs::File,
5676 }
5677
5678 impl BlockedDeletion {
5679 const HELD: &'static str = "held-open";
5680
5681 fn inject(directory: &Path) -> Option<Self> {
5684 #[cfg(unix)]
5685 if !Self::refusal_is_possible() {
5686 return None;
5687 }
5688 fs::create_dir_all(directory).unwrap();
5689 fs::write(
5690 directory.join(Self::HELD),
5691 b"a file the scrub cannot remove",
5692 )
5693 .unwrap();
5694 #[cfg(windows)]
5695 let handle = {
5696 use std::os::windows::fs::OpenOptionsExt;
5697
5698 fs::OpenOptions::new()
5699 .read(true)
5700 .share_mode(0)
5701 .open(directory.join(Self::HELD))
5702 .expect("the blocking handle opens")
5703 };
5704 #[cfg(unix)]
5705 Self::set_mode(directory, 0o555);
5706 Some(Self {
5707 directory: directory.to_path_buf(),
5708 #[cfg(windows)]
5709 _handle: handle,
5710 })
5711 }
5712
5713 fn release(self) {
5714 drop(self);
5715 }
5716
5717 #[cfg(unix)]
5718 fn refusal_is_possible() -> bool {
5719 let probe = tempfile::tempdir().unwrap();
5720 let directory = probe.path().join("probe");
5721 fs::create_dir(&directory).unwrap();
5722 fs::write(directory.join("file"), b"probe").unwrap();
5723 Self::set_mode(&directory, 0o555);
5724 let refused = fs::remove_dir_all(&directory).is_err();
5725 Self::set_mode(&directory, 0o755);
5726 refused
5727 }
5728
5729 #[cfg(unix)]
5730 fn set_mode(directory: &Path, mode: u32) {
5731 use std::os::unix::fs::PermissionsExt;
5732
5733 let mut permissions = fs::metadata(directory).unwrap().permissions();
5734 permissions.set_mode(mode);
5735 fs::set_permissions(directory, permissions).unwrap();
5736 }
5737 }
5738
5739 impl Drop for BlockedDeletion {
5740 fn drop(&mut self) {
5741 #[cfg(unix)]
5742 Self::set_mode(&self.directory, 0o755);
5743 #[cfg(not(unix))]
5744 let _ = &self.directory;
5745 }
5746 }
5747
5748 #[tokio::test]
5749 async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
5750 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5751 .with_persistent_workspace(1);
5752 harness.ready().await;
5753
5754 let first = harness.launch().await;
5755 let slot = harness.slot_path(1);
5756 assert_eq!(first.runtime_path(), slot);
5757 assert_eq!(
5758 read_runner_id(&slot),
5759 Some(73),
5760 "the attempt registered, so its identity is on disk"
5761 );
5762 let marker = retain_under_work(&slot);
5763 litter_the_slot(&slot);
5764
5765 harness.cleanup_retaining_work(first.id).await;
5766
5767 assert_eq!(entries_of(&slot), only_the_job_workspace());
5770 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
5771 assert_eq!(
5772 read_runner_id(&slot),
5773 None,
5774 "the first attempt's registration identity is gone before the second starts"
5775 );
5776 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
5777 assert!(!harness.attempt(first.id).holds_slot_lease());
5778
5779 let second = harness.launch().await;
5780 assert_ne!(second.id, first.id);
5781 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
5782 assert_eq!(
5783 second.runtime_path(),
5784 slot,
5785 "the same slot, so the same retained `_work`"
5786 );
5787 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
5788 }
5789
5790 #[tokio::test]
5791 async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
5792 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5793 .with_persistent_workspace(1);
5794 harness.ready().await;
5795 let attempt = harness.launch().await;
5796 let slot = harness.slot_path(1);
5797 let marker = retain_under_work(&slot);
5798 litter_the_slot(&slot);
5799 harness.conclude(attempt.id);
5800
5801 harness
5807 .store
5808 .remove_policy(harness.policy.id, harness.policy.revision())
5809 .unwrap();
5810 assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
5811
5812 harness
5813 .launcher
5814 .clean(attempt.id)
5815 .await
5816 .expect("journal facts alone are enough to clean the slot");
5817
5818 assert_eq!(entries_of(&slot), only_the_job_workspace());
5819 assert!(marker.exists());
5820 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
5821 }
5822
5823 #[tokio::test]
5824 async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
5825 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5826 .with_persistent_workspace(2);
5827 harness.ready().await;
5828 let first = harness.launch().await;
5829 let slot = harness.slot_path(1);
5830 let marker = retain_under_work(&slot);
5831 litter_the_slot(&slot);
5832 harness.conclude(first.id);
5833
5834 let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
5835 eprintln!(
5836 "skipped: this account cannot be refused a deletion, so no partial deletion can \
5837 be injected"
5838 );
5839 return;
5840 };
5841
5842 let refusal = harness
5843 .launcher
5844 .clean(first.id)
5845 .await
5846 .expect_err("a deletion that failed may not report a cleaned slot");
5847 let rendered = refusal.reason.to_string();
5848 assert!(rendered.contains("could not be removed"), "{rendered}");
5849
5850 let held = harness.attempt(first.id);
5851 assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
5852 assert!(held.holds_slot_lease(), "so the slot is still leased");
5853 assert!(
5854 !held.state().counts_against_capacity(),
5855 "and a concluded attempt still costs the host no capacity"
5856 );
5857
5858 let restarted = harness.restart();
5863 restarted
5864 .recover_startup(std::slice::from_ref(&harness.policy))
5865 .await
5866 .expect("one quarantined slot does not stop the host recovering");
5867 assert_eq!(
5868 harness.attempt(first.id).state(),
5869 AttemptState::Failed,
5870 "the quarantine survived the restart"
5871 );
5872 assert!(
5873 harness
5874 .reconcile_events
5875 .events()
5876 .iter()
5877 .any(|event| matches!(
5878 event,
5879 LifecycleEvent::AttemptCleanFailed {
5880 reason: "slot_entry_could_not_be_removed",
5881 ..
5882 }
5883 )),
5884 "the refusal is reported rather than retried in silence"
5885 );
5886
5887 let guard = harness.allocation_lock.acquire().await.unwrap();
5890 let second = restarted
5891 .launch(LaunchRequest {
5892 host: &harness.host,
5893 policy: &harness.policy,
5894 allocation_guard: &guard,
5895 })
5896 .await
5897 .expect("the host can still launch");
5898 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
5899 drop(guard);
5900
5901 block.release();
5904 restarted
5905 .clean(first.id)
5906 .await
5907 .expect("the retried cleanup completes");
5908 assert_eq!(entries_of(&slot), only_the_job_workspace());
5909 assert!(marker.exists());
5910 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
5911 }
5912
5913 #[tokio::test]
5924 async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
5925 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5926 harness.ready().await;
5927 let attempt = harness.launch().await;
5928 let runtime = attempt.runtime_path().to_path_buf();
5929 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
5930 harness.conclude(attempt.id);
5931
5932 let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
5933 eprintln!(
5934 "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
5935 );
5936 return;
5937 };
5938
5939 let refusal = harness
5940 .launcher
5941 .clean(attempt.id)
5942 .await
5943 .expect_err("a deletion that failed may not report a removed workspace");
5944 let rendered = refusal.reason.to_string();
5945 assert!(
5946 rendered.contains("could not be removed"),
5947 "the refusal names what happened: {rendered}"
5948 );
5949 assert_ne!(
5950 harness.attempt(attempt.id).state(),
5951 AttemptState::Cleaned,
5952 "an attempt whose directory is still on disk is not cleaned"
5953 );
5954 assert!(
5955 runtime.is_dir(),
5956 "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
5957 );
5958
5959 block.release();
5962 harness
5963 .launcher
5964 .clean(attempt.id)
5965 .await
5966 .expect("the retried cleanup completes");
5967 assert!(!runtime.exists(), "the whole attempt directory goes");
5968 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
5969 }
5970
5971 #[tokio::test]
5972 async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
5973 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5974 .with_persistent_workspace(1);
5975 harness.ready().await;
5976 let first = harness.launch().await;
5977 let slot = harness.slot_path(1);
5978 let marker = retain_under_work(&slot);
5979 harness.cleanup_retaining_work(first.id).await;
5980
5981 harness
5985 .policy
5986 .set_workspace_policy(WorkspacePolicy::Ephemeral)
5987 .unwrap();
5988
5989 let second = harness.launch().await;
5990 assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
5991 assert_eq!(
5992 second.runtime_path().parent().unwrap(),
5993 harness.host_root(),
5994 "a disposable attempt is a child of the host root"
5995 );
5996 assert!(slot.is_dir(), "the old slot is left where it stands");
5997 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
5998
5999 harness.conclude(second.id);
6002 harness.launcher.clean(second.id).await.unwrap();
6003 assert!(!second.runtime_path().exists());
6004 assert!(marker.exists());
6005 }
6006
6007 #[tokio::test]
6008 async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
6009 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6010 .with_persistent_workspace(1);
6011 harness.ready().await;
6012 let slot = harness.slot_path(1);
6013 fs::create_dir_all(&slot).unwrap();
6014 let marker = retain_under_work(&slot);
6015 litter_the_slot(&slot);
6016
6017 let id = AttemptId::new_random();
6018 let mut attempt = RunnerAttempt::allocate_in(
6019 id,
6020 harness.policy.id,
6021 &slot,
6022 AttemptWorkspace::persistent_slot(nz(1)),
6023 harness.clock.now(),
6024 );
6025 attempt.jit_received(harness.clock.now()).unwrap();
6026 attempt.started(4242, harness.clock.now()).unwrap();
6027 harness.store.record_attempt(&attempt).unwrap();
6028 harness.clock.advance_secs(11);
6029 harness.processes.set_alive(true);
6030 harness
6031 .github
6032 .observe(GithubRunnerObservation::NotRegistered);
6033
6034 harness.launcher.supervise(&harness.policy).await.unwrap();
6035
6036 let actions = harness.processes.actions.lock().unwrap().clone();
6040 let intent = actions
6041 .iter()
6042 .position(|action| *action == "terminate_intent")
6043 .unwrap();
6044 let signal = actions
6045 .iter()
6046 .position(|action| *action == "terminate")
6047 .unwrap();
6048 assert!(intent < signal, "{actions:?}");
6049 assert!(!harness.processes.alive.load(Ordering::SeqCst));
6050
6051 let cleaned = harness.attempt(id);
6052 assert_eq!(cleaned.state(), AttemptState::Cleaned);
6053 assert!(matches!(
6054 cleaned.outcome(),
6055 Some(AttemptOutcome::Failed {
6056 reason: FailureReason::TerminatedAfterRegistrationTimeout
6057 })
6058 ));
6059 assert_eq!(entries_of(&slot), only_the_job_workspace());
6060 assert!(marker.exists());
6061 }
6062
6063 #[test]
6064 fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
6065 let root = tempfile::tempdir().unwrap();
6066 let slot = root.path().join("s1");
6067 fs::create_dir(&slot).unwrap();
6068 let marker = retain_under_work(&slot);
6069 litter_the_slot(&slot);
6070 fs::write(slot.join("runner-package"), b"verified").unwrap();
6071
6072 scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
6073 verify_slot_scrubbed(&slot).expect("and proves it afterwards");
6074
6075 assert_eq!(entries_of(&slot), only_the_job_workspace());
6076 assert!(marker.exists());
6077 }
6078
6079 #[test]
6080 fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
6081 let slot = Path::new("/runners/s1");
6082
6083 let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
6086 assert!(counted.contains("2 entries other than"), "{counted}");
6087 assert!(counted.contains("including `bin`"), "{counted}");
6088 assert_eq!(
6089 residue_detail(slot, 1, &[]),
6090 format!(
6091 "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
6092 slot.display()
6093 )
6094 );
6095
6096 let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
6101 assert!(!raced.contains('0'), "{raced}");
6102 assert!(raced.contains("reported nothing but"), "{raced}");
6103 assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
6104 }
6105
6106 #[test]
6107 fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
6108 let root = tempfile::tempdir().unwrap();
6109 let slot = root.path().join("s1");
6110 fs::create_dir(&slot).unwrap();
6111 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6112 verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
6113
6114 for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
6118 fs::write(slot.join(survivor), b"left behind").unwrap();
6119 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
6120 assert_eq!(quarantine.refusal, SlotRefusal::Residue);
6121 assert!(
6122 quarantine.detail.contains(&format!("`{survivor}`")),
6123 "{quarantine}"
6124 );
6125 fs::remove_file(slot.join(survivor)).unwrap();
6126 }
6127
6128 let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
6131 fs::write(&handoff, JIT.as_bytes()).unwrap();
6132 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
6133 assert!(
6134 quarantine.detail.contains("an encoded JIT handoff"),
6135 "{quarantine}"
6136 );
6137 assert!(!quarantine.detail.contains(JIT), "{quarantine}");
6138 fs::remove_file(&handoff).unwrap();
6139
6140 fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
6144 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
6145 assert!(
6146 quarantine.detail.contains("1 entry other than"),
6147 "{quarantine}"
6148 );
6149 assert!(
6150 !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
6151 "{quarantine}"
6152 );
6153 }
6154
6155 #[test]
6156 fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
6157 let root = tempfile::tempdir().unwrap();
6158 let configured =
6159 LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
6160 let slot = configured.as_path().join("s1");
6161 fs::create_dir(&slot).unwrap();
6162
6163 verify_journalled_slot(&slot, nz(1), Some(&configured))
6164 .expect("the journalled slot agrees");
6165 verify_journalled_slot(&slot, nz(1), None)
6166 .expect("and a policy that is gone removes a check, not the ability to clean");
6167
6168 assert_eq!(
6171 verify_journalled_slot(&slot, nz(2), None)
6172 .unwrap_err()
6173 .refusal,
6174 SlotRefusal::NotTheJournalledSlot
6175 );
6176 for stray in ["s1/nested", "not-a-slot", "s01"] {
6177 let path = configured.as_path().join(stray);
6178 assert_eq!(
6179 verify_journalled_slot(&path, nz(1), None)
6180 .unwrap_err()
6181 .refusal,
6182 SlotRefusal::NotTheJournalledSlot,
6183 "{}",
6184 path.display()
6185 );
6186 }
6187
6188 let elsewhere = tempfile::tempdir().unwrap();
6191 let other =
6192 LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
6193 assert_eq!(
6194 verify_journalled_slot(&slot, nz(1), Some(&other))
6195 .unwrap_err()
6196 .refusal,
6197 SlotRefusal::PolicyRootDisagrees
6198 );
6199 }
6200
6201 #[cfg(unix)]
6202 #[test]
6203 fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
6204 let root = tempfile::tempdir().unwrap();
6208 let outside = root.path().join("operator-data");
6209 fs::create_dir(&outside).unwrap();
6210 let sentinel = outside.join("do-not-delete.txt");
6211 fs::write(
6212 &sentinel,
6213 b"an operator's data, outside every approved root",
6214 )
6215 .unwrap();
6216
6217 let slot = root.path().join("s1");
6218 fs::create_dir(&slot).unwrap();
6219 fs::create_dir(slot.join("bin")).unwrap();
6220 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6221
6222 let quarantine = scrub_slot_entries(&slot).unwrap_err();
6223 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
6224 assert!(
6225 sentinel.exists(),
6226 "the deletion followed the link out of the slot"
6227 );
6228 assert!(outside.is_dir());
6229 assert!(
6230 slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
6231 "the substituted link is left for the operator, never unlinked as if it were ours"
6232 );
6233
6234 let file_work = root.path().join("s2");
6238 fs::create_dir(&file_work).unwrap();
6239 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
6240 assert_eq!(
6241 scrub_slot_entries(&file_work).unwrap_err().refusal,
6242 SlotRefusal::WorkNotADirectory
6243 );
6244 }
6245
6246 #[cfg(unix)]
6247 #[test]
6248 fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
6249 let root = tempfile::tempdir().unwrap();
6250 let outside = root.path().join("operator-data");
6251 fs::create_dir(&outside).unwrap();
6252 let sentinel = outside.join("do-not-delete.txt");
6253 fs::write(
6254 &sentinel,
6255 b"an operator's data, outside every approved root",
6256 )
6257 .unwrap();
6258
6259 let inside = root.path().join("inside");
6262 fs::create_dir(&inside).unwrap();
6263 let slot = inside.join("s1");
6264 std::os::unix::fs::symlink(&outside, &slot).unwrap();
6265
6266 assert_eq!(
6267 verify_journalled_slot(&slot, nz(1), None)
6268 .unwrap_err()
6269 .refusal,
6270 SlotRefusal::Containment
6271 );
6272 assert!(sentinel.exists());
6273 assert!(
6274 slot.symlink_metadata().is_ok(),
6275 "the link is left for the operator rather than removed as if it were ours"
6276 );
6277 }
6278
6279 #[cfg(windows)]
6289 #[test]
6290 fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
6291 let root = tempfile::tempdir().unwrap();
6292 let outside = root.path().join("operator-data");
6293 fs::create_dir(&outside).unwrap();
6294 let sentinel = outside.join("do-not-delete.txt");
6295 fs::write(
6296 &sentinel,
6297 b"an operator's data, outside every approved root",
6298 )
6299 .unwrap();
6300
6301 let inside = root.path().join("inside");
6304 fs::create_dir(&inside).unwrap();
6305 let slot = inside.join("s1");
6306 let Some(()) = plant_junction(&slot, &outside) else {
6307 eprintln!("skipped: this machine would not create a directory junction");
6308 return;
6309 };
6310
6311 assert_eq!(
6312 verify_journalled_slot(&slot, nz(1), None)
6313 .unwrap_err()
6314 .refusal,
6315 SlotRefusal::Containment
6316 );
6317 assert!(
6318 sentinel.exists(),
6319 "the refusal resolved the junction and reached the operator's data"
6320 );
6321 assert!(
6322 slot.symlink_metadata().is_ok(),
6323 "the junction is left for the operator rather than removed as if it were ours"
6324 );
6325 }
6326
6327 #[cfg(windows)]
6336 fn plant_junction(link: &Path, target: &Path) -> Option<()> {
6337 let made = std::process::Command::new("cmd")
6338 .arg("/C")
6339 .arg("mklink")
6340 .arg("/J")
6341 .arg(link)
6342 .arg(target)
6343 .output()
6344 .ok()?;
6345 (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
6346 }
6347
6348 #[cfg(windows)]
6349 #[test]
6350 fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
6351 let root = tempfile::tempdir().unwrap();
6352 let outside = root.path().join("operator-data");
6353 fs::create_dir(&outside).unwrap();
6354 let sentinel = outside.join("do-not-delete.txt");
6355 fs::write(
6356 &sentinel,
6357 b"an operator's data, outside every approved root",
6358 )
6359 .unwrap();
6360
6361 let slot = root.path().join("s1");
6362 fs::create_dir(&slot).unwrap();
6363 fs::create_dir(slot.join("bin")).unwrap();
6364 let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
6365 eprintln!("skipped: this machine would not create a directory junction");
6366 return;
6367 };
6368
6369 let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6373 assert!(is_link_like(&work), "a junction is a reparse point");
6374 let quarantine = scrub_slot_entries(&slot).unwrap_err();
6375 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
6376 assert!(
6377 sentinel.exists(),
6378 "the deletion followed the junction out of the slot"
6379 );
6380 assert!(outside.is_dir());
6381
6382 let elsewhere = root.path().join("s2");
6385 fs::create_dir(&elsewhere).unwrap();
6386 fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
6387 if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
6388 scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
6389 verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
6390 assert!(sentinel.exists(), "the junction was followed, not unlinked");
6391 assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
6392 }
6393 }
6394
6395 #[cfg(unix)]
6396 #[tokio::test]
6397 async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
6398 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6399 .with_persistent_workspace(2);
6400 harness.ready().await;
6401 let first = harness.launch().await;
6402 let slot = harness.slot_path(1);
6403 harness.conclude(first.id);
6404
6405 let outside = harness._root.path().join("operator-data");
6406 fs::create_dir_all(&outside).unwrap();
6407 let sentinel = outside.join("do-not-delete.txt");
6408 fs::write(&sentinel, b"outside every approved root").unwrap();
6409 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6410
6411 harness
6412 .launcher
6413 .clean(first.id)
6414 .await
6415 .expect_err("a slot whose `_work` was substituted is quarantined");
6416 assert!(sentinel.exists());
6417
6418 let held = harness.attempt(first.id);
6419 assert_eq!(held.state(), AttemptState::Failed);
6420 assert!(held.holds_slot_lease());
6421
6422 let second = harness.launch().await;
6424 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
6425 }
6426
6427 #[test]
6428 fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
6429 let root = tempfile::tempdir().unwrap();
6430 let occupied = root.path().join("s1");
6431 fs::write(&occupied, b"an operator's file").unwrap();
6432 verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
6435 assert_eq!(
6436 slot_is_present(&occupied).unwrap_err().refusal,
6437 SlotRefusal::SlotNotADirectory
6438 );
6439 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
6440
6441 assert!(!slot_is_present(&root.path().join("s2")).unwrap());
6444 let present = root.path().join("s3");
6445 fs::create_dir(&present).unwrap();
6446 assert!(slot_is_present(&present).unwrap());
6447 }
6448
6449 #[test]
6450 fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
6451 let root = tempfile::tempdir().unwrap();
6452
6453 let disposable = root.path().join("abcdef012345");
6457 fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
6458 let ephemeral = RunnerAttempt::allocate(
6459 AttemptId::new_random(),
6460 fixtures::POLICY_ID,
6461 &disposable,
6462 fixtures::created_at(),
6463 );
6464 remove_materialized_package(&ephemeral).unwrap();
6465 assert!(!disposable.exists());
6466
6467 let slot = root.path().join("s1");
6469 fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
6470 fs::create_dir_all(slot.join("bin")).unwrap();
6471 let persistent = RunnerAttempt::allocate_in(
6472 AttemptId::new_random(),
6473 fixtures::POLICY_ID,
6474 &slot,
6475 AttemptWorkspace::persistent_slot(nz(1)),
6476 fixtures::created_at(),
6477 );
6478 remove_materialized_package(&persistent).unwrap();
6479 assert_eq!(entries_of(&slot), only_the_job_workspace());
6480 assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
6481 }
6482
6483 #[test]
6484 fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
6485 let refusals = [
6486 SlotRefusal::NotTheJournalledSlot,
6487 SlotRefusal::PolicyRootDisagrees,
6488 SlotRefusal::Containment,
6489 SlotRefusal::SlotNotADirectory,
6490 SlotRefusal::Enumeration,
6491 SlotRefusal::WorkNotADirectory,
6492 SlotRefusal::Deletion,
6493 SlotRefusal::Residue,
6494 ];
6495 let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
6496 assert_eq!(
6497 classes.len(),
6498 refusals.len(),
6499 "an event class shared by two refusals tells an operator less than it appears to"
6500 );
6501 for refusal in refusals {
6502 assert!(
6505 refusal
6506 .class()
6507 .chars()
6508 .all(|c| c.is_ascii_lowercase() || c == '_'),
6509 "{}",
6510 refusal.class()
6511 );
6512 assert!(
6513 refusal.remediation().contains("slot lease"),
6514 "every refusal has to say the lease is still held: {}",
6515 refusal.class()
6516 );
6517 }
6518 }
6519
6520 #[test]
6521 fn copy_package_tree_copies_files_and_preserves_paths_with_spaces() {
6522 let root = tempfile::tempdir().unwrap();
6523 let source = root.path().join("source with spaces");
6524 let dest = root.path().join("dest with spaces");
6525
6526 fs::create_dir_all(&source).unwrap();
6527 fs::write(source.join("file1.txt"), b"hello").unwrap();
6528
6529 let nested = source.join("nested dir");
6530 fs::create_dir_all(&nested).unwrap();
6531 fs::write(nested.join("file2.txt"), b"world").unwrap();
6532
6533 let nested_work = nested.join(DEFAULT_WORK_FOLDER);
6535 fs::create_dir_all(&nested_work).unwrap();
6536 fs::write(nested_work.join("allowed.txt"), b"allowed").unwrap();
6537
6538 copy_package_tree(&source, &dest).unwrap();
6539
6540 assert_eq!(fs::read_to_string(dest.join("file1.txt")).unwrap(), "hello");
6541 assert_eq!(
6542 fs::read_to_string(dest.join("nested dir").join("file2.txt")).unwrap(),
6543 "world"
6544 );
6545 assert_eq!(
6546 fs::read_to_string(
6547 dest.join("nested dir")
6548 .join(DEFAULT_WORK_FOLDER)
6549 .join("allowed.txt")
6550 )
6551 .unwrap(),
6552 "allowed"
6553 );
6554 }
6555
6556 #[test]
6557 fn copy_package_tree_refuses_top_level_work_folder() {
6558 let root = tempfile::tempdir().unwrap();
6559 let source = root.path().join("source");
6560 let dest = root.path().join("dest");
6561
6562 fs::create_dir_all(&source).unwrap();
6563 fs::write(source.join("file1.txt"), b"hello").unwrap();
6564
6565 let top_work = source.join(DEFAULT_WORK_FOLDER);
6567 fs::create_dir_all(&top_work).unwrap();
6568
6569 let err = copy_package_tree(&source, &dest).unwrap_err();
6570 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
6571 }
6572}