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 => fs::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 fs::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 copy_package_entries(source, destination, true)
1143}
1144
1145fn copy_package_entries(source: &Path, destination: &Path, top_level: bool) -> std::io::Result<()> {
1146 fs::create_dir_all(destination)?;
1147 for entry in fs::read_dir(source)? {
1148 let entry = entry?;
1149 if top_level && is_work_folder(&entry.file_name()) {
1150 return Err(std::io::Error::new(
1151 std::io::ErrorKind::InvalidData,
1152 format!(
1153 "the runner package holds a top-level `{DEFAULT_WORK_FOLDER}`; copying \
1154 it would overwrite the job workspace a persistent slot retains"
1155 ),
1156 ));
1157 }
1158 let target = destination.join(entry.file_name());
1159 if entry.file_type()?.is_dir() {
1160 copy_package_entries(&entry.path(), &target, false)?;
1161 } else {
1162 fs::copy(entry.path(), target)?;
1163 }
1164 }
1165 Ok(())
1166}
1167
1168#[derive(Debug, Clone, PartialEq, Eq)]
1171pub struct ProcessStartFailure {
1172 pub reason: FailureReason,
1173 pub retryable: bool,
1176 pub live_pid: Option<u32>,
1179}
1180
1181impl ProcessStartFailure {
1182 fn before_spawn(reason: FailureReason) -> Self {
1183 Self {
1184 reason,
1185 retryable: true,
1186 live_pid: None,
1187 }
1188 }
1189
1190 fn after_spawn_stopped() -> Self {
1191 Self {
1192 reason: FailureReason::ProcessStartFailed,
1193 retryable: false,
1194 live_pid: None,
1195 }
1196 }
1197
1198 fn after_spawn_live(pid: u32) -> Self {
1199 Self::after_spawn_live_with_reason(pid, FailureReason::ProcessStartFailed)
1200 }
1201
1202 fn after_spawn_live_with_reason(pid: u32, reason: FailureReason) -> Self {
1203 Self {
1204 reason,
1205 retryable: false,
1206 live_pid: Some(pid),
1207 }
1208 }
1209}
1210
1211pub trait ProcessSupervisor: fmt::Debug + Send + Sync {
1212 fn spawn(
1213 &self,
1214 attempt: &RunnerAttempt,
1215 config: &EncodedJitConfig,
1216 ) -> Result<u32, ProcessStartFailure>;
1217 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason>;
1218 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason>;
1221 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool;
1224 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
1225 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool;
1226 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
1227}
1228
1229#[derive(Debug, Default)]
1232pub struct NativeProcesses {
1233 children: Mutex<BTreeMap<AttemptId, ChildProcess>>,
1234 successful_exits: Mutex<BTreeMap<AttemptId, bool>>,
1235 #[cfg(test)]
1236 post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
1237 #[cfg(test)]
1238 post_spawn_reaps: std::sync::atomic::AtomicUsize,
1239 #[cfg(test)]
1240 post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
1241 #[cfg(test)]
1242 use_long_lived_test_listener: std::sync::atomic::AtomicBool,
1243}
1244
1245#[cfg(test)]
1246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1247enum PostSpawnBoundary {
1248 HandoffDelete,
1249 IdentitySerialize,
1250 IdentityWrite,
1251 ChildMapInsert,
1252}
1253
1254impl NativeProcesses {
1255 #[must_use]
1256 pub fn new() -> Self {
1257 Self::default()
1258 }
1259
1260 #[cfg(test)]
1261 fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
1262 self.post_spawn_faults.lock().unwrap().push_back(boundary);
1263 }
1264
1265 #[cfg(test)]
1266 fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
1267 let mut faults = self.post_spawn_faults.lock().unwrap();
1268 if faults.front() == Some(&boundary) {
1269 faults.pop_front();
1270 true
1271 } else {
1272 false
1273 }
1274 }
1275
1276 #[cfg(test)]
1277 fn fail_post_spawn_stops(&self, count: usize) {
1278 self.post_spawn_stop_failures
1279 .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
1280 }
1281
1282 #[cfg(test)]
1283 fn fail_next_post_spawn_stop(&self) {
1284 self.fail_post_spawn_stops(1);
1285 }
1286
1287 #[cfg(test)]
1288 fn use_long_lived_test_listener(&self) {
1289 self.use_long_lived_test_listener
1290 .store(true, std::sync::atomic::Ordering::SeqCst);
1291 }
1292
1293 fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
1294 #[cfg(test)]
1295 if self
1296 .post_spawn_stop_failures
1297 .fetch_update(
1298 std::sync::atomic::Ordering::SeqCst,
1299 std::sync::atomic::Ordering::SeqCst,
1300 |left| if left > 0 { Some(left - 1) } else { None },
1301 )
1302 .is_ok()
1303 {
1304 return Err(FailureReason::Other("injected runner stop failure".into()));
1305 }
1306 child
1307 .stop(Duration::from_secs(1))
1308 .map(|_| ())
1309 .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
1310 }
1311
1312 fn abort_spawned_child(
1313 &self,
1314 mut child: ChildProcess,
1315 attempt: &RunnerAttempt,
1316 remove_identity: bool,
1317 ) -> ProcessStartFailure {
1318 let mut reaped = self.stop_spawned_child(&mut child).is_ok();
1319 if reaped {
1320 if remove_identity {
1321 Self::remove_identity_files(attempt);
1322 }
1323 } else {
1324 let identity_durable =
1327 serde_json::to_vec(child.identity())
1328 .ok()
1329 .is_some_and(|identity| {
1330 self.persist_identity(attempt, &identity).is_ok()
1331 || self.persist_fallback_identity(attempt, &identity).is_ok()
1332 });
1333 if !identity_durable {
1334 for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
1339 if self.stop_spawned_child(&mut child).is_ok() {
1340 reaped = true;
1341 break;
1342 }
1343 }
1344 if !reaped {
1345 let pid = child.pid();
1350 let marker = write_durable_file(
1351 &Self::unresolved_process_path(attempt),
1352 pid.to_string().as_bytes(),
1353 );
1354 self.children
1355 .lock()
1356 .unwrap_or_else(std::sync::PoisonError::into_inner)
1357 .insert(attempt.id, child);
1358 let reason = if marker.is_ok() {
1359 FailureReason::Other(
1360 "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
1361 .into(),
1362 )
1363 } else {
1364 FailureReason::Other(
1365 "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
1366 .into(),
1367 )
1368 };
1369 return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
1370 }
1371 } else {
1372 let pid = child.pid();
1373 self.children
1374 .lock()
1375 .unwrap_or_else(std::sync::PoisonError::into_inner)
1376 .insert(attempt.id, child);
1377 return ProcessStartFailure::after_spawn_live(pid);
1378 }
1379 }
1380 #[cfg(test)]
1381 if reaped {
1382 self.post_spawn_reaps
1383 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1384 }
1385 #[cfg(not(test))]
1386 let _ = reaped;
1387 ProcessStartFailure::after_spawn_stopped()
1388 }
1389
1390 fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
1391 attempt.runtime_path().join(IDENTITY_FILE)
1392 }
1393
1394 fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
1395 attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
1396 }
1397
1398 fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
1399 attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
1400 }
1401
1402 fn remove_identity_files(attempt: &RunnerAttempt) {
1403 let _ = fs::remove_file(Self::identity_path(attempt));
1404 let _ = fs::remove_file(Self::fallback_identity_path(attempt));
1405 let _ = fs::remove_file(Self::unresolved_process_path(attempt));
1406 }
1407
1408 fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
1409 self.persist_identity_at(&Self::identity_path(attempt), bytes)
1410 }
1411
1412 fn persist_fallback_identity(
1413 &self,
1414 attempt: &RunnerAttempt,
1415 bytes: &[u8],
1416 ) -> std::io::Result<()> {
1417 self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
1418 }
1419
1420 fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1421 #[cfg(test)]
1422 if self.faults_at(PostSpawnBoundary::IdentityWrite) {
1423 return Err(std::io::Error::other("injected identity write failure"));
1424 }
1425 write_durable_file(path, bytes)
1426 }
1427
1428 fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
1429 attempt.runtime_path().join(TERMINATE_INTENT_FILE)
1430 }
1431
1432 fn read_identity(attempt: &RunnerAttempt) -> Result<Option<ProcessIdentity>, FailureReason> {
1433 match Self::read_identity_at(&Self::identity_path(attempt))? {
1434 Some(identity) => Ok(Some(identity)),
1435 None => Self::read_identity_at(&Self::fallback_identity_path(attempt)),
1436 }
1437 }
1438
1439 fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
1440 match fs::read(path) {
1441 Ok(bytes) => serde_json::from_slice(&bytes)
1442 .map(Some)
1443 .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
1444 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1445 Err(_) => Err(FailureReason::Other(
1446 "process identity journal could not be read".into(),
1447 )),
1448 }
1449 }
1450}
1451
1452impl ProcessSupervisor for NativeProcesses {
1453 fn spawn(
1454 &self,
1455 attempt: &RunnerAttempt,
1456 config: &EncodedJitConfig,
1457 ) -> Result<u32, ProcessStartFailure> {
1458 let handoff = RestrictiveHandoff::create(
1459 attempt.runtime_path(),
1460 SecretString::from(config.expose().to_owned()),
1461 )
1462 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
1463 #[cfg(windows)]
1464 let program = attempt
1465 .runtime_path()
1466 .join("bin")
1467 .join("Runner.Listener.exe");
1468 #[cfg(not(windows))]
1469 let program = attempt.runtime_path().join("bin").join("Runner.Listener");
1470 if !program.is_file() {
1473 return Err(ProcessStartFailure::before_spawn(
1474 FailureReason::ProcessStartFailed,
1475 ));
1476 }
1477 #[cfg(test)]
1478 let spec = if self
1479 .use_long_lived_test_listener
1480 .load(std::sync::atomic::Ordering::SeqCst)
1481 {
1482 SpawnSpec::new(program)
1483 .args([
1484 "--ignored",
1485 "--exact",
1486 "lifecycle::tests::long_lived_native_listener_helper",
1487 "--nocapture",
1488 ])
1489 .env(
1490 "RUNNER_MANAGER_TEST_LISTENER_READY",
1491 attempt.runtime_path().join(TEST_LISTENER_READY),
1492 )
1493 .working_dir(attempt.runtime_path())
1494 } else {
1495 runner_listener_spec(program, attempt.runtime_path())
1496 };
1497 #[cfg(not(test))]
1498 let spec = runner_listener_spec(program, attempt.runtime_path());
1499 let child = spec
1500 .spawn_runner_with_handoff(&handoff)
1501 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
1502 #[cfg(test)]
1504 if self.faults_at(PostSpawnBoundary::HandoffDelete) {
1505 drop(handoff);
1506 return Err(self.abort_spawned_child(child, attempt, false));
1507 }
1508 if handoff.delete().is_err() {
1509 return Err(self.abort_spawned_child(child, attempt, false));
1510 }
1511 #[cfg(test)]
1512 if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
1513 return Err(self.abort_spawned_child(child, attempt, false));
1514 }
1515 let identity = match serde_json::to_vec(child.identity()) {
1516 Ok(identity) => identity,
1517 Err(_) => {
1518 return Err(self.abort_spawned_child(child, attempt, false));
1519 }
1520 };
1521 if self.persist_identity(attempt, &identity).is_err() {
1522 return Err(self.abort_spawned_child(child, attempt, true));
1523 }
1524 let pid = child.pid();
1525 #[cfg(test)]
1526 if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
1527 return Err(self.abort_spawned_child(child, attempt, true));
1528 }
1529 let mut children = self
1530 .children
1531 .lock()
1532 .unwrap_or_else(std::sync::PoisonError::into_inner);
1533 children.insert(attempt.id, child);
1534 Ok(pid)
1535 }
1536
1537 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
1538 let mut children = self
1539 .children
1540 .lock()
1541 .unwrap_or_else(std::sync::PoisonError::into_inner);
1542 if let Some(child) = children.get_mut(&attempt.id) {
1543 return match child
1544 .try_exit_status()
1545 .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
1546 {
1547 None => Ok(true),
1548 Some(status) => {
1549 if let Ok(mut exits) = self.successful_exits.lock() {
1550 exits.insert(attempt.id, status.success());
1551 }
1552 Ok(false)
1553 }
1554 };
1555 }
1556 let Some(identity) = Self::read_identity(attempt)? else {
1557 if attempt.process_id().is_some() || Self::unresolved_process_path(attempt).is_file() {
1558 return Err(FailureReason::Other(
1559 "runner process identity is missing; refusing recovery until the process is resolved"
1560 .into(),
1561 ));
1562 }
1563 return Ok(false);
1564 };
1565 match identity.recheck() {
1566 Ok(Adoption::Live) => Ok(true),
1567 Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
1568 Err(_) => Ok(false),
1569 }
1570 }
1571
1572 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
1573 Ok(Self::read_identity(attempt)?.map(|identity| identity.pid()))
1574 }
1575
1576 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
1577 self.successful_exits
1578 .lock()
1579 .ok()
1580 .and_then(|exits| exits.get(&attempt.id).copied())
1581 .unwrap_or(false)
1582 }
1583
1584 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
1585 let path = Self::intent_path(attempt);
1586 write_durable_file(&path, b"registration-timeout\n")
1587 .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
1588 }
1589
1590 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
1591 Self::intent_path(attempt).is_file()
1592 }
1593
1594 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
1595 let mut children = self
1596 .children
1597 .lock()
1598 .unwrap_or_else(std::sync::PoisonError::into_inner);
1599 if let Some(child) = children.get_mut(&attempt.id) {
1600 child
1601 .stop(Duration::from_secs(10))
1602 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
1603 return Ok(());
1604 }
1605 let Some(identity) = Self::read_identity(attempt)? else {
1606 return Ok(());
1607 };
1608 match identity
1609 .terminate(Duration::from_secs(10))
1610 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
1611 {
1612 Termination::Terminated | Termination::AlreadyGone => Ok(()),
1613 Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
1614 "runner PID was recycled; refusing to signal it".into(),
1615 )),
1616 }
1617 }
1618}
1619
1620pub struct LifecyclePorts {
1621 pub store: Arc<dyn Store>,
1622 pub github: Arc<dyn LifecycleGithub>,
1623 pub packages: Arc<dyn RuntimePackages>,
1624 pub processes: Arc<dyn ProcessSupervisor>,
1625 pub clock: Arc<dyn Clock>,
1626 pub demand: Arc<dyn DemandPersistence>,
1627 pub delay: Arc<dyn RetryDelay>,
1628 pub events: Arc<dyn AttemptEventSink>,
1629 pub reconcile_events: Arc<dyn EventSink>,
1630}
1631
1632impl fmt::Debug for LifecyclePorts {
1633 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1634 f.debug_struct("LifecyclePorts")
1635 .field("store", &self.store)
1636 .field("github", &self.github)
1637 .field("packages", &self.packages)
1638 .field("processes", &self.processes)
1639 .finish_non_exhaustive()
1640 }
1641}
1642
1643#[derive(Debug, thiserror::Error)]
1644pub enum LifecycleError {
1645 #[error("attempt journal operation failed")]
1646 Journal,
1647 #[error("attempt {0} is not in the journal")]
1648 Missing(AttemptId),
1649 #[error("attempt lifecycle transition was refused")]
1650 Transition,
1651 #[error("startup recovery has not completed")]
1652 RecoveryIncomplete,
1653 #[error("the persistent slot was not cleaned: {detail}")]
1663 SlotQuarantined {
1664 class: &'static str,
1666 detail: String,
1667 },
1668 #[error("runner lifecycle failed: {0}")]
1669 Failed(FailureReason),
1670}
1671
1672impl LifecycleError {
1673 fn reason(&self) -> FailureReason {
1674 match self {
1675 Self::Failed(reason) => reason.clone(),
1676 Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
1677 Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
1678 Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
1679 Self::Transition => FailureReason::Other("attempt transition was refused".into()),
1680 Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
1683 }
1684 }
1685}
1686
1687#[derive(Debug)]
1689pub struct LifecycleLauncher {
1690 host_id: HostId,
1691 app_paths: runner_manager_platform::paths::AppPaths,
1692 diagnostics_root: PathBuf,
1693 runner_group_id: u64,
1694 timeouts: RecoveryTimeouts,
1695 retry: RetryPolicy,
1696 cancel: CancelToken,
1697 ports: LifecyclePorts,
1698 recovery_complete: Mutex<bool>,
1699 versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
1700 pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
1701}
1702
1703#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1704enum ReconcileProgress {
1705 Reconciled,
1706 Deferred,
1707 Replacement {
1708 attempt: AttemptId,
1709 operation: &'static str,
1710 },
1711}
1712
1713impl LifecycleLauncher {
1714 #[must_use]
1715 pub fn new(
1716 host_id: HostId,
1717 app_paths: runner_manager_platform::paths::AppPaths,
1718 diagnostics_root: impl Into<PathBuf>,
1719 runner_group_id: u64,
1720 timeouts: RecoveryTimeouts,
1721 retry: RetryPolicy,
1722 ports: LifecyclePorts,
1723 ) -> Self {
1724 Self {
1725 host_id,
1726 app_paths,
1727 diagnostics_root: diagnostics_root.into(),
1728 runner_group_id,
1729 timeouts,
1730 retry,
1731 cancel: CancelToken::new(),
1732 ports,
1733 recovery_complete: Mutex::new(false),
1734 versions: Mutex::new(BTreeMap::new()),
1735 pending_replacements: Mutex::new(BTreeMap::new()),
1736 }
1737 }
1738
1739 pub async fn recover_startup(
1743 &self,
1744 policies: &[ScalePolicy],
1745 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
1746 let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
1747 let attempts = self
1748 .ports
1749 .store
1750 .attempts()
1751 .map_err(|_| LifecycleError::Journal)?;
1752 let mut unresolved = false;
1753 for attempt in attempts {
1754 let Some(policy) = by_id.get(&attempt.policy_id) else {
1755 if !attempt.is_terminal() && attempt.state() != AttemptState::Cleaned {
1756 unresolved = true;
1757 }
1758 continue;
1759 };
1760 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
1761 match self.reconcile_one(policy, attempt).await? {
1762 ReconcileProgress::Deferred => unresolved = true,
1763 ReconcileProgress::Replacement { attempt, operation } => {
1764 self.pending_replacements
1765 .lock()
1766 .map_err(|_| LifecycleError::Journal)?
1767 .insert(
1768 attempt,
1769 ReplacementIntent {
1770 policy: policy.id,
1771 previous_attempt: attempt,
1772 operation,
1773 },
1774 );
1775 }
1776 ReconcileProgress::Reconciled => {}
1777 }
1778 }
1779 if unresolved {
1780 return Err(LifecycleError::RecoveryIncomplete);
1781 }
1782 *self
1783 .recovery_complete
1784 .lock()
1785 .map_err(|_| LifecycleError::Journal)? = true;
1786 Ok(self
1787 .pending_replacements
1788 .lock()
1789 .map_err(|_| LifecycleError::Journal)?
1790 .values()
1791 .copied()
1792 .collect())
1793 }
1794
1795 pub async fn supervise(
1797 &self,
1798 policy: &ScalePolicy,
1799 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
1800 let mut replacements = Vec::new();
1801 self.pending_replacements
1802 .lock()
1803 .map_err(|_| LifecycleError::Journal)?
1804 .retain(|_, intent| {
1805 if intent.policy == policy.id {
1806 replacements.push(*intent);
1807 false
1808 } else {
1809 true
1810 }
1811 });
1812 let attempts = self
1813 .ports
1814 .store
1815 .attempts_for_policy(policy.id)
1816 .map_err(|_| LifecycleError::Journal)?;
1817 for attempt in attempts {
1818 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
1819 if let ReconcileProgress::Replacement { attempt, operation } =
1820 self.reconcile_one(policy, attempt).await?
1821 {
1822 replacements.push(ReplacementIntent {
1823 policy: policy.id,
1824 previous_attempt: attempt,
1825 operation,
1826 });
1827 }
1828 }
1829 Ok(replacements)
1830 }
1831
1832 async fn reconcile_one(
1833 &self,
1834 policy: &ScalePolicy,
1835 mut attempt: RunnerAttempt,
1836 ) -> Result<ReconcileProgress, LifecycleError> {
1837 if attempt.state() == AttemptState::Cleaned {
1838 return Ok(ReconcileProgress::Reconciled);
1839 }
1840 if attempt.is_terminal() {
1841 self.clean_or_quarantine(&mut attempt)?;
1842 return Ok(ReconcileProgress::Reconciled);
1843 }
1844 let process_alive = self
1845 .ports
1846 .processes
1847 .is_alive(&attempt)
1848 .map_err(LifecycleError::Failed)?;
1849 let github = self
1850 .ports
1851 .github
1852 .observe(&policy.target, attempt.id, &self.cancel)
1853 .await;
1854
1855 if let Some(runner_id) = github.runner_id
1860 && read_runner_id(attempt.runtime_path()).is_none()
1861 {
1862 write_runner_id(attempt.runtime_path(), runner_id)?;
1863 self.ports
1864 .events
1865 .emit(AttemptEvent::RemoteIdentityRecovered {
1866 attempt: attempt.id,
1867 runner_id,
1868 });
1869 }
1870
1871 if attempt.state() == AttemptState::JitReceived
1876 && process_alive
1877 && let Some(pid) = self
1878 .ports
1879 .processes
1880 .recovered_pid(&attempt)
1881 .map_err(LifecycleError::Failed)?
1882 {
1883 attempt
1884 .started(pid, self.ports.clock.now())
1885 .map_err(|_| LifecycleError::Transition)?;
1886 self.record(&attempt)?;
1887 }
1888
1889 if attempt.state() == AttemptState::Busy
1894 && !process_alive
1895 && github.status == GithubRunnerObservation::NotRegistered
1896 && self.ports.processes.completed_successfully(&attempt)
1897 {
1898 self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
1899 self.clean_or_quarantine(&mut attempt)?;
1900 return Ok(ReconcileProgress::Reconciled);
1901 }
1902
1903 if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
1906 self.deregister_runner(policy, &attempt).await;
1907 self.conclude(
1908 &mut attempt,
1909 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
1910 )?;
1911 self.clean_or_quarantine(&mut attempt)?;
1912 return Ok(ReconcileProgress::Replacement {
1913 attempt: attempt.id,
1914 operation: "registration_timeout_replacement",
1915 });
1916 }
1917
1918 if matches!(
1924 attempt.state(),
1925 AttemptState::Allocated | AttemptState::JitReceived
1926 ) && !process_alive
1927 && matches!(github.status, GithubRunnerObservation::Registered { .. })
1928 {
1929 if attempt.state() == AttemptState::Allocated {
1930 attempt
1931 .jit_received(self.ports.clock.now())
1932 .map_err(|_| LifecycleError::Transition)?;
1933 self.record(&attempt)?;
1934 }
1935 self.deregister_runner(policy, &attempt).await;
1936 self.conclude(
1937 &mut attempt,
1938 AttemptOutcome::failed(FailureReason::JitExpired),
1939 )?;
1940 self.clean_or_quarantine(&mut attempt)?;
1941 return Ok(ReconcileProgress::Replacement {
1942 attempt: attempt.id,
1943 operation: "jit_expired_replacement",
1944 });
1945 }
1946
1947 match recovery_decision(
1948 &attempt,
1949 RecoveryObservation {
1950 process_alive,
1951 github: github.status,
1952 },
1953 self.timeouts,
1954 self.ports.clock.as_ref(),
1955 ) {
1956 RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
1957 RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
1958 RecoveryDecision::Adopt => {
1959 self.ports.events.emit(AttemptEvent::Adopted {
1960 attempt: attempt.id,
1961 });
1962 Ok(ReconcileProgress::Reconciled)
1963 }
1964 RecoveryDecision::Clean => {
1965 self.clean_or_quarantine(&mut attempt)?;
1966 Ok(ReconcileProgress::Reconciled)
1967 }
1968 RecoveryDecision::Observe(state) => {
1969 let runner_id = attempt
1970 .github_runner_id()
1971 .or(github.runner_id)
1972 .or_else(|| read_runner_id(attempt.runtime_path()))
1973 .ok_or(LifecycleError::Transition)?;
1974 match state {
1975 AttemptState::JitReceived => attempt
1976 .jit_received(self.ports.clock.now())
1977 .map_err(|_| LifecycleError::Transition)?,
1978 AttemptState::Starting => {
1979 let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
1980 attempt
1981 .started(pid, self.ports.clock.now())
1982 .map_err(|_| LifecycleError::Transition)?;
1983 }
1984 AttemptState::Idle => attempt
1985 .registered_idle(runner_id, self.ports.clock.now())
1986 .map_err(|_| LifecycleError::Transition)?,
1987 AttemptState::Busy => attempt
1988 .assigned_job(runner_id, self.ports.clock.now())
1989 .map_err(|_| LifecycleError::Transition)?,
1990 _ => return Err(LifecycleError::Transition),
1991 }
1992 self.record(&attempt)?;
1993 Ok(ReconcileProgress::Reconciled)
1994 }
1995 RecoveryDecision::Conclude(outcome) => {
1996 let replacement = replacement_operation(&outcome);
1997 if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
2002 self.deregister_runner(policy, &attempt).await;
2003 }
2004 self.conclude(&mut attempt, outcome)?;
2005 self.clean_or_quarantine(&mut attempt)?;
2006 Ok(
2007 replacement.map_or(ReconcileProgress::Reconciled, |operation| {
2008 ReconcileProgress::Replacement {
2009 attempt: attempt.id,
2010 operation,
2011 }
2012 }),
2013 )
2014 }
2015 RecoveryDecision::Terminate(payload) => {
2016 let idle_exit = payload.is_idle_exit();
2026 self.ports
2027 .processes
2028 .record_terminate_intent(&attempt)
2029 .map_err(LifecycleError::Failed)?;
2030 self.ports.events.emit(AttemptEvent::TerminateIntent {
2031 attempt: attempt.id,
2032 });
2033 self.ports
2034 .processes
2035 .terminate(&attempt)
2036 .map_err(LifecycleError::Failed)?;
2037 if self
2038 .ports
2039 .processes
2040 .is_alive(&attempt)
2041 .map_err(LifecycleError::Failed)?
2042 {
2043 return Ok(ReconcileProgress::Deferred);
2044 }
2045 self.ports.events.emit(AttemptEvent::Terminated {
2046 attempt: attempt.id,
2047 });
2048 let outcome = if idle_exit {
2054 AttemptOutcome::ExitedIdleWithoutWork
2055 } else {
2056 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
2057 };
2058 self.deregister_runner(policy, &attempt).await;
2059 self.conclude(&mut attempt, outcome)?;
2060 self.clean_or_quarantine(&mut attempt)?;
2061 if idle_exit {
2065 Ok(ReconcileProgress::Reconciled)
2066 } else {
2067 Ok(ReconcileProgress::Replacement {
2068 attempt: attempt.id,
2069 operation: "registration_timeout_replacement",
2070 })
2071 }
2072 }
2073 }
2074 }
2075
2076 fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2077 self.ports
2078 .store
2079 .record_attempt(attempt)
2080 .map_err(|_| LifecycleError::Journal)?;
2081 self.ports.events.emit(AttemptEvent::State {
2082 attempt: attempt.id,
2083 state: attempt.state(),
2084 });
2085 Ok(())
2086 }
2087
2088 async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
2109 let Some(runner_id) = attempt
2110 .github_runner_id()
2111 .or_else(|| read_runner_id(attempt.runtime_path()))
2112 else {
2113 return;
2114 };
2115 if self
2116 .ports
2117 .github
2118 .deregister(&policy.target, runner_id, &self.cancel)
2119 .await
2120 {
2121 self.ports.events.emit(AttemptEvent::Deregistered {
2122 attempt: attempt.id,
2123 runner_id,
2124 });
2125 } else {
2126 tracing::warn!(
2127 attempt = %attempt.id,
2128 runner_id,
2129 "the runner registration could not be removed from GitHub; it will show in the \
2130 target's runner settings until GitHub retires it or a later pass removes it"
2131 );
2132 }
2133 }
2134
2135 fn conclude(
2136 &self,
2137 attempt: &mut RunnerAttempt,
2138 outcome: AttemptOutcome,
2139 ) -> Result<(), LifecycleError> {
2140 attempt
2141 .conclude(outcome.clone(), self.ports.clock.now())
2142 .map_err(|_| LifecycleError::Transition)?;
2143 self.record(attempt)?;
2144 self.ports.events.emit(AttemptEvent::Concluded {
2145 attempt: attempt.id,
2146 outcome: OutcomeKind::of(&outcome),
2147 });
2148 Ok(())
2149 }
2150
2151 fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
2166 match self.clean_attempt(attempt) {
2167 Err(LifecycleError::SlotQuarantined { class, .. }) => {
2168 self.ports
2169 .reconcile_events
2170 .emit(LifecycleEvent::AttemptCleanFailed {
2171 policy: attempt.policy_id,
2172 attempt: attempt.id,
2173 reason: class,
2174 });
2175 Ok(())
2176 }
2177 other => other,
2178 }
2179 }
2180
2181 fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
2182 let outcome = attempt
2183 .outcome()
2184 .cloned()
2185 .ok_or(LifecycleError::Transition)?;
2186 self.preserve_diagnostics(attempt, &outcome)?;
2187 self.scrub_workspace(attempt)?;
2188 self.ports
2189 .packages
2190 .release(attempt.id)
2191 .map_err(LifecycleError::Failed)?;
2192 attempt
2193 .clean(self.ports.clock.now())
2194 .map_err(|_| LifecycleError::Transition)?;
2195 self.record(attempt)?;
2196 let kind = OutcomeKind::of(&outcome);
2197 self.ports.events.emit(AttemptEvent::Cleaned {
2198 attempt: attempt.id,
2199 outcome: kind,
2200 });
2201 self.ports
2202 .reconcile_events
2203 .emit(LifecycleEvent::AttemptCleaned {
2204 policy: attempt.policy_id,
2205 attempt: attempt.id,
2206 outcome: kind,
2207 });
2208 Ok(())
2209 }
2210
2211 fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2221 #[cfg(test)]
2222 {
2223 if matches!(
2227 std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
2228 Ok("skip_workspace_cleanup" | "reuse_job_workspace")
2229 ) {
2230 return Ok(());
2231 }
2232 }
2233 match attempt.workspace() {
2234 AttemptWorkspace::Ephemeral => match fs::remove_dir_all(attempt.runtime_path()) {
2235 Ok(()) => Ok(()),
2236 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2237 Err(_) => Err(LifecycleError::Failed(FailureReason::Other(
2238 "attempt workspace could not be removed".into(),
2239 ))),
2240 },
2241 AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
2242 }
2243 }
2244
2245 fn scrub_persistent_slot(
2258 &self,
2259 attempt: &RunnerAttempt,
2260 slot: NonZeroU16,
2261 ) -> Result<(), LifecycleError> {
2262 let configured = self
2267 .ports
2268 .store
2269 .policy(attempt.policy_id)
2270 .map_err(|_| LifecycleError::Journal)?
2271 .and_then(|policy| match policy.workspace_policy() {
2272 WorkspacePolicy::Persistent { root } => Some(root.clone()),
2273 WorkspacePolicy::Ephemeral => None,
2274 });
2275 let runtime = attempt.runtime_path();
2276 self.quarantine_on_refusal(
2277 attempt,
2278 verify_journalled_slot(runtime, slot, configured.as_ref())
2279 .and_then(|()| slot_is_present(runtime))
2280 .and_then(|present| {
2281 if present {
2282 scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
2283 } else {
2284 Ok(())
2285 }
2286 }),
2287 )
2288 }
2289
2290 fn quarantine_on_refusal(
2299 &self,
2300 attempt: &RunnerAttempt,
2301 outcome: Result<(), SlotQuarantine>,
2302 ) -> Result<(), LifecycleError> {
2303 let Err(quarantine) = outcome else {
2304 return Ok(());
2305 };
2306 let detail = quarantine.to_string();
2307 tracing::warn!(
2308 attempt = %attempt.id,
2309 policy = %attempt.policy_id,
2310 slot = attempt.workspace().slot_number(),
2311 refusal = quarantine.refusal.class(),
2312 "{detail}"
2313 );
2314 Err(LifecycleError::SlotQuarantined {
2315 class: quarantine.refusal.class(),
2316 detail,
2317 })
2318 }
2319
2320 fn preserve_diagnostics(
2321 &self,
2322 attempt: &RunnerAttempt,
2323 outcome: &AttemptOutcome,
2324 ) -> Result<(), LifecycleError> {
2325 fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
2326 LifecycleError::Failed(FailureReason::Other(
2327 "diagnostics directory could not be created".into(),
2328 ))
2329 })?;
2330 let diagnostic = format!(
2333 "attempt_id={}\npolicy_id={}\noutcome={}\n",
2334 attempt.id,
2335 attempt.policy_id,
2336 OutcomeKind::of(outcome).as_str()
2337 );
2338 fs::write(
2339 self.diagnostics_root.join(format!("{}.log", attempt.id)),
2340 diagnostic,
2341 )
2342 .map_err(|_| {
2343 LifecycleError::Failed(FailureReason::Other(
2344 "redacted diagnostics could not be preserved".into(),
2345 ))
2346 })
2347 }
2348
2349 async fn materialize_with_retry(
2350 &self,
2351 policy: &ScalePolicy,
2352 attempt: &RunnerAttempt,
2353 ) -> Result<RunnerVersion, FailureReason> {
2354 let mut issued = 0_u32;
2355 loop {
2356 issued = issued.saturating_add(1);
2357 match self.ports.packages.materialize(attempt).await {
2358 Ok(version) => return Ok(version),
2359 Err(reason)
2360 if package_failure_is_terminal(&reason)
2361 || issued >= self.retry.max_attempts.max(1) =>
2362 {
2363 return Err(reason);
2364 }
2365 Err(reason) => {
2366 if !self.ports.demand.persists(policy.id).await {
2367 return Err(reason);
2368 }
2369 let delay = self.retry.delay(issued);
2370 self.ports.events.emit(AttemptEvent::Retry {
2371 attempt: attempt.id,
2372 operation: "package_materialization",
2373 delay,
2374 });
2375 self.ports.delay.wait(delay).await;
2376 if !self.ports.demand.persists(policy.id).await {
2377 return Err(reason);
2378 }
2379 }
2380 }
2381 }
2382 }
2383
2384 async fn register_with_retry(
2385 &self,
2386 policy: &ScalePolicy,
2387 attempt: AttemptId,
2388 request: &JitRunnerRequest,
2389 ) -> Result<JitRegistration, LifecycleError> {
2390 let mut issued = 0_u32;
2391 loop {
2392 issued = issued.saturating_add(1);
2393 match self
2394 .ports
2395 .github
2396 .register(&policy.target, request, &self.cancel)
2397 .await
2398 {
2399 Ok(registration) => return Ok(registration),
2400 Err(error) if error.terminal => {
2401 return Err(LifecycleError::Failed(error.reason));
2402 }
2403 Err(error) => {
2404 if issued >= self.retry.max_attempts.max(1)
2405 || !self.ports.demand.persists(policy.id).await
2406 {
2407 return Err(LifecycleError::Failed(error.reason));
2408 }
2409 let delay = error
2410 .retry_after
2411 .unwrap_or_else(|| self.retry.delay(issued));
2412 self.ports.events.emit(AttemptEvent::Retry {
2413 attempt,
2414 operation: "jit_request",
2415 delay,
2416 });
2417 self.ports.delay.wait(delay).await;
2418 if !self.ports.demand.persists(policy.id).await {
2419 return Err(LifecycleError::Failed(error.reason));
2420 }
2421 }
2422 }
2423 }
2424 }
2425
2426 fn allocate_workspace(
2436 &self,
2437 policy: &ScalePolicy,
2438 id: AttemptId,
2439 ) -> Result<Placement, LifecycleError> {
2440 let placement = match policy.workspace_policy() {
2441 WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
2446 WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
2447 };
2448 if placement.is_ok() {
2451 self.root_accepted(policy.id);
2452 }
2453 placement
2454 }
2455
2456 fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
2463 let host = self
2464 .ports
2465 .store
2466 .host(self.host_id)
2467 .map_err(|_| LifecycleError::Journal)?
2468 .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
2469 Ok(host.runner_root_override.clone())
2470 }
2471
2472 fn effective_host_root(
2477 &self,
2478 policy: &ScalePolicy,
2479 ) -> Result<LocalAbsolutePath, LifecycleError> {
2480 match self.configured_host_root()? {
2481 Some(configured) => Ok(configured),
2482 None => default_runner_root(&self.app_paths).map_err(|error| {
2483 self.root_refused(policy.id, "the platform default runner root", error)
2486 }),
2487 }
2488 }
2489
2490 fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
2503 let _ = runner_manager_platform::service::record_runner_root_refusal(
2504 &self.app_paths,
2505 &policy.to_string(),
2506 self.ports.clock.now(),
2507 error.kind(),
2508 root,
2509 &error.to_string(),
2510 );
2511 root_failure(error)
2512 }
2513
2514 fn root_accepted(&self, policy: PolicyId) {
2521 let _ = runner_manager_platform::service::clear_runner_root_refusal(
2522 &self.app_paths,
2523 &policy.to_string(),
2524 );
2525 }
2526
2527 fn allocate_disposable(
2530 &self,
2531 policy: &ScalePolicy,
2532 id: AttemptId,
2533 ) -> Result<Placement, LifecycleError> {
2534 let effective_root = self.effective_host_root(policy)?;
2535 RootPreflight::new(&self.app_paths)
2536 .check(&RootOwner::Host, &effective_root)
2537 .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
2538 let runtime = effective_root.as_path().join({
2539 #[cfg(test)]
2540 {
2541 if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
2542 == Ok("reuse_job_workspace")
2543 {
2544 "mutant-shared-workspace".to_owned()
2545 } else {
2546 workspace_name(id)
2547 }
2548 }
2549 #[cfg(not(test))]
2550 {
2551 workspace_name(id)
2552 }
2553 });
2554 fs::create_dir_all(&runtime)
2555 .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
2556 Ok(Placement {
2557 runtime,
2558 workspace: AttemptWorkspace::Ephemeral,
2559 })
2560 }
2561
2562 fn allocate_persistent_slot(
2574 &self,
2575 policy: &ScalePolicy,
2576 root: &LocalAbsolutePath,
2577 ) -> Result<Placement, LifecycleError> {
2578 let ceiling = policy.max_capacity().ok_or_else(|| {
2581 LifecycleError::Failed(FailureReason::Other(
2582 "a persistent workspace needs the policy's max_capacity to bound its slots"
2583 .to_string(),
2584 ))
2585 })?;
2586 let leases = self
2587 .ports
2588 .store
2589 .slot_leases_for_policy(policy.id)
2590 .map_err(|_| LifecycleError::Journal)?;
2591 let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
2592 LifecycleError::Failed(FailureReason::Other(format!(
2593 "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
2594 not been cleaned, so no slot is free; raise the repository's max capacity, or \
2595 finish cleaning a concluded attempt",
2596 policy.target
2597 )))
2598 })?;
2599 let workspace = AttemptWorkspace::persistent_slot(slot);
2600 let name = workspace
2601 .slot_directory_name()
2602 .expect("a persistent allocation names its slot directory");
2603
2604 let host_root = self
2616 .configured_host_root()?
2617 .or_else(|| default_runner_root(&self.app_paths).ok());
2618 let mut preflight = RootPreflight::new(&self.app_paths);
2619 if let Some(host_root) = host_root {
2620 preflight = preflight.against(RootOwner::Host, host_root);
2621 }
2622 let checked = preflight
2623 .check(&RootOwner::Repository(policy.target.to_string()), root)
2624 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
2625 if let Some(leaf) = checked.leaf_to_create() {
2626 fs::create_dir(leaf).map_err(|source| {
2627 LifecycleError::Failed(FailureReason::Other(format!(
2628 "the persistent workspace root {} could not be created: {source}",
2629 leaf.display()
2630 )))
2631 })?;
2632 }
2633
2634 let slot_path = runner_root::derive_child(root, &name)
2638 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
2639 create_or_validate_slot(slot_path.as_path())?;
2640 runner_root::verify_containment(root, &slot_path)
2641 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
2642 accept_reusable_slot(slot_path.as_path())?;
2643 Ok(Placement {
2644 runtime: slot_path.as_path().to_path_buf(),
2645 workspace,
2646 })
2647 }
2648
2649 fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2661 match self.ports.store.record_attempt(attempt) {
2662 Ok(()) => {
2663 self.ports.events.emit(AttemptEvent::State {
2664 attempt: attempt.id,
2665 state: attempt.state(),
2666 });
2667 Ok(())
2668 }
2669 Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
2670 FailureReason::Other(error.to_string()),
2671 )),
2672 Err(_) => Err(LifecycleError::Journal),
2673 }
2674 }
2675
2676 async fn launch_attempt(
2677 &self,
2678 policy: &ScalePolicy,
2679 allocation_guard: &AllocationGuard,
2680 ) -> Result<RunnerAttempt, LifecycleError> {
2681 if !*self
2682 .recovery_complete
2683 .lock()
2684 .map_err(|_| LifecycleError::Journal)?
2685 {
2686 return Err(LifecycleError::RecoveryIncomplete);
2687 }
2688 let labels = policy
2689 .routing_labels()
2690 .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
2691 let id = AttemptId::new_random();
2692 let placement = self.allocate_workspace(policy, id)?;
2693 let mut attempt = RunnerAttempt::allocate_in(
2694 id,
2695 policy.id,
2696 placement.runtime,
2697 placement.workspace,
2698 self.ports.clock.now(),
2699 );
2700 self.record_allocation(&attempt)?;
2705
2706 let version = match self.materialize_with_retry(policy, &attempt).await {
2707 Ok(version) => version,
2708 Err(reason) => return self.fail_launch(&mut attempt, reason),
2709 };
2710 self.prune_under_allocation_lock(allocation_guard, &version)?;
2711 self.versions
2712 .lock()
2713 .map_err(|_| LifecycleError::Journal)?
2714 .insert(id, version);
2715
2716 let jit_request =
2717 JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
2718 let registration = match self.register_with_retry(policy, id, &jit_request).await {
2719 Ok(registration) => registration,
2720 Err(error) => return self.fail_launch(&mut attempt, error.reason()),
2721 };
2722 let runner_id = registration.runner().id;
2723 write_runner_id(attempt.runtime_path(), runner_id)?;
2724 attempt
2725 .jit_received(self.ports.clock.now())
2726 .map_err(|_| LifecycleError::Transition)?;
2727 self.record(&attempt)?;
2728 let config = registration.into_config();
2729 let mut issued = 0_u32;
2730 let pid = loop {
2731 issued = issued.saturating_add(1);
2732 match self.ports.processes.spawn(&attempt, &config) {
2733 Ok(pid) => break pid,
2734 Err(error) => {
2735 if let Some(pid) = error.live_pid {
2736 attempt
2737 .started(pid, self.ports.clock.now())
2738 .map_err(|_| LifecycleError::Transition)?;
2739 self.record(&attempt)?;
2740 return Err(LifecycleError::Failed(error.reason));
2741 }
2742 if !error.retryable
2743 || issued >= self.retry.max_attempts.max(1)
2744 || !self.ports.demand.persists(policy.id).await
2745 {
2746 return self.fail_launch(&mut attempt, error.reason);
2747 }
2748 let delay = self.retry.delay(issued);
2749 self.ports.events.emit(AttemptEvent::Retry {
2750 attempt: attempt.id,
2751 operation: "process_start",
2752 delay,
2753 });
2754 self.ports.delay.wait(delay).await;
2755 if !self.ports.demand.persists(policy.id).await {
2756 return self.fail_launch(&mut attempt, error.reason);
2757 }
2758 }
2759 }
2760 };
2761 attempt
2762 .started(pid, self.ports.clock.now())
2763 .map_err(|_| LifecycleError::Transition)?;
2764 self.record(&attempt)?;
2765 Ok(attempt)
2766 }
2767
2768 fn fail_launch<T>(
2769 &self,
2770 attempt: &mut RunnerAttempt,
2771 reason: FailureReason,
2772 ) -> Result<T, LifecycleError> {
2773 self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
2774 Err(LifecycleError::Failed(reason))
2775 }
2776
2777 fn prune_under_allocation_lock(
2780 &self,
2781 guard: &AllocationGuard,
2782 version: &RunnerVersion,
2783 ) -> Result<(), LifecycleError> {
2784 let attempts = self
2785 .ports
2786 .store
2787 .attempts()
2788 .map_err(|_| LifecycleError::Journal)?;
2789 self.ports
2790 .packages
2791 .prune_obsolete_guarded(
2792 PruneAuthority::from_launch_request(guard),
2793 version,
2794 &attempts,
2795 )
2796 .map_err(LifecycleError::Failed)
2797 }
2798}
2799
2800#[async_trait]
2801impl RunnerLauncher for LifecycleLauncher {
2802 async fn supervise(
2803 &self,
2804 policy: &ScalePolicy,
2805 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
2806 LifecycleLauncher::supervise(self, policy)
2807 .await
2808 .map_err(|error| LaunchFailure::new(error.reason()))
2809 }
2810
2811 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
2812 self.ports.store.attempts().map_err(|_| {
2813 LaunchFailure::new(FailureReason::Other(
2814 "attempt journal could not be read".into(),
2815 ))
2816 })
2817 }
2818
2819 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
2820 self.launch_attempt(request.policy, request.allocation_guard)
2821 .await
2822 .map_err(|error| LaunchFailure::new(error.reason()))
2823 }
2824
2825 async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
2826 let mut attempt = self
2827 .ports
2828 .store
2829 .attempt(id)
2830 .map_err(|_| {
2831 LaunchFailure::new(FailureReason::Other(
2832 "attempt journal could not be read".into(),
2833 ))
2834 })?
2835 .ok_or_else(|| {
2836 LaunchFailure::new(FailureReason::Other(
2837 "attempt disappeared from the journal".into(),
2838 ))
2839 })?;
2840 self.clean_attempt(&mut attempt)
2841 .map_err(|error| LaunchFailure::new(error.reason()))
2842 }
2843}
2844
2845fn runner_name(attempt: AttemptId) -> String {
2846 format!("runner-manager-{attempt}")
2847}
2848
2849fn read_runner_id(runtime: &Path) -> Option<u64> {
2850 fs::read_to_string(runtime.join(RUNNER_ID_FILE))
2851 .ok()?
2852 .trim()
2853 .parse()
2854 .ok()
2855}
2856
2857fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
2858 let target = runtime.join(RUNNER_ID_FILE);
2859 if let Some(existing) = read_runner_id(runtime) {
2860 return (existing == runner_id)
2861 .then_some(())
2862 .ok_or(LifecycleError::Journal);
2863 }
2864 let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
2865 write_durable_file(&temporary, runner_id.to_string().as_bytes())
2866 .map_err(|_| LifecycleError::Journal)?;
2867 match fs::rename(&temporary, &target) {
2868 Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
2869 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
2870 let _ = fs::remove_file(&temporary);
2871 (read_runner_id(runtime) == Some(runner_id))
2872 .then_some(())
2873 .ok_or(LifecycleError::Journal)
2874 }
2875 Err(_) => {
2876 let _ = fs::remove_file(&temporary);
2877 Err(LifecycleError::Journal)
2878 }
2879 }
2880}
2881
2882fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2883 let mut file = fs::OpenOptions::new()
2884 .create(true)
2885 .truncate(true)
2886 .write(true)
2887 .open(path)?;
2888 file.write_all(bytes)?;
2889 file.sync_all()?;
2890 let parent = path.parent().ok_or_else(|| {
2891 std::io::Error::new(
2892 std::io::ErrorKind::InvalidInput,
2893 "file has no parent directory",
2894 )
2895 })?;
2896 sync_directory(parent)
2897}
2898
2899#[cfg(unix)]
2900fn sync_directory(path: &Path) -> std::io::Result<()> {
2901 fs::File::open(path)?.sync_all()
2902}
2903
2904#[cfg(windows)]
2905fn sync_directory(path: &Path) -> std::io::Result<()> {
2906 use std::os::windows::fs::OpenOptionsExt;
2907
2908 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
2909 const FILE_SHARE_ALL: u32 = 0x0000_0007;
2910 const GENERIC_WRITE: u32 = 0x4000_0000;
2911 fs::OpenOptions::new()
2912 .access_mode(GENERIC_WRITE)
2913 .share_mode(FILE_SHARE_ALL)
2914 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
2915 .open(path)?
2916 .sync_all()
2917}
2918
2919#[cfg(test)]
2920mod tests {
2921 use super::*;
2922 use std::collections::BTreeSet;
2923 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
2924
2925 use crate::reconcile::{AllocationLock, InProcessAllocationLock};
2926 use runner_manager_domain::model::{Elapsed, TargetScope};
2927 use runner_manager_domain::store::SqliteStore;
2928 use runner_manager_github::jit::JitRunner;
2929 use runner_manager_testkit::clock::FakeClock;
2930 use runner_manager_testkit::fixtures;
2931
2932 type CapturedFields = Vec<(String, String)>;
2934
2935 #[derive(Clone, Default)]
2943 struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
2944
2945 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
2946 fn on_event(
2947 &self,
2948 event: &tracing::Event<'_>,
2949 _context: tracing_subscriber::layer::Context<'_, S>,
2950 ) {
2951 struct Collect(Vec<(String, String)>);
2952 impl tracing::field::Visit for Collect {
2953 fn record_debug(
2954 &mut self,
2955 field: &tracing::field::Field,
2956 value: &dyn std::fmt::Debug,
2957 ) {
2958 self.0.push((
2961 field.name().to_owned(),
2962 format!("{value:?}").trim_matches('"').to_owned(),
2963 ));
2964 }
2965 }
2966 let mut collected = Collect(Vec::new());
2967 event.record(&mut collected);
2968 self.0
2969 .lock()
2970 .expect("the capture mutex is not poisoned")
2971 .push(collected.0);
2972 }
2973 }
2974
2975 #[test]
2993 fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
2994 use runner_manager_platform::logging;
2995 use tracing_subscriber::layer::SubscriberExt as _;
2996
2997 let captured = CapturedEvents::default();
2998 let error = RunnerRootError::DeniedByPrivacyPolicy {
2999 requested: PathBuf::from("/Volumes/NVME/runners"),
3000 refused: PathBuf::from("/Volumes/NVME"),
3001 remediation: RootOwner::Host.remediation(),
3002 };
3003 let kind = error.kind();
3004
3005 let failure = tracing::subscriber::with_default(
3006 tracing_subscriber::registry().with(captured.clone()),
3007 || root_failure(error),
3008 );
3009
3010 assert!(
3015 matches!(
3016 &failure,
3017 LifecycleError::Failed(FailureReason::Other(detail))
3018 if detail.contains("/Volumes/NVME/runners")
3019 && detail.contains("Full Disk Access")
3020 ),
3021 "the reason must still carry the detail: {failure:?}"
3022 );
3023
3024 let events = captured
3025 .0
3026 .lock()
3027 .expect("the capture mutex is not poisoned")
3028 .clone();
3029 let event = events
3030 .iter()
3031 .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
3032 .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
3033
3034 for (name, value) in event {
3039 assert!(
3040 logging::is_field_allowed(name),
3041 "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
3042 logging::REDACTION
3043 );
3044 assert_eq!(
3045 &logging::redact(value),
3046 value,
3047 "`{name}` does not survive value-shape scrubbing: {event:?}"
3048 );
3049 }
3050 }
3051
3052 fn nz(slot: u16) -> NonZeroU16 {
3054 NonZeroU16::new(slot).expect("a positive slot")
3055 }
3056
3057 const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
3058
3059 #[derive(Debug, Default)]
3060 struct FakeGithubLifecycle {
3061 registration_failures: Mutex<VecDeque<bool>>,
3062 observations: Mutex<VecDeque<LifecycleGithubObservation>>,
3063 registrations: AtomicUsize,
3064 remaining_runners: AtomicUsize,
3065 deregistrations: Mutex<Vec<u64>>,
3069 deregistration_fails: AtomicBool,
3072 journal: Mutex<Option<Arc<SqliteStore>>>,
3077 registration_facts: Mutex<Vec<RegistrationFact>>,
3079 }
3080
3081 #[derive(Debug, Clone)]
3088 struct RegistrationFact {
3089 leased_slots: Vec<u16>,
3091 work_folder: String,
3093 runner_name: String,
3096 }
3097
3098 impl FakeGithubLifecycle {
3099 fn fail(mut self, terminal: bool) -> Self {
3100 self.registration_failures
3101 .get_mut()
3102 .expect("unpoisoned")
3103 .push_back(terminal);
3104 self
3105 }
3106
3107 fn watch_journal(&self, store: Arc<SqliteStore>) {
3108 *self.journal.lock().unwrap() = Some(store);
3109 }
3110
3111 fn registration_facts(&self) -> Vec<RegistrationFact> {
3112 self.registration_facts.lock().unwrap().clone()
3113 }
3114
3115 fn observe(&self, observation: GithubRunnerObservation) {
3116 let observation = match observation {
3117 GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
3118 GithubRunnerObservation::NotRegistered => {
3119 LifecycleGithubObservation::not_registered()
3120 }
3121 GithubRunnerObservation::Registered { busy } => {
3122 LifecycleGithubObservation::registered(73, busy)
3123 }
3124 };
3125 self.observations.lock().unwrap().push_back(observation);
3126 }
3127 }
3128
3129 #[async_trait]
3130 impl LifecycleGithub for FakeGithubLifecycle {
3131 async fn register(
3132 &self,
3133 _target: &ScaleTarget,
3134 request: &JitRunnerRequest,
3135 _cancel: &CancelToken,
3136 ) -> Result<JitRegistration, JitRequestFailure> {
3137 self.registrations.fetch_add(1, Ordering::SeqCst);
3138 if let Some(store) = self.journal.lock().unwrap().as_ref() {
3139 let slots = store
3140 .attempts()
3141 .expect("the journal is readable")
3142 .iter()
3143 .filter_map(|attempt| attempt.workspace().slot_number())
3144 .collect();
3145 self.registration_facts
3146 .lock()
3147 .unwrap()
3148 .push(RegistrationFact {
3149 leased_slots: slots,
3150 work_folder: request.work_folder().to_string(),
3151 runner_name: request.name().to_string(),
3152 });
3153 }
3154 if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
3155 return Err(JitRequestFailure {
3156 terminal,
3157 reason: if terminal {
3158 FailureReason::Other("GitHub refused JIT registration with 403".into())
3159 } else {
3160 FailureReason::JitRequestFailed
3161 },
3162 retry_after: None,
3163 });
3164 }
3165 self.remaining_runners.store(1, Ordering::SeqCst);
3166 Ok(JitRegistration::new(
3167 EncodedJitConfig::new(JIT),
3168 JitRunner {
3169 id: 73,
3170 name: request.name().to_string(),
3171 os: "windows".into(),
3172 status: "offline".into(),
3173 busy: false,
3174 runner_group_id: Some(1),
3175 labels: request.labels().to_vec(),
3176 },
3177 ))
3178 }
3179
3180 async fn observe(
3181 &self,
3182 _target: &ScaleTarget,
3183 _attempt: AttemptId,
3184 _cancel: &CancelToken,
3185 ) -> LifecycleGithubObservation {
3186 let observation = self
3187 .observations
3188 .lock()
3189 .unwrap()
3190 .pop_front()
3191 .unwrap_or(LifecycleGithubObservation::not_registered());
3192 if observation.status == GithubRunnerObservation::NotRegistered {
3193 self.remaining_runners.store(0, Ordering::SeqCst);
3194 }
3195 observation
3196 }
3197
3198 async fn deregister(
3199 &self,
3200 _target: &ScaleTarget,
3201 runner_id: u64,
3202 _cancel: &CancelToken,
3203 ) -> bool {
3204 self.deregistrations.lock().unwrap().push(runner_id);
3205 if self.deregistration_fails.load(Ordering::SeqCst) {
3206 return false;
3207 }
3208 self.remaining_runners.store(0, Ordering::SeqCst);
3209 true
3210 }
3211 }
3212
3213 #[derive(Debug)]
3214 struct FakePackages {
3215 version: RunnerVersion,
3216 leases: Mutex<BTreeSet<AttemptId>>,
3217 materializations: AtomicUsize,
3218 materialization_failures: AtomicUsize,
3219 releases: AtomicUsize,
3220 prunes: AtomicUsize,
3221 prune_currents: Mutex<Vec<RunnerVersion>>,
3222 }
3223
3224 impl Default for FakePackages {
3225 fn default() -> Self {
3226 Self {
3227 version: RunnerVersion::parse("2.330.0").unwrap(),
3228 leases: Mutex::new(BTreeSet::new()),
3229 materializations: AtomicUsize::new(0),
3230 materialization_failures: AtomicUsize::new(0),
3231 releases: AtomicUsize::new(0),
3232 prunes: AtomicUsize::new(0),
3233 prune_currents: Mutex::new(Vec::new()),
3234 }
3235 }
3236 }
3237
3238 impl FakePackages {
3239 fn fail_materializations(&self, count: usize) {
3240 self.materialization_failures.store(count, Ordering::SeqCst);
3241 }
3242 }
3243
3244 #[async_trait]
3245 impl RuntimePackages for FakePackages {
3246 async fn materialize(
3247 &self,
3248 attempt: &RunnerAttempt,
3249 ) -> Result<RunnerVersion, FailureReason> {
3250 self.materializations.fetch_add(1, Ordering::SeqCst);
3251 if self
3252 .materialization_failures
3253 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
3254 if left > 0 { Some(left - 1) } else { None }
3255 })
3256 .is_ok()
3257 {
3258 return Err(FailureReason::Other(
3259 "runner package materialization failed transiently".into(),
3260 ));
3261 }
3262 fs::create_dir_all(attempt.runtime_path()).unwrap();
3263 fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
3264 self.leases.lock().unwrap().insert(attempt.id);
3265 Ok(self.version.clone())
3266 }
3267
3268 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
3269 self.leases.lock().unwrap().remove(&attempt);
3270 self.releases.fetch_add(1, Ordering::SeqCst);
3271 Ok(())
3272 }
3273
3274 fn prune_obsolete_guarded(
3275 &self,
3276 _authority: PruneAuthority<'_>,
3277 current: &RunnerVersion,
3278 _attempts: &[RunnerAttempt],
3279 ) -> Result<(), FailureReason> {
3280 self.prunes.fetch_add(1, Ordering::SeqCst);
3281 self.prune_currents.lock().unwrap().push(current.clone());
3282 Ok(())
3283 }
3284 }
3285
3286 #[derive(Debug, Default)]
3287 struct FakeProcesses {
3288 alive: AtomicBool,
3289 completed_successfully: AtomicBool,
3290 spawns: AtomicUsize,
3291 spawn_failures: AtomicUsize,
3292 live_spawn_failure: AtomicBool,
3293 terminations: AtomicUsize,
3294 intent: AtomicBool,
3295 intent_failure: AtomicBool,
3296 actions: Mutex<Vec<&'static str>>,
3297 saw_secret: AtomicBool,
3298 }
3299
3300 impl FakeProcesses {
3301 fn fail_spawns(&self, count: usize) {
3302 self.spawn_failures.store(count, Ordering::SeqCst);
3303 }
3304
3305 fn fail_spawn_with_live_child(&self) {
3306 self.live_spawn_failure.store(true, Ordering::SeqCst);
3307 }
3308
3309 fn set_alive(&self, alive: bool) {
3310 self.alive.store(alive, Ordering::SeqCst);
3311 }
3312
3313 fn finish_successfully(&self) {
3314 self.completed_successfully.store(true, Ordering::SeqCst);
3315 self.alive.store(false, Ordering::SeqCst);
3316 }
3317
3318 fn fail_intent(&self) {
3319 self.intent_failure.store(true, Ordering::SeqCst);
3320 }
3321 }
3322
3323 impl ProcessSupervisor for FakeProcesses {
3324 fn spawn(
3325 &self,
3326 attempt: &RunnerAttempt,
3327 config: &EncodedJitConfig,
3328 ) -> Result<u32, ProcessStartFailure> {
3329 self.spawns.fetch_add(1, Ordering::SeqCst);
3330 let handoff = RestrictiveHandoff::create(
3333 attempt.runtime_path(),
3334 SecretString::from(config.expose().to_owned()),
3335 )
3336 .unwrap();
3337 self.saw_secret
3338 .store(config.expose() == JIT, Ordering::SeqCst);
3339 let handoff_path = handoff.path().to_path_buf();
3340 let failing = self
3341 .spawn_failures
3342 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
3343 if left > 0 { Some(left - 1) } else { None }
3344 })
3345 .is_ok();
3346 drop(handoff);
3347 assert!(!handoff_path.exists(), "handoff must be absent on return");
3348 if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
3349 self.alive.store(true, Ordering::SeqCst);
3350 return Err(ProcessStartFailure::after_spawn_live(4242));
3351 }
3352 if failing {
3353 return Err(ProcessStartFailure::before_spawn(
3354 FailureReason::ProcessStartFailed,
3355 ));
3356 }
3357 self.alive.store(true, Ordering::SeqCst);
3358 Ok(4242)
3359 }
3360
3361 fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
3362 self.actions.lock().unwrap().push("observe_process");
3363 Ok(self.alive.load(Ordering::SeqCst))
3364 }
3365
3366 fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
3367 Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
3368 }
3369
3370 fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
3371 self.completed_successfully.load(Ordering::SeqCst)
3372 }
3373
3374 fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3375 self.actions.lock().unwrap().push("terminate_intent");
3376 if self.intent_failure.load(Ordering::SeqCst) {
3377 return Err(FailureReason::Other(
3378 "terminate intent directory sync failed".into(),
3379 ));
3380 }
3381 self.intent.store(true, Ordering::SeqCst);
3382 Ok(())
3383 }
3384
3385 fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
3386 self.intent.load(Ordering::SeqCst)
3387 }
3388
3389 fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3390 assert!(
3391 self.intent.load(Ordering::SeqCst),
3392 "the durable intent must exist before signalling"
3393 );
3394 self.actions.lock().unwrap().push("terminate");
3395 self.terminations.fetch_add(1, Ordering::SeqCst);
3396 self.alive.store(false, Ordering::SeqCst);
3397 Ok(())
3398 }
3399 }
3400
3401 #[derive(Debug, Default)]
3402 struct FakeDemand {
3403 answers: Mutex<VecDeque<bool>>,
3404 }
3405
3406 impl FakeDemand {
3407 fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
3408 Self {
3409 answers: Mutex::new(answers.into_iter().collect()),
3410 }
3411 }
3412 }
3413
3414 #[async_trait]
3415 impl DemandPersistence for FakeDemand {
3416 async fn persists(&self, _policy: PolicyId) -> bool {
3417 self.answers.lock().unwrap().pop_front().unwrap_or(true)
3418 }
3419 }
3420
3421 #[derive(Debug, Default)]
3422 struct FakeDelay(Mutex<Vec<Duration>>);
3423
3424 #[async_trait]
3425 impl RetryDelay for FakeDelay {
3426 async fn wait(&self, duration: Duration) {
3427 self.0.lock().unwrap().push(duration);
3428 }
3429 }
3430
3431 struct Harness {
3432 _root: tempfile::TempDir,
3433 app_paths: runner_manager_platform::paths::AppPaths,
3434 launcher: LifecycleLauncher,
3435 demand: Arc<dyn DemandPersistence>,
3436 store: Arc<SqliteStore>,
3437 github: Arc<FakeGithubLifecycle>,
3438 packages: Arc<FakePackages>,
3439 processes: Arc<FakeProcesses>,
3440 clock: Arc<FakeClock>,
3441 events: Arc<AttemptEventLog>,
3442 reconcile_events: Arc<crate::reconcile::EventLog>,
3443 delay: Arc<FakeDelay>,
3444 host: runner_manager_domain::model::Host,
3445 policy: ScalePolicy,
3446 allocation_lock: InProcessAllocationLock,
3447 workspace_root: Option<LocalAbsolutePath>,
3449 }
3450
3451 impl Harness {
3452 fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
3453 let root = tempfile::tempdir().unwrap();
3454 let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
3455 paths.create_all().unwrap();
3456 let policy = fixtures::policy()
3457 .repository("octo/repo")
3458 .autoscale("home", 2)
3459 .active()
3460 .build();
3461 let host = fixtures::host().build();
3462 let store = Arc::new(SqliteStore::open_in_memory().unwrap());
3463 store.put_host(&host).unwrap();
3464 let github = Arc::new(github);
3465 let packages = Arc::new(FakePackages::default());
3466 let processes = Arc::new(FakeProcesses::default());
3467 let clock = Arc::new(FakeClock::default());
3468 let events = Arc::new(AttemptEventLog::default());
3469 let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
3470 let delay = Arc::new(FakeDelay::default());
3471 let ports = LifecyclePorts {
3472 store: Arc::clone(&store) as Arc<dyn Store>,
3473 github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
3474 packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
3475 processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
3476 clock: Arc::clone(&clock) as Arc<dyn Clock>,
3477 demand: Arc::clone(&demand),
3478 delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
3479 events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
3480 reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
3481 };
3482 let launcher = Self::launcher_over(policy.host_id, &paths, ports);
3483 Self {
3484 _root: root,
3485 app_paths: paths,
3486 launcher,
3487 demand,
3488 store,
3489 github,
3490 packages,
3491 processes,
3492 clock,
3493 events,
3494 reconcile_events,
3495 delay,
3496 host,
3497 policy,
3498 allocation_lock: InProcessAllocationLock::new(),
3499 workspace_root: None,
3500 }
3501 }
3502
3503 fn with_host_runner_root(mut self) -> Self {
3510 let host_root = self.host_root();
3511 fs::create_dir_all(&host_root).unwrap();
3512 self.host.runner_root_override = Some(
3513 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
3514 .expect("a local absolute host root"),
3515 );
3516 self.store.put_host(&self.host).unwrap();
3517 self
3518 }
3519
3520 fn with_persistent_workspace(mut self, capacity: u16) -> Self {
3522 self = self.with_host_runner_root();
3523 let root = self._root.path().join("persist");
3524 let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
3525 .expect("a local absolute workspace root");
3526 self.policy = fixtures::policy()
3527 .repository("octo/repo")
3528 .autoscale("home", capacity)
3529 .active()
3530 .build();
3531 self.policy
3532 .set_workspace_policy(
3533 WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
3534 .expect("a repository may be persistent"),
3535 )
3536 .expect("a repository may be persistent");
3537 self.workspace_root = Some(root);
3538 self.store.insert_policy(&self.policy).unwrap();
3541 self
3542 }
3543
3544 fn workspace_root(&self) -> &LocalAbsolutePath {
3545 self.workspace_root
3546 .as_ref()
3547 .expect("this harness configured a persistent workspace")
3548 }
3549
3550 fn slot_path(&self, slot: u16) -> PathBuf {
3551 self.workspace_root().as_path().join(format!("s{slot}"))
3552 }
3553
3554 fn host_root(&self) -> PathBuf {
3555 self._root.path().join("host-root")
3556 }
3557
3558 fn attempt(&self, id: AttemptId) -> RunnerAttempt {
3559 self.store
3560 .attempt(id)
3561 .unwrap()
3562 .expect("the attempt is journalled")
3563 }
3564
3565 fn conclude(&self, id: AttemptId) -> RunnerAttempt {
3567 let mut attempt = self.attempt(id);
3568 attempt
3569 .conclude(
3570 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
3571 self.clock.now(),
3572 )
3573 .unwrap();
3574 self.store.record_attempt(&attempt).unwrap();
3575 attempt
3576 }
3577
3578 async fn cleanup_retaining_work(&self, id: AttemptId) {
3579 self.conclude(id);
3580 self.launcher
3581 .clean(id)
3582 .await
3583 .expect("the slot is scrubbed and the lease released");
3584 }
3585
3586 fn launcher_over(
3589 host: HostId,
3590 paths: &runner_manager_platform::paths::AppPaths,
3591 ports: LifecyclePorts,
3592 ) -> LifecycleLauncher {
3593 LifecycleLauncher::new(
3594 host,
3595 paths.clone(),
3596 paths.logs_dir(),
3597 1,
3598 RecoveryTimeouts::new(
3599 Elapsed::seconds(10),
3600 Elapsed::seconds(10),
3601 Elapsed::seconds(10),
3602 ),
3603 RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
3604 ports,
3605 )
3606 }
3607
3608 fn restart(&self) -> LifecycleLauncher {
3611 Self::launcher_over(
3612 self.policy.host_id,
3613 &self.app_paths,
3614 LifecyclePorts {
3615 store: Arc::clone(&self.store) as Arc<dyn Store>,
3616 github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
3617 packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
3618 processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
3619 clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
3620 demand: Arc::clone(&self.demand),
3621 delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
3622 events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
3623 reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
3624 },
3625 )
3626 }
3627
3628 async fn ready(&self) {
3629 self.launcher
3630 .recover_startup(std::slice::from_ref(&self.policy))
3631 .await
3632 .unwrap();
3633 }
3634
3635 async fn launch(&self) -> RunnerAttempt {
3636 self.launch_result().await.unwrap()
3637 }
3638
3639 async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
3640 let guard = self.allocation_lock.acquire().await.unwrap();
3641 self.launcher
3642 .launch(LaunchRequest {
3643 host: &self.host,
3644 policy: &self.policy,
3645 allocation_guard: &guard,
3646 })
3647 .await
3648 }
3649
3650 fn only_attempt(&self) -> RunnerAttempt {
3651 self.store.attempts().unwrap().into_iter().next().unwrap()
3652 }
3653 }
3654
3655 #[tokio::test]
3664 async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
3665 use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
3666
3667 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
3668 .with_host_runner_root();
3669 harness.ready().await;
3670
3671 let unusable = harness
3675 ._root
3676 .path()
3677 .join("absent")
3678 .join("deeper")
3679 .join("runners");
3680 let mut host = harness.host.clone();
3681 host.runner_root_override = Some(
3682 LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
3683 .expect("a local absolute host root"),
3684 );
3685 harness.store.put_host(&host).unwrap();
3686
3687 let failure = harness
3688 .launch_result()
3689 .await
3690 .expect_err("a root whose parents are missing cannot hold a runner");
3691 assert!(
3692 matches!(failure.reason, FailureReason::Other(_)),
3693 "{failure:?}"
3694 );
3695
3696 let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
3697 let refusal = refusals
3698 .first()
3699 .expect("the refusal reached the one surface that can hold it");
3700 assert_eq!(refusal.policy, harness.policy.id.to_string());
3701 assert_eq!(refusal.kind, "missing_parents");
3702 assert!(
3703 refusal.root.contains("runners") && refusal.detail.contains("runners"),
3704 "the directory must be named in full: {refusal:?}"
3705 );
3706
3707 harness.store.put_host(&harness.host).unwrap();
3710 harness.launch().await;
3711 assert!(
3712 runner_root_refusals(&harness.app_paths)
3713 .expect("readable")
3714 .is_empty(),
3715 "a successful placement clears that policy's record"
3716 );
3717
3718 clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
3719 .expect("cleanup");
3720 }
3721
3722 #[tokio::test]
3723 async fn a_job_walks_every_state_and_cleans_every_artifact() {
3724 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3725 harness.ready().await;
3726 let started = harness.launch().await;
3727 assert_eq!(started.state(), AttemptState::Starting);
3728 assert_eq!(read_runner_id(started.runtime_path()), Some(73));
3729
3730 harness
3731 .github
3732 .observe(GithubRunnerObservation::Registered { busy: false });
3733 harness.launcher.supervise(&harness.policy).await.unwrap();
3734 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
3735
3736 harness
3737 .github
3738 .observe(GithubRunnerObservation::Registered { busy: true });
3739 harness.launcher.supervise(&harness.policy).await.unwrap();
3740 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
3741
3742 harness.processes.finish_successfully();
3743 harness
3744 .github
3745 .observe(GithubRunnerObservation::NotRegistered);
3746 harness.launcher.supervise(&harness.policy).await.unwrap();
3747 let cleaned = harness.only_attempt();
3748 assert_eq!(cleaned.state(), AttemptState::Cleaned);
3749 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
3750 assert!(!started.runtime_path().exists());
3751 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
3752 assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
3753
3754 let states: Vec<_> = harness
3755 .events
3756 .events()
3757 .into_iter()
3758 .filter_map(|event| match event {
3759 AttemptEvent::State { state, .. } => Some(state),
3760 _ => None,
3761 })
3762 .collect();
3763 assert_eq!(
3764 states,
3765 vec![
3766 AttemptState::Allocated,
3767 AttemptState::JitReceived,
3768 AttemptState::Starting,
3769 AttemptState::Idle,
3770 AttemptState::Busy,
3771 AttemptState::Finished,
3772 AttemptState::Cleaned,
3773 ]
3774 );
3775 }
3776
3777 #[tokio::test]
3778 async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
3779 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3780 harness.ready().await;
3781 let started = harness.launch().await;
3782 harness
3783 .github
3784 .observe(GithubRunnerObservation::Registered { busy: false });
3785 harness.launcher.supervise(&harness.policy).await.unwrap();
3786 harness.clock.advance_secs(11);
3787 harness.processes.set_alive(false);
3788 harness
3789 .github
3790 .observe(GithubRunnerObservation::NotRegistered);
3791 harness.launcher.supervise(&harness.policy).await.unwrap();
3792
3793 let cleaned = harness.only_attempt();
3794 assert!(cleaned.outcome().unwrap().is_idle_exit());
3795 assert!(!cleaned.outcome().unwrap().is_failure());
3796 assert!(!started.runtime_path().exists());
3797 assert!(
3798 harness
3799 .reconcile_events
3800 .events()
3801 .iter()
3802 .any(|event| matches!(
3803 event,
3804 LifecycleEvent::AttemptCleaned {
3805 outcome: OutcomeKind::IdleExit,
3806 ..
3807 }
3808 ))
3809 );
3810 assert!(!harness.events.events().iter().any(|event| matches!(
3811 event,
3812 AttemptEvent::Concluded {
3813 outcome: OutcomeKind::Failed,
3814 ..
3815 }
3816 )));
3817 }
3818
3819 #[tokio::test]
3820 async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
3821 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3822 harness.processes.fail_spawns(2);
3823 harness.ready().await;
3824 let attempt = harness.launch().await;
3825 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
3826 assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
3827 let names: Vec<_> = fs::read_dir(attempt.runtime_path())
3828 .unwrap()
3829 .map(|entry| entry.unwrap().file_name())
3830 .collect();
3831 assert!(
3832 names.iter().all(|name| {
3833 !name
3834 .to_string_lossy()
3835 .starts_with(RestrictiveHandoff::NAME_PREFIX)
3836 }),
3837 "JIT artifact survived: {names:?}"
3838 );
3839 assert_eq!(
3840 *harness.delay.0.lock().unwrap(),
3841 vec![Duration::from_millis(10), Duration::from_millis(20)]
3842 );
3843 }
3844
3845 #[tokio::test]
3846 async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
3847 let gone = Harness::new(
3848 FakeGithubLifecycle::default().fail(false),
3849 Arc::new(FakeDemand::answering([false])),
3850 );
3851 gone.ready().await;
3852 assert!(gone.launch_result().await.is_err());
3853 assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
3854 assert!(gone.delay.0.lock().unwrap().is_empty());
3855
3856 let forbidden = Harness::new(
3857 FakeGithubLifecycle::default().fail(true),
3858 Arc::new(PersistentDemand),
3859 );
3860 forbidden.ready().await;
3861 assert!(forbidden.launch_result().await.is_err());
3862 assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
3863 assert!(forbidden.delay.0.lock().unwrap().is_empty());
3864 assert!(matches!(
3865 forbidden.only_attempt().outcome(),
3866 Some(AttemptOutcome::Failed {
3867 reason: FailureReason::Other(action)
3868 }) if action.contains("403")
3869 ));
3870
3871 let transient = Harness::new(
3872 FakeGithubLifecycle::default().fail(false).fail(false),
3873 Arc::new(PersistentDemand),
3874 );
3875 transient.ready().await;
3876 transient.launch().await;
3877 assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
3878 assert_eq!(
3879 *transient.delay.0.lock().unwrap(),
3880 vec![Duration::from_millis(10), Duration::from_millis(20)]
3881 );
3882 }
3883
3884 #[test]
3892 fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
3893 const MAX_PATH: usize = 260;
3894 let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
3896 let repo = "GitHub-Runner-Scaler-UI";
3900 let deepest = format!(
3901 r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
3902 "0".repeat(40)
3903 );
3904
3905 let name = workspace_name(AttemptId::new_random());
3906 assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
3907 assert!(
3908 name.chars().all(|c| c.is_ascii_hexdigit()),
3909 "a directory name must not carry the identifier's dashes: {name}"
3910 );
3911
3912 let full = format!(r"{root}\{name}\{deepest}");
3913 assert!(
3914 full.len() < MAX_PATH,
3915 "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
3916 full.len()
3917 );
3918
3919 let old = format!(
3922 r"{root}\{}\{}\{deepest}",
3923 PolicyId::new_random(),
3924 AttemptId::new_random()
3925 );
3926 assert!(
3927 old.len() > MAX_PATH,
3928 "the old layout is supposed to be the thing that did not fit: {} characters",
3929 old.len()
3930 );
3931 }
3932
3933 #[tokio::test]
3934 async fn two_attempts_never_share_a_workspace_even_after_failure() {
3935 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3936 harness.ready().await;
3937 let first = harness.launch().await;
3938 fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
3939 harness
3940 .github
3941 .observe(GithubRunnerObservation::Registered { busy: false });
3942 harness.launcher.supervise(&harness.policy).await.unwrap();
3943 harness.clock.advance_secs(11);
3944 harness.processes.set_alive(false);
3945 harness
3946 .github
3947 .observe(GithubRunnerObservation::NotRegistered);
3948 harness.launcher.supervise(&harness.policy).await.unwrap();
3949 assert!(!first.runtime_path().exists());
3950
3951 let second = harness.launch().await;
3952 assert_ne!(first.runtime_path(), second.runtime_path());
3953 assert!(!second.runtime_path().join("hostile-leftover").exists());
3954
3955 fs::write(
3956 second.runtime_path().join("hostile-on-failure"),
3957 b"second job",
3958 )
3959 .unwrap();
3960 harness.processes.set_alive(false);
3961 harness
3962 .github
3963 .observe(GithubRunnerObservation::NotRegistered);
3964 harness.launcher.supervise(&harness.policy).await.unwrap();
3965 assert!(
3966 !second.runtime_path().exists(),
3967 "failed workspace was retained"
3968 );
3969 }
3970
3971 #[tokio::test]
3972 async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
3973 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3974 harness.ready().await;
3975 let attempt = harness.launch().await;
3976
3977 harness
3981 .github
3982 .observe(GithubRunnerObservation::Registered { busy: false });
3983 harness.launcher.supervise(&harness.policy).await.unwrap();
3984 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
3985
3986 harness.clock.advance_secs(9);
3989 harness
3990 .github
3991 .observe(GithubRunnerObservation::Registered { busy: false });
3992 let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
3993 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
3994 assert!(none_yet.is_empty());
3995 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
3996
3997 harness.clock.advance_secs(1);
3999 harness
4000 .github
4001 .observe(GithubRunnerObservation::Registered { busy: false });
4002 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4003
4004 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
4005 assert_eq!(
4006 concluded.outcome(),
4007 Some(&AttemptOutcome::ExitedIdleWithoutWork),
4008 "a surplus runner did not fail; recording one as a failure sends an operator \
4009 hunting a fault that does not exist"
4010 );
4011 assert_eq!(concluded.state(), AttemptState::Cleaned);
4012 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
4013 assert!(!attempt.runtime_path().exists());
4014
4015 assert_eq!(
4018 *harness.github.deregistrations.lock().unwrap(),
4019 vec![73],
4020 "the attempt's own runner id, deleted exactly once"
4021 );
4022
4023 assert!(
4026 replacements.is_empty(),
4027 "a surplus exit must not request a replacement"
4028 );
4029 }
4030
4031 #[tokio::test]
4032 async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
4033 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4037 harness.ready().await;
4038 let attempt = harness.launch().await;
4039 harness
4040 .github
4041 .observe(GithubRunnerObservation::Registered { busy: false });
4042 harness.launcher.supervise(&harness.policy).await.unwrap();
4043
4044 harness
4045 .github
4046 .deregistration_fails
4047 .store(true, Ordering::SeqCst);
4048 harness.clock.advance_secs(11);
4049 harness
4050 .github
4051 .observe(GithubRunnerObservation::Registered { busy: false });
4052 harness.launcher.supervise(&harness.policy).await.unwrap();
4053
4054 assert_eq!(
4055 *harness.github.deregistrations.lock().unwrap(),
4056 vec![73],
4057 "the delete was attempted"
4058 );
4059 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
4060 assert_eq!(
4061 concluded.outcome(),
4062 Some(&AttemptOutcome::ExitedIdleWithoutWork),
4063 "the attempt concluded anyway"
4064 );
4065 assert_eq!(concluded.state(), AttemptState::Cleaned);
4066 assert!(!attempt.runtime_path().exists());
4067 }
4068
4069 #[tokio::test]
4070 async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
4071 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4072 harness.ready().await;
4073 let first = harness.launch().await;
4074 harness.processes.set_alive(false);
4075 harness
4076 .github
4077 .observe(GithubRunnerObservation::NotRegistered);
4078 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4079 let failed = harness.store.attempt(first.id).unwrap().unwrap();
4080 assert!(matches!(
4081 failed.outcome(),
4082 Some(AttemptOutcome::Failed {
4083 reason: FailureReason::ProcessExitedUnexpectedly
4084 })
4085 ));
4086 assert!(!first.runtime_path().exists());
4087
4088 assert_eq!(
4089 replacements,
4090 vec![ReplacementIntent {
4091 policy: harness.policy.id,
4092 previous_attempt: first.id,
4093 operation: "exit_before_acceptance_replacement",
4094 }]
4095 );
4096 assert_eq!(harness.store.attempts().unwrap().len(), 1);
4097 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
4098 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
4099 assert!(harness.delay.0.lock().unwrap().is_empty());
4100 }
4101
4102 #[tokio::test]
4103 async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
4104 let harness = Harness::new(
4105 FakeGithubLifecycle::default(),
4106 Arc::new(FakeDemand::answering([false])),
4107 );
4108 let id = AttemptId::new_random();
4109 let runtime = harness
4110 .launcher
4111 .app_paths
4112 .runtime_dir()
4113 .join(harness.policy.id.to_string())
4114 .join(id.to_string());
4115 fs::create_dir_all(&runtime).unwrap();
4116 let mut attempt =
4117 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4118 attempt.jit_received(harness.clock.now()).unwrap();
4119 harness.store.record_attempt(&attempt).unwrap();
4120 harness.clock.advance_secs(11);
4121 let replacements = harness
4122 .launcher
4123 .recover_startup(std::slice::from_ref(&harness.policy))
4124 .await
4125 .unwrap();
4126 assert_eq!(
4127 replacements,
4128 vec![ReplacementIntent {
4129 policy: harness.policy.id,
4130 previous_attempt: id,
4131 operation: "jit_expired_replacement",
4132 }]
4133 );
4134
4135 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4136 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4137 assert!(matches!(
4138 cleaned.outcome(),
4139 Some(AttemptOutcome::Failed {
4140 reason: FailureReason::JitExpired
4141 })
4142 ));
4143 assert!(!runtime.exists());
4144 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
4145 assert!(harness.delay.0.lock().unwrap().is_empty());
4146 }
4147
4148 #[tokio::test]
4149 async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
4150 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4151 let id = AttemptId::new_random();
4152 let runtime = harness
4153 .launcher
4154 .app_paths
4155 .runtime_dir()
4156 .join("expired-with-demand");
4157 fs::create_dir_all(&runtime).unwrap();
4158 let mut attempt =
4159 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4160 attempt.jit_received(harness.clock.now()).unwrap();
4161 harness.store.record_attempt(&attempt).unwrap();
4162 harness.clock.advance_secs(11);
4163 let replacements = harness
4164 .launcher
4165 .recover_startup(std::slice::from_ref(&harness.policy))
4166 .await
4167 .unwrap();
4168
4169 let attempts = harness.store.attempts().unwrap();
4170 assert_eq!(attempts.len(), 1);
4171 assert_eq!(
4172 attempts
4173 .iter()
4174 .find(|attempt| attempt.id == id)
4175 .unwrap()
4176 .state(),
4177 AttemptState::Cleaned
4178 );
4179 assert_eq!(
4180 replacements,
4181 vec![ReplacementIntent {
4182 policy: harness.policy.id,
4183 previous_attempt: id,
4184 operation: "jit_expired_replacement",
4185 }]
4186 );
4187 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
4188 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
4189 assert!(harness.delay.0.lock().unwrap().is_empty());
4190 }
4191
4192 #[tokio::test]
4193 async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
4194 let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4195 persistent.packages.fail_materializations(2);
4196 persistent.ready().await;
4197 persistent.launch().await;
4198 assert_eq!(
4199 persistent.packages.materializations.load(Ordering::SeqCst),
4200 3
4201 );
4202 assert_eq!(
4203 *persistent.delay.0.lock().unwrap(),
4204 vec![Duration::from_millis(10), Duration::from_millis(20)]
4205 );
4206
4207 let gone_before_wait = Harness::new(
4208 FakeGithubLifecycle::default(),
4209 Arc::new(FakeDemand::answering([false])),
4210 );
4211 gone_before_wait.packages.fail_materializations(3);
4212 gone_before_wait.ready().await;
4213 assert!(gone_before_wait.launch_result().await.is_err());
4214 assert_eq!(
4215 gone_before_wait
4216 .packages
4217 .materializations
4218 .load(Ordering::SeqCst),
4219 1
4220 );
4221 assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
4222
4223 let gone_during_wait = Harness::new(
4224 FakeGithubLifecycle::default(),
4225 Arc::new(FakeDemand::answering([true, false])),
4226 );
4227 gone_during_wait.packages.fail_materializations(3);
4228 gone_during_wait.ready().await;
4229 assert!(gone_during_wait.launch_result().await.is_err());
4230 assert_eq!(
4231 gone_during_wait
4232 .packages
4233 .materializations
4234 .load(Ordering::SeqCst),
4235 1
4236 );
4237 assert_eq!(
4238 *gone_during_wait.delay.0.lock().unwrap(),
4239 vec![Duration::from_millis(10)]
4240 );
4241 }
4242
4243 #[tokio::test]
4244 async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
4245 let harness = Harness::new(
4246 FakeGithubLifecycle::default(),
4247 Arc::new(FakeDemand::answering([true, false])),
4248 );
4249 harness.ready().await;
4250 let first = harness.launch().await;
4251 harness.processes.set_alive(false);
4252 harness
4253 .github
4254 .observe(GithubRunnerObservation::NotRegistered);
4255 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4256
4257 assert_eq!(harness.store.attempts().unwrap().len(), 1);
4258 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
4259 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
4260 assert!(harness.delay.0.lock().unwrap().is_empty());
4261 assert_eq!(
4262 replacements,
4263 vec![ReplacementIntent {
4264 policy: harness.policy.id,
4265 previous_attempt: first.id,
4266 operation: "exit_before_acceptance_replacement",
4267 }]
4268 );
4269 assert_eq!(
4270 harness.store.attempt(first.id).unwrap().unwrap().state(),
4271 AttemptState::Cleaned
4272 );
4273 }
4274
4275 #[tokio::test]
4276 async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
4277 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4278 let before = harness.launch_result().await;
4279 assert!(before.is_err());
4280 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
4281
4282 let id = AttemptId::new_random();
4283 let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
4284 fs::create_dir_all(&runtime).unwrap();
4285 let mut attempt =
4286 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
4287 attempt.jit_received(harness.clock.now()).unwrap();
4288 attempt.started(4242, harness.clock.now()).unwrap();
4289 harness.store.record_attempt(&attempt).unwrap();
4290 harness.processes.set_alive(true);
4291 harness
4292 .github
4293 .observe(GithubRunnerObservation::NotRegistered);
4294 let replacements = harness
4295 .launcher
4296 .recover_startup(std::slice::from_ref(&harness.policy))
4297 .await
4298 .unwrap();
4299 assert!(replacements.is_empty());
4300 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
4301 assert!(
4302 harness
4303 .events
4304 .events()
4305 .contains(&AttemptEvent::Adopted { attempt: id })
4306 );
4307 }
4308
4309 #[tokio::test]
4310 async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
4311 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4312 let id = AttemptId::new_random();
4313 let runtime = harness
4314 .launcher
4315 .app_paths
4316 .runtime_dir()
4317 .join("spawn-before-starting");
4318 fs::create_dir_all(&runtime).unwrap();
4319 let mut attempt =
4320 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4321 attempt.jit_received(harness.clock.now()).unwrap();
4322 harness.store.record_attempt(&attempt).unwrap();
4323 harness.processes.set_alive(true);
4324 harness
4325 .github
4326 .observe(GithubRunnerObservation::Registered { busy: true });
4327
4328 let replacements = harness
4329 .launcher
4330 .recover_startup(std::slice::from_ref(&harness.policy))
4331 .await
4332 .unwrap();
4333 assert!(replacements.is_empty());
4334 let recovered = harness.store.attempt(id).unwrap().unwrap();
4335 assert_eq!(recovered.state(), AttemptState::Busy);
4336 assert_eq!(recovered.process_id(), Some(4242));
4337 assert_eq!(recovered.github_runner_id(), Some(73));
4338 let events = harness.events.events();
4339 let starting = events
4340 .iter()
4341 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
4342 .unwrap();
4343 let busy = events
4344 .iter()
4345 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
4346 .unwrap();
4347 assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
4348
4349 harness.processes.finish_successfully();
4350 harness
4351 .github
4352 .observe(GithubRunnerObservation::NotRegistered);
4353 assert!(
4354 harness
4355 .launcher
4356 .supervise(&harness.policy)
4357 .await
4358 .unwrap()
4359 .is_empty()
4360 );
4361 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4362 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4363 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
4364 assert!(!runtime.exists());
4365 }
4366
4367 #[tokio::test]
4368 async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
4369 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4370 harness.processes.fail_spawn_with_live_child();
4371 harness.ready().await;
4372 assert!(harness.launch_result().await.is_err());
4373
4374 let attempt = harness.only_attempt();
4375 assert_eq!(attempt.state(), AttemptState::Starting);
4376 assert_eq!(attempt.process_id(), Some(4242));
4377 assert!(attempt.outcome().is_none());
4378 assert!(attempt.state().counts_against_capacity());
4379 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
4380 assert!(harness.delay.0.lock().unwrap().is_empty());
4381
4382 harness.processes.set_alive(false);
4383 harness
4384 .github
4385 .observe(GithubRunnerObservation::NotRegistered);
4386 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4387 assert_eq!(replacements.len(), 1);
4388 assert_eq!(
4389 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
4390 AttemptState::Cleaned
4391 );
4392 }
4393
4394 #[tokio::test]
4395 async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
4396 for sidecar_already_present in [false, true] {
4397 let harness = Harness::new(
4398 FakeGithubLifecycle::default(),
4399 Arc::new(FakeDemand::answering([false])),
4400 );
4401 let id = AttemptId::new_random();
4402 let runtime =
4403 harness
4404 .launcher
4405 .app_paths
4406 .runtime_dir()
4407 .join(if sidecar_already_present {
4408 "after-id-sidecar"
4409 } else {
4410 "before-id-sidecar"
4411 });
4412 fs::create_dir_all(&runtime).unwrap();
4413 let mut attempt =
4414 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4415 if sidecar_already_present {
4416 write_runner_id(&runtime, 73).unwrap();
4417 attempt.jit_received(harness.clock.now()).unwrap();
4418 }
4419 harness.store.record_attempt(&attempt).unwrap();
4420 harness.processes.set_alive(true);
4421 harness
4422 .github
4423 .observe(GithubRunnerObservation::Registered { busy: false });
4424 harness
4425 .launcher
4426 .recover_startup(std::slice::from_ref(&harness.policy))
4427 .await
4428 .unwrap();
4429
4430 assert_eq!(read_runner_id(&runtime), Some(73));
4431 assert!(
4432 harness
4433 .store
4434 .attempt(id)
4435 .unwrap()
4436 .unwrap()
4437 .outcome()
4438 .is_none()
4439 );
4440 let events = harness.events.events();
4441 let recovered = events.iter().position(|event| {
4442 matches!(
4443 event,
4444 AttemptEvent::RemoteIdentityRecovered {
4445 attempt,
4446 runner_id: 73
4447 } if *attempt == id
4448 )
4449 });
4450 assert_eq!(recovered.is_some(), !sidecar_already_present);
4451 if let Some(recovered) = recovered {
4452 let adopted = events
4453 .iter()
4454 .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
4455 .unwrap();
4456 assert!(
4457 recovered < adopted,
4458 "identity was not durable before adoption: {events:?}"
4459 );
4460 }
4461 assert!(runtime.exists());
4462 }
4463 }
4464
4465 #[tokio::test]
4466 async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
4467 let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4468 let unknown_attempt = RunnerAttempt::allocate(
4469 AttemptId::new_random(),
4470 PolicyId::from_u128(0xfeed),
4471 unknown
4472 .launcher
4473 .app_paths
4474 .runtime_dir()
4475 .join("unknown-policy"),
4476 unknown.clock.now(),
4477 );
4478 unknown.store.record_attempt(&unknown_attempt).unwrap();
4479 let expired_id = AttemptId::new_random();
4480 let expired_runtime = unknown
4481 .launcher
4482 .app_paths
4483 .runtime_dir()
4484 .join("expired-beside-unknown");
4485 fs::create_dir_all(&expired_runtime).unwrap();
4486 let mut expired = RunnerAttempt::allocate(
4487 expired_id,
4488 unknown.policy.id,
4489 expired_runtime,
4490 unknown.clock.now(),
4491 );
4492 expired.jit_received(unknown.clock.now()).unwrap();
4493 unknown.store.record_attempt(&expired).unwrap();
4494 unknown.clock.advance_secs(11);
4495 assert!(matches!(
4496 unknown
4497 .launcher
4498 .recover_startup(std::slice::from_ref(&unknown.policy))
4499 .await,
4500 Err(LifecycleError::RecoveryIncomplete)
4501 ));
4502 assert!(unknown.launch_result().await.is_err());
4503 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
4504 let recovered_policy = fixtures::policy()
4505 .id(PolicyId::from_u128(0xfeed))
4506 .repository("octo/repo")
4507 .autoscale("home", 2)
4508 .active()
4509 .build();
4510 let pending = unknown
4511 .launcher
4512 .recover_startup(&[unknown.policy.clone(), recovered_policy])
4513 .await
4514 .unwrap();
4515 assert_eq!(
4516 pending,
4517 vec![ReplacementIntent {
4518 policy: unknown.policy.id,
4519 previous_attempt: expired_id,
4520 operation: "jit_expired_replacement",
4521 }]
4522 );
4523 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
4524
4525 let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4526 let id = AttemptId::new_random();
4527 let runtime = unreachable
4528 .launcher
4529 .app_paths
4530 .runtime_dir()
4531 .join("unreachable");
4532 fs::create_dir_all(&runtime).unwrap();
4533 unreachable
4534 .store
4535 .record_attempt(&RunnerAttempt::allocate(
4536 id,
4537 unreachable.policy.id,
4538 runtime,
4539 unreachable.clock.now(),
4540 ))
4541 .unwrap();
4542 unreachable
4543 .github
4544 .observe(GithubRunnerObservation::Unreachable);
4545 assert!(matches!(
4546 unreachable
4547 .launcher
4548 .recover_startup(std::slice::from_ref(&unreachable.policy))
4549 .await,
4550 Err(LifecycleError::RecoveryIncomplete)
4551 ));
4552 assert!(unreachable.launch_result().await.is_err());
4553 assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
4554 }
4555
4556 #[tokio::test]
4557 async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
4558 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4559 let id = AttemptId::new_random();
4560 let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
4561 fs::create_dir_all(&runtime).unwrap();
4562 let mut attempt =
4563 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4564 attempt.jit_received(harness.clock.now()).unwrap();
4565 attempt.started(4242, harness.clock.now()).unwrap();
4566 attempt.assigned_job(73, harness.clock.now()).unwrap();
4567 harness.store.record_attempt(&attempt).unwrap();
4568 harness.processes.set_alive(false);
4569 harness
4570 .github
4571 .observe(GithubRunnerObservation::NotRegistered);
4572 harness
4573 .launcher
4574 .recover_startup(std::slice::from_ref(&harness.policy))
4575 .await
4576 .unwrap();
4577 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4578 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4579 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
4580 assert!(!runtime.exists());
4581 }
4582
4583 #[tokio::test]
4584 async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
4585 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4586 harness.ready().await;
4587 let id = AttemptId::new_random();
4588 let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
4589 fs::create_dir_all(&runtime).unwrap();
4590 let mut attempt =
4591 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
4592 attempt.jit_received(harness.clock.now()).unwrap();
4593 attempt.started(4242, harness.clock.now()).unwrap();
4594 harness.store.record_attempt(&attempt).unwrap();
4595 harness.clock.advance_secs(11);
4596 harness.processes.set_alive(true);
4597 harness
4598 .github
4599 .observe(GithubRunnerObservation::NotRegistered);
4600 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4601 assert_eq!(
4602 replacements,
4603 vec![ReplacementIntent {
4604 policy: harness.policy.id,
4605 previous_attempt: id,
4606 operation: "registration_timeout_replacement",
4607 }]
4608 );
4609
4610 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
4611 assert!(!harness.processes.alive.load(Ordering::SeqCst));
4612 let actions = harness.processes.actions.lock().unwrap().clone();
4613 let intent = actions
4614 .iter()
4615 .position(|action| *action == "terminate_intent")
4616 .unwrap();
4617 let signal = actions
4618 .iter()
4619 .position(|action| *action == "terminate")
4620 .unwrap();
4621 assert!(
4622 intent < signal,
4623 "intent was not durable before signal: {actions:?}"
4624 );
4625
4626 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4627 assert!(matches!(
4628 cleaned.outcome(),
4629 Some(AttemptOutcome::Failed {
4630 reason: FailureReason::TerminatedAfterRegistrationTimeout
4631 })
4632 ));
4633 let events = harness.events.events();
4634 let intent = events
4635 .iter()
4636 .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
4637 .unwrap();
4638 let stopped = events
4639 .iter()
4640 .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
4641 .unwrap();
4642 let concluded = events
4643 .iter()
4644 .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
4645 .unwrap();
4646 assert!(intent < stopped && stopped < concluded, "{events:?}");
4647 }
4648
4649 #[tokio::test]
4650 async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
4651 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4652 let id = AttemptId::new_random();
4653 let runtime = harness
4654 .launcher
4655 .app_paths
4656 .runtime_dir()
4657 .join("timeout-after-crash");
4658 fs::create_dir_all(&runtime).unwrap();
4659 let mut attempt =
4660 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
4661 attempt.jit_received(harness.clock.now()).unwrap();
4662 attempt.started(4242, harness.clock.now()).unwrap();
4663 harness.store.record_attempt(&attempt).unwrap();
4664 harness.processes.intent.store(true, Ordering::SeqCst);
4665 harness.processes.set_alive(false);
4666 harness
4667 .github
4668 .observe(GithubRunnerObservation::NotRegistered);
4669
4670 let replacements = harness
4671 .launcher
4672 .recover_startup(std::slice::from_ref(&harness.policy))
4673 .await
4674 .unwrap();
4675 assert_eq!(
4676 replacements,
4677 vec![ReplacementIntent {
4678 policy: harness.policy.id,
4679 previous_attempt: id,
4680 operation: "registration_timeout_replacement",
4681 }]
4682 );
4683 let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
4684 .await
4685 .unwrap();
4686 assert_eq!(consumed, replacements);
4687 assert!(
4688 RunnerLauncher::supervise(&harness.launcher, &harness.policy)
4689 .await
4690 .unwrap()
4691 .is_empty(),
4692 "startup replacement evidence must be consumed exactly once by e1"
4693 );
4694 assert!(matches!(
4695 harness.store.attempt(id).unwrap().unwrap().outcome(),
4696 Some(AttemptOutcome::Failed {
4697 reason: FailureReason::TerminatedAfterRegistrationTimeout
4698 })
4699 ));
4700 }
4701
4702 #[tokio::test]
4703 async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
4704 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4705 let id = AttemptId::new_random();
4706 let runtime = harness
4707 .launcher
4708 .app_paths
4709 .runtime_dir()
4710 .join("timeout-sync-failure");
4711 fs::create_dir_all(&runtime).unwrap();
4712 let mut attempt =
4713 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4714 attempt.jit_received(harness.clock.now()).unwrap();
4715 attempt.started(4242, harness.clock.now()).unwrap();
4716 harness.store.record_attempt(&attempt).unwrap();
4717 harness.clock.advance_secs(11);
4718 harness.processes.set_alive(true);
4719 harness.processes.fail_intent();
4720 harness
4721 .github
4722 .observe(GithubRunnerObservation::NotRegistered);
4723
4724 assert!(
4725 harness
4726 .launcher
4727 .recover_startup(std::slice::from_ref(&harness.policy))
4728 .await
4729 .is_err()
4730 );
4731 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
4732 assert!(harness.processes.alive.load(Ordering::SeqCst));
4733 assert_eq!(
4734 harness.store.attempt(id).unwrap().unwrap().state(),
4735 AttemptState::Starting
4736 );
4737 assert!(!harness.events.events().iter().any(|event| matches!(
4738 event,
4739 AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
4740 if *attempt == id
4741 )));
4742 }
4743
4744 #[tokio::test]
4745 async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
4746 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4747 harness.ready().await;
4748 let attempt = harness.launch().await;
4749 harness
4750 .github
4751 .observe(GithubRunnerObservation::Registered { busy: false });
4752 harness.launcher.supervise(&harness.policy).await.unwrap();
4753 harness.clock.advance_secs(11);
4754 harness.processes.set_alive(false);
4755 harness
4756 .github
4757 .observe(GithubRunnerObservation::NotRegistered);
4758 harness.launcher.supervise(&harness.policy).await.unwrap();
4759 let diagnostic = fs::read_to_string(
4760 harness
4761 .launcher
4762 .diagnostics_root
4763 .join(format!("{}.log", attempt.id)),
4764 )
4765 .unwrap();
4766 assert!(diagnostic.contains("exited_idle_without_work"));
4767 assert!(!diagnostic.contains(JIT));
4768 assert!(!diagnostic.contains("ghp_"));
4769 assert!(!attempt.runtime_path().exists());
4770 }
4771
4772 #[test]
4773 fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
4774 let root = tempfile::tempdir().unwrap();
4775 let policy = fixtures::policy()
4776 .repository("octo/repo")
4777 .autoscale("home", 1)
4778 .active()
4779 .build();
4780 let runtime = root.path().join("successful");
4781 fs::create_dir_all(&runtime).unwrap();
4782 let processes = NativeProcesses::new();
4783 let config = EncodedJitConfig::new(JIT);
4784 let handoff =
4785 RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
4786 .unwrap();
4787 let mut child = native_inspection_spec()
4788 .spawn_runner_with_handoff(&handoff)
4789 .expect("native child starts");
4790 let pid = child.pid();
4791 handoff.delete().unwrap();
4792 let command_line = native_command_line(pid);
4793 assert!(
4794 !command_line.contains(JIT),
4795 "the encoded JIT configuration appeared in the native process listing"
4796 );
4797 assert_no_jit_file(&runtime);
4798 child
4799 .stop(Duration::from_secs(1))
4800 .expect("native child stops");
4801
4802 let failed_runtime = root.path().join("failed");
4803 fs::create_dir_all(&failed_runtime).unwrap();
4804 let failed = RunnerAttempt::allocate(
4805 AttemptId::new_random(),
4806 policy.id,
4807 &failed_runtime,
4808 FakeClock::default().now(),
4809 );
4810 assert!(
4811 processes
4812 .spawn(&failed, &EncodedJitConfig::new(JIT))
4813 .is_err(),
4814 "a runtime with no runner executable must fail"
4815 );
4816 assert_no_jit_file(&failed_runtime);
4817 processes
4818 .record_terminate_intent(&failed)
4819 .expect("the intent file and its directory entry are durably synced");
4820 assert_eq!(
4821 fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
4822 b"registration-timeout\n"
4823 );
4824 }
4825
4826 #[test]
4827 fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
4828 let root = tempfile::tempdir().unwrap();
4829 let policy = fixtures::policy()
4830 .repository("octo/repo")
4831 .autoscale("home", 1)
4832 .active()
4833 .build();
4834 let processes = NativeProcesses::new();
4835 processes.use_long_lived_test_listener();
4836 for (index, boundary) in [
4837 PostSpawnBoundary::HandoffDelete,
4838 PostSpawnBoundary::IdentitySerialize,
4839 PostSpawnBoundary::IdentityWrite,
4840 PostSpawnBoundary::ChildMapInsert,
4841 ]
4842 .into_iter()
4843 .enumerate()
4844 {
4845 let runtime = root.path().join(format!("post-spawn-{index}"));
4846 let bin = runtime.join("bin");
4847 fs::create_dir_all(&bin).unwrap();
4848 #[cfg(windows)]
4849 let listener = bin.join("Runner.Listener.exe");
4850 #[cfg(not(windows))]
4851 let listener = bin.join("Runner.Listener");
4852 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
4853 let attempt = RunnerAttempt::allocate(
4854 AttemptId::new_random(),
4855 policy.id,
4856 &runtime,
4857 FakeClock::default().now(),
4858 );
4859 processes.fail_post_spawn_at(boundary);
4860 let failure = processes
4861 .spawn(&attempt, &EncodedJitConfig::new(JIT))
4862 .expect_err("fault must cross the post-spawn cleanup path");
4863 assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
4864 assert!(
4865 !processes.is_alive(&attempt).unwrap(),
4866 "{boundary:?} left a child"
4867 );
4868 assert!(!NativeProcesses::identity_path(&attempt).exists());
4869 assert_no_jit_file(&runtime);
4870 }
4871 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
4872
4873 let runtime = root.path().join("identity-and-stop-fail");
4874 let bin = runtime.join("bin");
4875 fs::create_dir_all(&bin).unwrap();
4876 #[cfg(windows)]
4877 let listener = bin.join("Runner.Listener.exe");
4878 #[cfg(not(windows))]
4879 let listener = bin.join("Runner.Listener");
4880 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
4881 let attempt = RunnerAttempt::allocate(
4882 AttemptId::new_random(),
4883 policy.id,
4884 &runtime,
4885 FakeClock::default().now(),
4886 );
4887 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
4891 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
4892 processes.fail_next_post_spawn_stop();
4893 let failure = processes
4894 .spawn(&attempt, &EncodedJitConfig::new(JIT))
4895 .expect_err("the identity boundary must fail closed");
4896 assert!(failure.live_pid.is_some());
4897 assert_long_lived_listener_ready(&processes, &attempt);
4898 assert!(processes.is_alive(&attempt).unwrap());
4899 assert!(!NativeProcesses::identity_path(&attempt).exists());
4900 assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
4901 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
4902 processes.terminate(&attempt).unwrap();
4903
4904 let runtime = root.path().join("persistent-stop-and-identity-failures");
4905 let bin = runtime.join("bin");
4906 fs::create_dir_all(&bin).unwrap();
4907 #[cfg(windows)]
4908 let listener = bin.join("Runner.Listener.exe");
4909 #[cfg(not(windows))]
4910 let listener = bin.join("Runner.Listener");
4911 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
4912 let mut unresolved = RunnerAttempt::allocate(
4913 AttemptId::new_random(),
4914 policy.id,
4915 &runtime,
4916 FakeClock::default().now(),
4917 );
4918 for _ in 0..3 {
4919 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
4920 }
4921 processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
4922 let failure = processes
4923 .spawn(&unresolved, &EncodedJitConfig::new(JIT))
4924 .expect_err("bounded cleanup must return even when every stop errors");
4925 let pid = failure
4926 .live_pid
4927 .expect("the owned child remains supervised in this invocation");
4928 assert!(matches!(failure.reason, FailureReason::Other(_)));
4929 assert_long_lived_listener_ready(&processes, &unresolved);
4930 unresolved.jit_received(FakeClock::default().now()).unwrap();
4931 unresolved.started(pid, FakeClock::default().now()).unwrap();
4932 let journal = SqliteStore::open_in_memory().unwrap();
4933 journal.record_attempt(&unresolved).unwrap();
4934 let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
4935 assert_eq!(recovered.process_id(), Some(pid));
4936 assert_eq!(recovered.state(), AttemptState::Starting);
4937 assert!(processes.is_alive(&unresolved).unwrap());
4938 assert!(!NativeProcesses::identity_path(&unresolved).exists());
4939 assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
4940 assert_eq!(
4941 fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
4942 pid.to_string(),
4943 "bounded cleanup must leave durable unresolved-process evidence before returning"
4944 );
4945 assert!(
4946 NativeProcesses::new().is_alive(&recovered).is_err(),
4947 "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
4948 );
4949 processes.terminate(&unresolved).unwrap();
4950
4951 let runtime = root.path().join("post-spawn-stop-failed");
4952 let bin = runtime.join("bin");
4953 fs::create_dir_all(&bin).unwrap();
4954 #[cfg(windows)]
4955 let listener = bin.join("Runner.Listener.exe");
4956 #[cfg(not(windows))]
4957 let listener = bin.join("Runner.Listener");
4958 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
4959 let attempt = RunnerAttempt::allocate(
4960 AttemptId::new_random(),
4961 policy.id,
4962 &runtime,
4963 FakeClock::default().now(),
4964 );
4965 processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
4966 processes.fail_next_post_spawn_stop();
4967 let failure = processes
4968 .spawn(&attempt, &EncodedJitConfig::new(JIT))
4969 .expect_err("the injected stop failure must preserve supervision");
4970 let live_pid = failure
4971 .live_pid
4972 .expect("live PID is returned to the journal");
4973 assert!(!failure.retryable);
4974 assert_long_lived_listener_ready(&processes, &attempt);
4975 assert!(NativeProcesses::identity_path(&attempt).is_file());
4976 assert_eq!(
4977 NativeProcesses::read_identity(&attempt)
4978 .unwrap()
4979 .unwrap()
4980 .pid(),
4981 live_pid
4982 );
4983 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
4984 processes.terminate(&attempt).unwrap();
4985 }
4986
4987 #[test]
4988 #[ignore = "spawned only as the platform-stable native listener fixture"]
4989 fn long_lived_native_listener_helper() {
4990 let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
4991 .map(PathBuf::from)
4992 .expect("the parent supplies the readiness path");
4993 fs::write(ready, b"ready\n").expect("the listener publishes readiness");
4994 std::thread::sleep(Duration::from_secs(30));
4995 }
4996
4997 fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
4998 let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
4999 let deadline = std::time::Instant::now() + Duration::from_secs(5);
5000 loop {
5001 if ready.is_file() {
5002 assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
5003 return;
5004 }
5005 assert!(
5006 processes.is_alive(attempt).unwrap(),
5007 "the native listener exited before publishing readiness"
5008 );
5009 assert!(
5010 std::time::Instant::now() < deadline,
5011 "the native listener stayed alive but never published readiness"
5012 );
5013 std::thread::sleep(Duration::from_millis(10));
5014 }
5015 }
5016
5017 #[tokio::test]
5018 async fn every_production_launch_prunes_under_the_same_allocation_guard() {
5019 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5020 harness.ready().await;
5021 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
5022 harness.launch().await;
5023 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
5024 assert_eq!(
5025 *harness.packages.prune_currents.lock().unwrap(),
5026 vec![harness.packages.version.clone()],
5027 "the leased current version is an exclusion, never the prune target"
5028 );
5029 }
5030
5031 fn assert_no_jit_file(runtime: &Path) {
5032 for entry in fs::read_dir(runtime).unwrap() {
5033 let path = entry.unwrap().path();
5034 if path.is_file() {
5035 let bytes = fs::read(&path).unwrap();
5036 assert!(
5037 !bytes
5038 .windows(JIT.len())
5039 .any(|window| window == JIT.as_bytes()),
5040 "a JIT payload survived in a runtime file"
5041 );
5042 }
5043 }
5044 }
5045
5046 #[test]
5047 fn production_listener_command_uses_the_supported_jit_contract() {
5048 let runtime = Path::new("runtime");
5049 let spec = runner_listener_spec(PathBuf::from("Runner.Listener"), runtime);
5050 let arguments: Vec<_> = spec
5051 .arguments()
5052 .iter()
5053 .map(|argument| argument.to_string_lossy().into_owned())
5054 .collect();
5055
5056 assert_eq!(arguments, ["run"]);
5057 assert!(
5058 !arguments
5059 .iter()
5060 .any(|argument| argument == "--jit-config-file"),
5061 "the obsolete file option would be rejected by Runner.Listener 2.336.0"
5062 );
5063 }
5064
5065 #[cfg(windows)]
5066 fn native_inspection_spec() -> SpawnSpec {
5067 SpawnSpec::new("powershell.exe").args([
5068 "-NoProfile",
5069 "-NonInteractive",
5070 "-Command",
5071 "Start-Sleep -Seconds 30",
5072 ])
5073 }
5074
5075 #[cfg(unix)]
5076 fn native_inspection_spec() -> SpawnSpec {
5077 SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
5078 }
5079
5080 #[cfg(windows)]
5081 fn native_command_line(pid: u32) -> String {
5082 let output = std::process::Command::new("powershell.exe")
5083 .args([
5084 "-NoProfile",
5085 "-NonInteractive",
5086 "-Command",
5087 &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
5088 ])
5089 .output()
5090 .expect("PowerShell can inspect the native child");
5091 assert!(output.status.success(), "native process inspection failed");
5092 String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
5093 }
5094
5095 #[cfg(target_os = "linux")]
5096 fn native_command_line(pid: u32) -> String {
5097 fs::read(format!("/proc/{pid}/cmdline"))
5098 .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
5099 .expect("/proc exposes the native child command line")
5100 }
5101
5102 #[cfg(target_os = "macos")]
5103 fn native_command_line(pid: u32) -> String {
5104 let output = std::process::Command::new("ps")
5105 .args(["-o", "command=", "-p", &pid.to_string()])
5106 .output()
5107 .expect("ps can inspect the native child");
5108 assert!(output.status.success(), "native process inspection failed");
5109 String::from_utf8(output.stdout).expect("the command line is UTF-8")
5110 }
5111
5112 #[tokio::test]
5115 async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
5116 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5117 .with_persistent_workspace(2);
5118 harness.github.watch_journal(Arc::clone(&harness.store));
5119 harness.ready().await;
5120
5121 let attempt = harness.launch().await;
5122
5123 assert_eq!(
5124 attempt.workspace(),
5125 AttemptWorkspace::persistent_slot(nz(1)),
5126 "the lowest free slot is leased"
5127 );
5128 assert_eq!(attempt.runtime_path(), harness.slot_path(1));
5129 assert!(attempt.holds_slot_lease());
5130 assert_eq!(
5132 harness.attempt(attempt.id).runtime_path(),
5133 harness.slot_path(1)
5134 );
5135
5136 let facts = harness.github.registration_facts();
5140 assert_eq!(facts.len(), 1);
5141 assert_eq!(
5142 facts[0].leased_slots,
5143 vec![1],
5144 "the lease was journalled first"
5145 );
5146 assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
5147 }
5148
5149 #[tokio::test]
5150 async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
5151 let harness = Harness::new(
5152 FakeGithubLifecycle::default().fail(true),
5153 Arc::new(PersistentDemand),
5154 )
5155 .with_persistent_workspace(2);
5156 harness.ready().await;
5157
5158 harness.launch_result().await.unwrap_err();
5160 let first = harness.store.attempts().unwrap().remove(0);
5161 assert_eq!(first.state(), AttemptState::Failed);
5162 assert!(
5163 !first.state().counts_against_capacity(),
5164 "a concluded attempt is invisible to host capacity"
5165 );
5166 assert!(
5167 first.holds_slot_lease(),
5168 "and still owns its directory, so its slot is not free"
5169 );
5170
5171 let second = harness.launch().await;
5172 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
5173 assert_eq!(second.runtime_path(), harness.slot_path(2));
5174 assert_eq!(
5175 harness
5176 .store
5177 .slot_leases_for_policy(harness.policy.id)
5178 .unwrap()
5179 .len(),
5180 2
5181 );
5182 }
5183
5184 #[tokio::test]
5185 async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
5186 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5187 .with_persistent_workspace(1);
5188 harness.ready().await;
5189
5190 let first = harness.launch().await;
5191 assert_eq!(first.runtime_path(), harness.slot_path(1));
5192
5193 let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
5195 fs::create_dir_all(&checkout).unwrap();
5196 fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
5197
5198 harness.cleanup_retaining_work(first.id).await;
5199
5200 let second = harness.launch().await;
5201 assert_ne!(second.id, first.id);
5202 assert_eq!(
5203 second.workspace(),
5204 AttemptWorkspace::persistent_slot(nz(1)),
5205 "a released slot is leased again rather than skipped"
5206 );
5207 assert_eq!(
5208 second.runtime_path(),
5209 first.runtime_path(),
5210 "the same slot is the same exact path"
5211 );
5212 assert_eq!(
5213 fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
5214 "from the first job",
5215 "the retained job workspace survived the second allocation"
5216 );
5217 assert!(harness.slot_path(1).join("runner-package").exists());
5219 }
5220
5221 #[tokio::test]
5222 async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
5223 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5224 .with_persistent_workspace(2);
5225 harness.ready().await;
5226
5227 let first = harness.launch().await;
5228 let second = harness.launch().await;
5229 assert_eq!(second.runtime_path(), harness.slot_path(2));
5230 let kept = harness
5231 .slot_path(2)
5232 .join(DEFAULT_WORK_FOLDER)
5233 .join("kept.txt");
5234 fs::create_dir_all(kept.parent().unwrap()).unwrap();
5235 fs::write(&kept, b"s2 was here").unwrap();
5236 harness.cleanup_retaining_work(second.id).await;
5237
5238 harness.policy.set_max_capacity(nz(1)).unwrap();
5240 let refusal = harness.launch_result().await.unwrap_err().to_string();
5241 assert!(
5242 refusal.contains("s1 to s1"),
5243 "the refusal names the ceiling it reached: {refusal}"
5244 );
5245 assert!(
5246 harness.slot_path(2).exists() && kept.exists(),
5247 "lowering capacity deletes nothing; the higher slot is merely unusable"
5248 );
5249
5250 harness.policy.set_max_capacity(nz(2)).unwrap();
5252 let third = harness.launch().await;
5253 assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
5254 assert_eq!(third.runtime_path(), harness.slot_path(2));
5255 assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
5256 assert!(first.holds_slot_lease(), "s1 was never disturbed");
5257 }
5258
5259 #[tokio::test]
5260 async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
5261 for policy in [
5262 fixtures::policy()
5263 .organization("octo")
5264 .autoscale("home", 2)
5265 .active()
5266 .build(),
5267 fixtures::policy()
5268 .repository("octo/repo")
5269 .autoscale("home", 2)
5270 .active()
5271 .build(),
5272 ] {
5273 let mut harness =
5274 Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5275 .with_host_runner_root();
5276 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
5277 harness.policy = policy;
5278 harness.ready().await;
5279
5280 let attempt = harness.launch().await;
5281 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
5282 assert_eq!(attempt.workspace().slot_number(), None);
5283 assert!(!attempt.holds_slot_lease());
5284 assert_eq!(
5285 attempt.runtime_path().parent().unwrap(),
5286 harness.host_root(),
5287 "a disposable attempt is a child of the host root, never of a slot"
5288 );
5289 assert!(
5290 harness
5291 .store
5292 .slot_leases_for_policy(harness.policy.id)
5293 .unwrap()
5294 .is_empty()
5295 );
5296 }
5297 }
5298
5299 #[tokio::test]
5300 async fn two_concurrent_allocations_never_share_a_slot() {
5301 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5302 .with_persistent_workspace(2);
5303 harness.github.watch_journal(Arc::clone(&harness.store));
5304 harness.ready().await;
5305
5306 let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
5310 let first = first.unwrap();
5311 let second = second.unwrap();
5312
5313 let slots: BTreeSet<u16> = [&first, &second]
5314 .iter()
5315 .map(|attempt| {
5316 attempt
5317 .workspace()
5318 .slot_number()
5319 .expect("a persistent attempt leases a slot")
5320 })
5321 .collect();
5322 assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
5323 assert_ne!(first.runtime_path(), second.runtime_path());
5324 assert_eq!(
5325 harness
5326 .store
5327 .slot_leases_for_policy(harness.policy.id)
5328 .unwrap()
5329 .len(),
5330 2
5331 );
5332
5333 let facts = harness.github.registration_facts();
5338 assert_eq!(facts.len(), 2);
5339 for fact in facts {
5340 let attempt = [&first, &second]
5341 .into_iter()
5342 .find(|attempt| runner_name(attempt.id) == fact.runner_name)
5343 .expect("every registration belongs to one of the two attempts");
5344 let slot = attempt
5345 .workspace()
5346 .slot_number()
5347 .expect("a persistent attempt leases a slot");
5348 assert!(
5349 fact.leased_slots.contains(&slot),
5350 "a JIT request never precedes its own lease: s{slot} not in {:?}",
5351 fact.leased_slots
5352 );
5353 assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
5354 }
5355 }
5356
5357 #[tokio::test]
5358 async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
5359 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5360 .with_persistent_workspace(2);
5361 harness.ready().await;
5362 let first = harness.launch().await;
5363
5364 let clash = RunnerAttempt::allocate_in(
5368 AttemptId::new_random(),
5369 harness.policy.id,
5370 first.runtime_path(),
5371 AttemptWorkspace::persistent_slot(nz(1)),
5372 harness.clock.now(),
5373 );
5374 assert!(matches!(
5375 harness.store.record_attempt(&clash).unwrap_err(),
5376 StoreError::SlotAlreadyLeased { slot: 1, .. }
5377 ));
5378
5379 let error = harness.launcher.record_allocation(&clash).unwrap_err();
5382 let rendered = error.to_string();
5383 assert!(rendered.contains("slot s1"), "{rendered}");
5384 assert!(rendered.contains("nothing was written"), "{rendered}");
5385 assert_eq!(
5386 harness.store.attempts().unwrap().len(),
5387 1,
5388 "the losing allocator journalled nothing"
5389 );
5390 }
5391
5392 #[test]
5393 fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
5394 let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
5395 slots
5396 .iter()
5397 .map(|slot| {
5398 RunnerAttempt::allocate_in(
5399 AttemptId::new_random(),
5400 fixtures::POLICY_ID,
5401 format!("/srv/rman/acme/s{slot}"),
5402 AttemptWorkspace::persistent_slot(nz(*slot)),
5403 fixtures::created_at(),
5404 )
5405 })
5406 .collect()
5407 };
5408
5409 assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
5410 assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
5411 assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
5413 assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
5415 assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
5416 let ephemeral = vec![RunnerAttempt::allocate(
5418 AttemptId::new_random(),
5419 fixtures::POLICY_ID,
5420 "/srv/rman/host/abc",
5421 fixtures::created_at(),
5422 )];
5423 assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
5424 }
5425
5426 #[test]
5427 fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
5428 let root = tempfile::tempdir().unwrap();
5429 let slot = root.path().join("s1");
5430 fs::create_dir(&slot).unwrap();
5431 accept_reusable_slot(&slot).expect("an empty slot is reusable");
5432
5433 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
5434 accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
5435
5436 fs::create_dir(slot.join("bin")).unwrap();
5439 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
5440 let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
5441 assert!(refusal.contains("bin"), "{refusal}");
5442 assert!(refusal.contains(".github-runner-id"), "{refusal}");
5443
5444 let file_work = root.path().join("s2");
5446 fs::create_dir(&file_work).unwrap();
5447 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
5448 assert!(accept_reusable_slot(&file_work).is_err());
5449 }
5450
5451 #[cfg(unix)]
5452 #[test]
5453 fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
5454 let root = tempfile::tempdir().unwrap();
5458 let elsewhere = root.path().join("elsewhere");
5459 fs::create_dir(&elsewhere).unwrap();
5460
5461 let slot = root.path().join("s1");
5462 fs::create_dir(&slot).unwrap();
5463 std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
5464 assert!(accept_reusable_slot(&slot).is_err());
5465
5466 let linked_slot = root.path().join("s2");
5467 std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
5468 assert!(create_or_validate_slot(&linked_slot).is_err());
5469 }
5470
5471 #[test]
5472 fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
5473 let root = tempfile::tempdir().unwrap();
5474 let occupied = root.path().join("s1");
5475 fs::write(&occupied, b"an operator's file").unwrap();
5476 let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
5477 assert!(refusal.contains("is not a directory"), "{refusal}");
5478 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
5479
5480 let fresh = root.path().join("s2");
5481 create_or_validate_slot(&fresh).expect("a missing slot is created");
5482 assert!(fresh.is_dir());
5483 create_or_validate_slot(&fresh).expect("an existing directory is accepted");
5484 }
5485
5486 #[test]
5487 fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
5488 assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
5489 assert!(!is_work_folder(OsStr::new("_work2")));
5490 assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
5494 }
5495
5496 #[test]
5497 fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
5498 let root = tempfile::tempdir().unwrap();
5499 let package = root.path().join("package");
5500 fs::create_dir_all(package.join("bin")).unwrap();
5501 fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
5502 fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
5504
5505 let slot = root.path().join("s1");
5506 let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
5507 fs::create_dir_all(&retained).unwrap();
5508 fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
5509
5510 copy_package_tree(&package, &slot).expect("the package lays out around `_work`");
5511 assert!(slot.join("bin").join("Runner.Listener").exists());
5512 assert!(
5513 slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
5514 "the guard is top-level only"
5515 );
5516 assert_eq!(
5517 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
5518 "from the first job"
5519 );
5520
5521 fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
5523 let error = copy_package_tree(&package, &slot).unwrap_err();
5524 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
5525 assert_eq!(
5526 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
5527 "from the first job"
5528 );
5529 }
5530
5531 #[test]
5532 fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
5533 let root = tempfile::tempdir().unwrap();
5534
5535 let slot = root.path().join("s1");
5536 let retained = slot.join(DEFAULT_WORK_FOLDER);
5537 fs::create_dir_all(retained.join("repo")).unwrap();
5538 fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
5539 fs::create_dir_all(slot.join("bin")).unwrap();
5540 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
5541 let persistent = RunnerAttempt::allocate_in(
5542 AttemptId::new_random(),
5543 fixtures::POLICY_ID,
5544 &slot,
5545 AttemptWorkspace::persistent_slot(nz(1)),
5546 fixtures::created_at(),
5547 );
5548
5549 remove_materialized_package(&persistent).unwrap();
5550 assert!(slot.is_dir(), "the slot itself is not removed");
5551 assert!(!slot.join("bin").exists());
5552 assert!(!slot.join(".github-runner-id").exists());
5553 assert_eq!(
5554 fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
5555 "kept"
5556 );
5557
5558 let disposable_path = root.path().join("abcdef012345");
5559 fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
5560 let disposable = RunnerAttempt::allocate(
5561 AttemptId::new_random(),
5562 fixtures::POLICY_ID,
5563 &disposable_path,
5564 fixtures::created_at(),
5565 );
5566 remove_materialized_package(&disposable).unwrap();
5567 assert!(
5568 !disposable_path.exists(),
5569 "a disposable directory is still removed whole"
5570 );
5571 }
5572
5573 fn litter_the_slot(slot: &Path) {
5583 for directory in ["bin", "externals", "_diag"] {
5584 fs::create_dir_all(slot.join(directory)).unwrap();
5585 }
5586 fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
5587 for file in SENSITIVE_SLOT_ENTRIES
5588 .iter()
5589 .filter(|entry| !slot.join(entry).is_dir())
5590 {
5591 fs::write(slot.join(file), b"runner state").unwrap();
5592 }
5593 fs::write(
5595 slot.join(format!(
5596 "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
5597 RestrictiveHandoff::NAME_PREFIX
5598 )),
5599 JIT.as_bytes(),
5600 )
5601 .unwrap();
5602 }
5603
5604 fn retain_under_work(slot: &Path) -> PathBuf {
5606 let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
5607 fs::create_dir_all(&checkout).unwrap();
5608 let marker = checkout.join("build-output.bin");
5609 fs::write(&marker, RETAINED).unwrap();
5610 marker
5611 }
5612
5613 const RETAINED: &str = "a Git-ignored build output the next job reuses";
5615
5616 fn entries_of(directory: &Path) -> Vec<String> {
5618 let mut names: Vec<String> = fs::read_dir(directory)
5619 .unwrap()
5620 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
5621 .collect();
5622 names.sort();
5623 names
5624 }
5625
5626 fn only_the_job_workspace() -> Vec<String> {
5628 vec![DEFAULT_WORK_FOLDER.to_owned()]
5629 }
5630
5631 struct BlockedDeletion {
5647 directory: PathBuf,
5648 #[cfg(windows)]
5649 _handle: fs::File,
5650 }
5651
5652 impl BlockedDeletion {
5653 const HELD: &'static str = "held-open";
5654
5655 fn inject(directory: &Path) -> Option<Self> {
5658 #[cfg(unix)]
5659 if !Self::refusal_is_possible() {
5660 return None;
5661 }
5662 fs::create_dir_all(directory).unwrap();
5663 fs::write(
5664 directory.join(Self::HELD),
5665 b"a file the scrub cannot remove",
5666 )
5667 .unwrap();
5668 #[cfg(windows)]
5669 let handle = {
5670 use std::os::windows::fs::OpenOptionsExt;
5671
5672 fs::OpenOptions::new()
5673 .read(true)
5674 .share_mode(0)
5675 .open(directory.join(Self::HELD))
5676 .expect("the blocking handle opens")
5677 };
5678 #[cfg(unix)]
5679 Self::set_mode(directory, 0o555);
5680 Some(Self {
5681 directory: directory.to_path_buf(),
5682 #[cfg(windows)]
5683 _handle: handle,
5684 })
5685 }
5686
5687 fn release(self) {
5688 drop(self);
5689 }
5690
5691 #[cfg(unix)]
5692 fn refusal_is_possible() -> bool {
5693 let probe = tempfile::tempdir().unwrap();
5694 let directory = probe.path().join("probe");
5695 fs::create_dir(&directory).unwrap();
5696 fs::write(directory.join("file"), b"probe").unwrap();
5697 Self::set_mode(&directory, 0o555);
5698 let refused = fs::remove_dir_all(&directory).is_err();
5699 Self::set_mode(&directory, 0o755);
5700 refused
5701 }
5702
5703 #[cfg(unix)]
5704 fn set_mode(directory: &Path, mode: u32) {
5705 use std::os::unix::fs::PermissionsExt;
5706
5707 let mut permissions = fs::metadata(directory).unwrap().permissions();
5708 permissions.set_mode(mode);
5709 fs::set_permissions(directory, permissions).unwrap();
5710 }
5711 }
5712
5713 impl Drop for BlockedDeletion {
5714 fn drop(&mut self) {
5715 #[cfg(unix)]
5716 Self::set_mode(&self.directory, 0o755);
5717 #[cfg(not(unix))]
5718 let _ = &self.directory;
5719 }
5720 }
5721
5722 #[tokio::test]
5723 async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
5724 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5725 .with_persistent_workspace(1);
5726 harness.ready().await;
5727
5728 let first = harness.launch().await;
5729 let slot = harness.slot_path(1);
5730 assert_eq!(first.runtime_path(), slot);
5731 assert_eq!(
5732 read_runner_id(&slot),
5733 Some(73),
5734 "the attempt registered, so its identity is on disk"
5735 );
5736 let marker = retain_under_work(&slot);
5737 litter_the_slot(&slot);
5738
5739 harness.cleanup_retaining_work(first.id).await;
5740
5741 assert_eq!(entries_of(&slot), only_the_job_workspace());
5744 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
5745 assert_eq!(
5746 read_runner_id(&slot),
5747 None,
5748 "the first attempt's registration identity is gone before the second starts"
5749 );
5750 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
5751 assert!(!harness.attempt(first.id).holds_slot_lease());
5752
5753 let second = harness.launch().await;
5754 assert_ne!(second.id, first.id);
5755 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
5756 assert_eq!(
5757 second.runtime_path(),
5758 slot,
5759 "the same slot, so the same retained `_work`"
5760 );
5761 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
5762 }
5763
5764 #[tokio::test]
5765 async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
5766 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5767 .with_persistent_workspace(1);
5768 harness.ready().await;
5769 let attempt = harness.launch().await;
5770 let slot = harness.slot_path(1);
5771 let marker = retain_under_work(&slot);
5772 litter_the_slot(&slot);
5773 harness.conclude(attempt.id);
5774
5775 harness
5781 .store
5782 .remove_policy(harness.policy.id, harness.policy.revision())
5783 .unwrap();
5784 assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
5785
5786 harness
5787 .launcher
5788 .clean(attempt.id)
5789 .await
5790 .expect("journal facts alone are enough to clean the slot");
5791
5792 assert_eq!(entries_of(&slot), only_the_job_workspace());
5793 assert!(marker.exists());
5794 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
5795 }
5796
5797 #[tokio::test]
5798 async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
5799 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5800 .with_persistent_workspace(2);
5801 harness.ready().await;
5802 let first = harness.launch().await;
5803 let slot = harness.slot_path(1);
5804 let marker = retain_under_work(&slot);
5805 litter_the_slot(&slot);
5806 harness.conclude(first.id);
5807
5808 let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
5809 eprintln!(
5810 "skipped: this account cannot be refused a deletion, so no partial deletion can \
5811 be injected"
5812 );
5813 return;
5814 };
5815
5816 let refusal = harness
5817 .launcher
5818 .clean(first.id)
5819 .await
5820 .expect_err("a deletion that failed may not report a cleaned slot");
5821 let rendered = refusal.reason.to_string();
5822 assert!(rendered.contains("could not be removed"), "{rendered}");
5823
5824 let held = harness.attempt(first.id);
5825 assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
5826 assert!(held.holds_slot_lease(), "so the slot is still leased");
5827 assert!(
5828 !held.state().counts_against_capacity(),
5829 "and a concluded attempt still costs the host no capacity"
5830 );
5831
5832 let restarted = harness.restart();
5837 restarted
5838 .recover_startup(std::slice::from_ref(&harness.policy))
5839 .await
5840 .expect("one quarantined slot does not stop the host recovering");
5841 assert_eq!(
5842 harness.attempt(first.id).state(),
5843 AttemptState::Failed,
5844 "the quarantine survived the restart"
5845 );
5846 assert!(
5847 harness
5848 .reconcile_events
5849 .events()
5850 .iter()
5851 .any(|event| matches!(
5852 event,
5853 LifecycleEvent::AttemptCleanFailed {
5854 reason: "slot_entry_could_not_be_removed",
5855 ..
5856 }
5857 )),
5858 "the refusal is reported rather than retried in silence"
5859 );
5860
5861 let guard = harness.allocation_lock.acquire().await.unwrap();
5864 let second = restarted
5865 .launch(LaunchRequest {
5866 host: &harness.host,
5867 policy: &harness.policy,
5868 allocation_guard: &guard,
5869 })
5870 .await
5871 .expect("the host can still launch");
5872 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
5873 drop(guard);
5874
5875 block.release();
5878 restarted
5879 .clean(first.id)
5880 .await
5881 .expect("the retried cleanup completes");
5882 assert_eq!(entries_of(&slot), only_the_job_workspace());
5883 assert!(marker.exists());
5884 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
5885 }
5886
5887 #[tokio::test]
5898 async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
5899 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5900 harness.ready().await;
5901 let attempt = harness.launch().await;
5902 let runtime = attempt.runtime_path().to_path_buf();
5903 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
5904 harness.conclude(attempt.id);
5905
5906 let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
5907 eprintln!(
5908 "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
5909 );
5910 return;
5911 };
5912
5913 let refusal = harness
5914 .launcher
5915 .clean(attempt.id)
5916 .await
5917 .expect_err("a deletion that failed may not report a removed workspace");
5918 let rendered = refusal.reason.to_string();
5919 assert!(
5920 rendered.contains("could not be removed"),
5921 "the refusal names what happened: {rendered}"
5922 );
5923 assert_ne!(
5924 harness.attempt(attempt.id).state(),
5925 AttemptState::Cleaned,
5926 "an attempt whose directory is still on disk is not cleaned"
5927 );
5928 assert!(
5929 runtime.is_dir(),
5930 "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
5931 );
5932
5933 block.release();
5936 harness
5937 .launcher
5938 .clean(attempt.id)
5939 .await
5940 .expect("the retried cleanup completes");
5941 assert!(!runtime.exists(), "the whole attempt directory goes");
5942 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
5943 }
5944
5945 #[tokio::test]
5946 async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
5947 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5948 .with_persistent_workspace(1);
5949 harness.ready().await;
5950 let first = harness.launch().await;
5951 let slot = harness.slot_path(1);
5952 let marker = retain_under_work(&slot);
5953 harness.cleanup_retaining_work(first.id).await;
5954
5955 harness
5959 .policy
5960 .set_workspace_policy(WorkspacePolicy::Ephemeral)
5961 .unwrap();
5962
5963 let second = harness.launch().await;
5964 assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
5965 assert_eq!(
5966 second.runtime_path().parent().unwrap(),
5967 harness.host_root(),
5968 "a disposable attempt is a child of the host root"
5969 );
5970 assert!(slot.is_dir(), "the old slot is left where it stands");
5971 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
5972
5973 harness.conclude(second.id);
5976 harness.launcher.clean(second.id).await.unwrap();
5977 assert!(!second.runtime_path().exists());
5978 assert!(marker.exists());
5979 }
5980
5981 #[tokio::test]
5982 async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
5983 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5984 .with_persistent_workspace(1);
5985 harness.ready().await;
5986 let slot = harness.slot_path(1);
5987 fs::create_dir_all(&slot).unwrap();
5988 let marker = retain_under_work(&slot);
5989 litter_the_slot(&slot);
5990
5991 let id = AttemptId::new_random();
5992 let mut attempt = RunnerAttempt::allocate_in(
5993 id,
5994 harness.policy.id,
5995 &slot,
5996 AttemptWorkspace::persistent_slot(nz(1)),
5997 harness.clock.now(),
5998 );
5999 attempt.jit_received(harness.clock.now()).unwrap();
6000 attempt.started(4242, harness.clock.now()).unwrap();
6001 harness.store.record_attempt(&attempt).unwrap();
6002 harness.clock.advance_secs(11);
6003 harness.processes.set_alive(true);
6004 harness
6005 .github
6006 .observe(GithubRunnerObservation::NotRegistered);
6007
6008 harness.launcher.supervise(&harness.policy).await.unwrap();
6009
6010 let actions = harness.processes.actions.lock().unwrap().clone();
6014 let intent = actions
6015 .iter()
6016 .position(|action| *action == "terminate_intent")
6017 .unwrap();
6018 let signal = actions
6019 .iter()
6020 .position(|action| *action == "terminate")
6021 .unwrap();
6022 assert!(intent < signal, "{actions:?}");
6023 assert!(!harness.processes.alive.load(Ordering::SeqCst));
6024
6025 let cleaned = harness.attempt(id);
6026 assert_eq!(cleaned.state(), AttemptState::Cleaned);
6027 assert!(matches!(
6028 cleaned.outcome(),
6029 Some(AttemptOutcome::Failed {
6030 reason: FailureReason::TerminatedAfterRegistrationTimeout
6031 })
6032 ));
6033 assert_eq!(entries_of(&slot), only_the_job_workspace());
6034 assert!(marker.exists());
6035 }
6036
6037 #[test]
6038 fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
6039 let root = tempfile::tempdir().unwrap();
6040 let slot = root.path().join("s1");
6041 fs::create_dir(&slot).unwrap();
6042 let marker = retain_under_work(&slot);
6043 litter_the_slot(&slot);
6044 fs::write(slot.join("runner-package"), b"verified").unwrap();
6045
6046 scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
6047 verify_slot_scrubbed(&slot).expect("and proves it afterwards");
6048
6049 assert_eq!(entries_of(&slot), only_the_job_workspace());
6050 assert!(marker.exists());
6051 }
6052
6053 #[test]
6054 fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
6055 let slot = Path::new("/runners/s1");
6056
6057 let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
6060 assert!(counted.contains("2 entries other than"), "{counted}");
6061 assert!(counted.contains("including `bin`"), "{counted}");
6062 assert_eq!(
6063 residue_detail(slot, 1, &[]),
6064 format!(
6065 "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
6066 slot.display()
6067 )
6068 );
6069
6070 let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
6075 assert!(!raced.contains('0'), "{raced}");
6076 assert!(raced.contains("reported nothing but"), "{raced}");
6077 assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
6078 }
6079
6080 #[test]
6081 fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
6082 let root = tempfile::tempdir().unwrap();
6083 let slot = root.path().join("s1");
6084 fs::create_dir(&slot).unwrap();
6085 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6086 verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
6087
6088 for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
6092 fs::write(slot.join(survivor), b"left behind").unwrap();
6093 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
6094 assert_eq!(quarantine.refusal, SlotRefusal::Residue);
6095 assert!(
6096 quarantine.detail.contains(&format!("`{survivor}`")),
6097 "{quarantine}"
6098 );
6099 fs::remove_file(slot.join(survivor)).unwrap();
6100 }
6101
6102 let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
6105 fs::write(&handoff, JIT.as_bytes()).unwrap();
6106 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
6107 assert!(
6108 quarantine.detail.contains("an encoded JIT handoff"),
6109 "{quarantine}"
6110 );
6111 assert!(!quarantine.detail.contains(JIT), "{quarantine}");
6112 fs::remove_file(&handoff).unwrap();
6113
6114 fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
6118 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
6119 assert!(
6120 quarantine.detail.contains("1 entry other than"),
6121 "{quarantine}"
6122 );
6123 assert!(
6124 !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
6125 "{quarantine}"
6126 );
6127 }
6128
6129 #[test]
6130 fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
6131 let root = tempfile::tempdir().unwrap();
6132 let configured =
6133 LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
6134 let slot = configured.as_path().join("s1");
6135 fs::create_dir(&slot).unwrap();
6136
6137 verify_journalled_slot(&slot, nz(1), Some(&configured))
6138 .expect("the journalled slot agrees");
6139 verify_journalled_slot(&slot, nz(1), None)
6140 .expect("and a policy that is gone removes a check, not the ability to clean");
6141
6142 assert_eq!(
6145 verify_journalled_slot(&slot, nz(2), None)
6146 .unwrap_err()
6147 .refusal,
6148 SlotRefusal::NotTheJournalledSlot
6149 );
6150 for stray in ["s1/nested", "not-a-slot", "s01"] {
6151 let path = configured.as_path().join(stray);
6152 assert_eq!(
6153 verify_journalled_slot(&path, nz(1), None)
6154 .unwrap_err()
6155 .refusal,
6156 SlotRefusal::NotTheJournalledSlot,
6157 "{}",
6158 path.display()
6159 );
6160 }
6161
6162 let elsewhere = tempfile::tempdir().unwrap();
6165 let other =
6166 LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
6167 assert_eq!(
6168 verify_journalled_slot(&slot, nz(1), Some(&other))
6169 .unwrap_err()
6170 .refusal,
6171 SlotRefusal::PolicyRootDisagrees
6172 );
6173 }
6174
6175 #[cfg(unix)]
6176 #[test]
6177 fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
6178 let root = tempfile::tempdir().unwrap();
6182 let outside = root.path().join("operator-data");
6183 fs::create_dir(&outside).unwrap();
6184 let sentinel = outside.join("do-not-delete.txt");
6185 fs::write(
6186 &sentinel,
6187 b"an operator's data, outside every approved root",
6188 )
6189 .unwrap();
6190
6191 let slot = root.path().join("s1");
6192 fs::create_dir(&slot).unwrap();
6193 fs::create_dir(slot.join("bin")).unwrap();
6194 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6195
6196 let quarantine = scrub_slot_entries(&slot).unwrap_err();
6197 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
6198 assert!(
6199 sentinel.exists(),
6200 "the deletion followed the link out of the slot"
6201 );
6202 assert!(outside.is_dir());
6203 assert!(
6204 slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
6205 "the substituted link is left for the operator, never unlinked as if it were ours"
6206 );
6207
6208 let file_work = root.path().join("s2");
6212 fs::create_dir(&file_work).unwrap();
6213 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
6214 assert_eq!(
6215 scrub_slot_entries(&file_work).unwrap_err().refusal,
6216 SlotRefusal::WorkNotADirectory
6217 );
6218 }
6219
6220 #[cfg(unix)]
6221 #[test]
6222 fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
6223 let root = tempfile::tempdir().unwrap();
6224 let outside = root.path().join("operator-data");
6225 fs::create_dir(&outside).unwrap();
6226 let sentinel = outside.join("do-not-delete.txt");
6227 fs::write(
6228 &sentinel,
6229 b"an operator's data, outside every approved root",
6230 )
6231 .unwrap();
6232
6233 let inside = root.path().join("inside");
6236 fs::create_dir(&inside).unwrap();
6237 let slot = inside.join("s1");
6238 std::os::unix::fs::symlink(&outside, &slot).unwrap();
6239
6240 assert_eq!(
6241 verify_journalled_slot(&slot, nz(1), None)
6242 .unwrap_err()
6243 .refusal,
6244 SlotRefusal::Containment
6245 );
6246 assert!(sentinel.exists());
6247 assert!(
6248 slot.symlink_metadata().is_ok(),
6249 "the link is left for the operator rather than removed as if it were ours"
6250 );
6251 }
6252
6253 #[cfg(windows)]
6263 #[test]
6264 fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
6265 let root = tempfile::tempdir().unwrap();
6266 let outside = root.path().join("operator-data");
6267 fs::create_dir(&outside).unwrap();
6268 let sentinel = outside.join("do-not-delete.txt");
6269 fs::write(
6270 &sentinel,
6271 b"an operator's data, outside every approved root",
6272 )
6273 .unwrap();
6274
6275 let inside = root.path().join("inside");
6278 fs::create_dir(&inside).unwrap();
6279 let slot = inside.join("s1");
6280 let Some(()) = plant_junction(&slot, &outside) else {
6281 eprintln!("skipped: this machine would not create a directory junction");
6282 return;
6283 };
6284
6285 assert_eq!(
6286 verify_journalled_slot(&slot, nz(1), None)
6287 .unwrap_err()
6288 .refusal,
6289 SlotRefusal::Containment
6290 );
6291 assert!(
6292 sentinel.exists(),
6293 "the refusal resolved the junction and reached the operator's data"
6294 );
6295 assert!(
6296 slot.symlink_metadata().is_ok(),
6297 "the junction is left for the operator rather than removed as if it were ours"
6298 );
6299 }
6300
6301 #[cfg(windows)]
6310 fn plant_junction(link: &Path, target: &Path) -> Option<()> {
6311 let made = std::process::Command::new("cmd")
6312 .arg("/C")
6313 .arg("mklink")
6314 .arg("/J")
6315 .arg(link)
6316 .arg(target)
6317 .output()
6318 .ok()?;
6319 (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
6320 }
6321
6322 #[cfg(windows)]
6323 #[test]
6324 fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
6325 let root = tempfile::tempdir().unwrap();
6326 let outside = root.path().join("operator-data");
6327 fs::create_dir(&outside).unwrap();
6328 let sentinel = outside.join("do-not-delete.txt");
6329 fs::write(
6330 &sentinel,
6331 b"an operator's data, outside every approved root",
6332 )
6333 .unwrap();
6334
6335 let slot = root.path().join("s1");
6336 fs::create_dir(&slot).unwrap();
6337 fs::create_dir(slot.join("bin")).unwrap();
6338 let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
6339 eprintln!("skipped: this machine would not create a directory junction");
6340 return;
6341 };
6342
6343 let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6347 assert!(is_link_like(&work), "a junction is a reparse point");
6348 let quarantine = scrub_slot_entries(&slot).unwrap_err();
6349 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
6350 assert!(
6351 sentinel.exists(),
6352 "the deletion followed the junction out of the slot"
6353 );
6354 assert!(outside.is_dir());
6355
6356 let elsewhere = root.path().join("s2");
6359 fs::create_dir(&elsewhere).unwrap();
6360 fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
6361 if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
6362 scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
6363 verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
6364 assert!(sentinel.exists(), "the junction was followed, not unlinked");
6365 assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
6366 }
6367 }
6368
6369 #[cfg(unix)]
6370 #[tokio::test]
6371 async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
6372 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6373 .with_persistent_workspace(2);
6374 harness.ready().await;
6375 let first = harness.launch().await;
6376 let slot = harness.slot_path(1);
6377 harness.conclude(first.id);
6378
6379 let outside = harness._root.path().join("operator-data");
6380 fs::create_dir_all(&outside).unwrap();
6381 let sentinel = outside.join("do-not-delete.txt");
6382 fs::write(&sentinel, b"outside every approved root").unwrap();
6383 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6384
6385 harness
6386 .launcher
6387 .clean(first.id)
6388 .await
6389 .expect_err("a slot whose `_work` was substituted is quarantined");
6390 assert!(sentinel.exists());
6391
6392 let held = harness.attempt(first.id);
6393 assert_eq!(held.state(), AttemptState::Failed);
6394 assert!(held.holds_slot_lease());
6395
6396 let second = harness.launch().await;
6398 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
6399 }
6400
6401 #[test]
6402 fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
6403 let root = tempfile::tempdir().unwrap();
6404 let occupied = root.path().join("s1");
6405 fs::write(&occupied, b"an operator's file").unwrap();
6406 verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
6409 assert_eq!(
6410 slot_is_present(&occupied).unwrap_err().refusal,
6411 SlotRefusal::SlotNotADirectory
6412 );
6413 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
6414
6415 assert!(!slot_is_present(&root.path().join("s2")).unwrap());
6418 let present = root.path().join("s3");
6419 fs::create_dir(&present).unwrap();
6420 assert!(slot_is_present(&present).unwrap());
6421 }
6422
6423 #[test]
6424 fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
6425 let root = tempfile::tempdir().unwrap();
6426
6427 let disposable = root.path().join("abcdef012345");
6431 fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
6432 let ephemeral = RunnerAttempt::allocate(
6433 AttemptId::new_random(),
6434 fixtures::POLICY_ID,
6435 &disposable,
6436 fixtures::created_at(),
6437 );
6438 remove_materialized_package(&ephemeral).unwrap();
6439 assert!(!disposable.exists());
6440
6441 let slot = root.path().join("s1");
6443 fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
6444 fs::create_dir_all(slot.join("bin")).unwrap();
6445 let persistent = RunnerAttempt::allocate_in(
6446 AttemptId::new_random(),
6447 fixtures::POLICY_ID,
6448 &slot,
6449 AttemptWorkspace::persistent_slot(nz(1)),
6450 fixtures::created_at(),
6451 );
6452 remove_materialized_package(&persistent).unwrap();
6453 assert_eq!(entries_of(&slot), only_the_job_workspace());
6454 assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
6455 }
6456
6457 #[test]
6458 fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
6459 let refusals = [
6460 SlotRefusal::NotTheJournalledSlot,
6461 SlotRefusal::PolicyRootDisagrees,
6462 SlotRefusal::Containment,
6463 SlotRefusal::SlotNotADirectory,
6464 SlotRefusal::Enumeration,
6465 SlotRefusal::WorkNotADirectory,
6466 SlotRefusal::Deletion,
6467 SlotRefusal::Residue,
6468 ];
6469 let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
6470 assert_eq!(
6471 classes.len(),
6472 refusals.len(),
6473 "an event class shared by two refusals tells an operator less than it appears to"
6474 );
6475 for refusal in refusals {
6476 assert!(
6479 refusal
6480 .class()
6481 .chars()
6482 .all(|c| c.is_ascii_lowercase() || c == '_'),
6483 "{}",
6484 refusal.class()
6485 );
6486 assert!(
6487 refusal.remediation().contains("slot lease"),
6488 "every refusal has to say the lease is still held: {}",
6489 refusal.class()
6490 );
6491 }
6492 }
6493}