1#[cfg(test)]
12use std::collections::VecDeque;
13use std::collections::{BTreeMap, BTreeSet};
14use std::ffi::OsStr;
15use std::fmt;
16use std::fs;
17use std::io::Write;
18use std::num::NonZeroU16;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21use std::time::Duration;
22
23use async_trait::async_trait;
24use runner_manager_domain::attempt::{
25 AttemptOutcome, AttemptState, FailureReason, GithubRunnerObservation, RecoveryDecision,
26 RecoveryObservation, RecoveryTimeouts, RunnerAttempt, authorize, recovery_decision,
27};
28use runner_manager_domain::model::{AttemptId, Clock, HostId, PolicyId, ScaleTarget};
29use runner_manager_domain::path::LocalAbsolutePath;
30use runner_manager_domain::policy::ScalePolicy;
31use runner_manager_domain::store::{Store, StoreError};
32use runner_manager_domain::workspace::{AttemptWorkspace, WorkspacePolicy};
33use runner_manager_github::jit::{
34 DEFAULT_WORK_FOLDER, EncodedJitConfig, JitError, JitGateway, JitRegistration, JitRunnerRequest,
35};
36use runner_manager_github::rest::{CancelToken, InventoryGateway};
37use runner_manager_platform::process::{
38 Adoption, ChildProcess, ProcessIdentity, RestrictiveHandoff, SpawnSpec, Termination,
39};
40use runner_manager_platform::runner_root::{
41 self, RootOwner, RootPreflight, RunnerRootError, default_runner_root,
42};
43use secrecy::SecretString;
44
45use crate::package::{PackageCache, PackageError, RunnerVersion};
46use crate::reconcile::{
47 AllocationGuard, EventSink, LaunchFailure, LaunchRequest, LifecycleEvent, OutcomeKind,
48 ReplacementIntent, RunnerLauncher,
49};
50
51const IDENTITY_FILE: &str = ".runner-process.json";
52const FALLBACK_IDENTITY_FILE: &str = ".runner-process.recovery.json";
53const UNRESOLVED_PROCESS_FILE: &str = ".runner-process.unresolved";
54const RUNNER_ID_FILE: &str = ".github-runner-id";
55const TERMINATE_INTENT_FILE: &str = ".terminate-registration-timeout";
56const MAX_POST_SPAWN_STOP_ATTEMPTS: usize = 3;
57
58const SENSITIVE_SLOT_ENTRIES: &[&str] = &[
71 "bin",
73 "externals",
74 "run.sh",
75 "run.cmd",
76 "config.sh",
77 "config.cmd",
78 ".runner",
81 ".credentials",
82 ".credentials_rsaparams",
83 ".env",
84 ".path",
85 "_diag",
86 IDENTITY_FILE,
88 FALLBACK_IDENTITY_FILE,
89 UNRESOLVED_PROCESS_FILE,
90 RUNNER_ID_FILE,
91 TERMINATE_INTENT_FILE,
92];
93#[cfg(test)]
94const TEST_LISTENER_READY: &str = ".test-listener-ready";
95
96fn runner_listener_spec(program: PathBuf, runtime: &Path) -> SpawnSpec {
101 let tmp = runtime.join("tmp");
102 let _ = std::fs::create_dir_all(&tmp);
103 SpawnSpec::new(program)
104 .arg("run")
105 .working_dir(runtime)
106 .env("TMPDIR", &tmp)
107 .env("TEMP", &tmp)
108 .env("TMP", &tmp)
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct RetryPolicy {
114 pub max_attempts: u32,
115 pub initial: Duration,
116 pub maximum: Duration,
117}
118
119impl RetryPolicy {
120 #[must_use]
121 pub const fn bounded(max_attempts: u32, initial: Duration, maximum: Duration) -> Self {
122 Self {
123 max_attempts,
124 initial,
125 maximum,
126 }
127 }
128
129 fn delay(self, failure_index: u32) -> Duration {
130 let shift = failure_index.saturating_sub(1).min(31);
131 self.initial
132 .saturating_mul(1_u32 << shift)
133 .min(self.maximum)
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum AttemptEvent {
141 State {
142 attempt: AttemptId,
143 state: AttemptState,
144 },
145 Retry {
146 attempt: AttemptId,
147 operation: &'static str,
148 delay: Duration,
149 },
150 Adopted {
151 attempt: AttemptId,
152 },
153 RemoteIdentityRecovered {
154 attempt: AttemptId,
155 runner_id: u64,
156 },
157 TerminateIntent {
158 attempt: AttemptId,
159 },
160 Terminated {
161 attempt: AttemptId,
162 },
163 Deregistered {
167 attempt: AttemptId,
168 runner_id: u64,
169 },
170 Concluded {
171 attempt: AttemptId,
172 outcome: OutcomeKind,
173 },
174 Cleaned {
175 attempt: AttemptId,
176 outcome: OutcomeKind,
177 },
178}
179
180pub trait AttemptEventSink: fmt::Debug + Send + Sync {
181 fn emit(&self, event: AttemptEvent);
182}
183
184#[derive(Debug, Default)]
185pub struct AttemptEventLog(Mutex<Vec<AttemptEvent>>);
186
187impl AttemptEventLog {
188 #[must_use]
189 pub fn events(&self) -> Vec<AttemptEvent> {
190 self.0
191 .lock()
192 .map(|events| events.clone())
193 .unwrap_or_default()
194 }
195}
196
197impl AttemptEventSink for AttemptEventLog {
198 fn emit(&self, event: AttemptEvent) {
199 if let Ok(mut events) = self.0.lock() {
200 events.push(event);
201 }
202 }
203}
204
205#[derive(Debug, Clone, Copy, Default)]
206pub struct NoAttemptEvents;
207
208impl AttemptEventSink for NoAttemptEvents {
209 fn emit(&self, _event: AttemptEvent) {}
210}
211
212#[async_trait]
214pub trait DemandPersistence: fmt::Debug + Send + Sync {
215 async fn persists(&self, policy: PolicyId) -> bool;
216}
217
218#[derive(Debug, Clone, Copy, Default)]
219pub struct PersistentDemand;
220
221#[async_trait]
222impl DemandPersistence for PersistentDemand {
223 async fn persists(&self, _policy: PolicyId) -> bool {
224 true
225 }
226}
227
228#[async_trait]
229pub trait RetryDelay: fmt::Debug + Send + Sync {
230 async fn wait(&self, duration: Duration);
231}
232
233#[derive(Debug, Clone, Copy, Default)]
234pub struct TokioRetryDelay;
235
236#[async_trait]
237impl RetryDelay for TokioRetryDelay {
238 async fn wait(&self, duration: Duration) {
239 tokio::time::sleep(duration).await;
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct JitRequestFailure {
245 pub terminal: bool,
246 pub reason: FailureReason,
247 pub retry_after: Option<Duration>,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub struct LifecycleGithubObservation {
255 pub status: GithubRunnerObservation,
256 pub runner_id: Option<u64>,
257}
258
259impl LifecycleGithubObservation {
260 #[must_use]
261 pub const fn unreachable() -> Self {
262 Self {
263 status: GithubRunnerObservation::Unreachable,
264 runner_id: None,
265 }
266 }
267
268 #[must_use]
269 pub const fn not_registered() -> Self {
270 Self {
271 status: GithubRunnerObservation::NotRegistered,
272 runner_id: None,
273 }
274 }
275
276 #[must_use]
277 pub const fn registered(runner_id: u64, busy: bool) -> Self {
278 Self {
279 status: GithubRunnerObservation::Registered { busy },
280 runner_id: Some(runner_id),
281 }
282 }
283}
284
285#[async_trait]
288pub trait LifecycleGithub: fmt::Debug + Send + Sync {
289 async fn register(
290 &self,
291 target: &ScaleTarget,
292 request: &JitRunnerRequest,
293 cancel: &CancelToken,
294 ) -> Result<JitRegistration, JitRequestFailure>;
295
296 async fn observe(
297 &self,
298 target: &ScaleTarget,
299 attempt: AttemptId,
300 cancel: &CancelToken,
301 ) -> LifecycleGithubObservation;
302
303 async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool;
310}
311
312#[async_trait]
313impl<T> LifecycleGithub for T
314where
315 T: JitGateway + InventoryGateway + fmt::Debug + Send + Sync,
316{
317 async fn register(
318 &self,
319 target: &ScaleTarget,
320 request: &JitRunnerRequest,
321 cancel: &CancelToken,
322 ) -> Result<JitRegistration, JitRequestFailure> {
323 self.generate_jit_config(target, request, cancel)
324 .await
325 .map_err(|error| {
326 let reason = if matches!(&error, JitError::Forbidden { .. }) {
327 FailureReason::Other(
328 "GitHub refused JIT registration with 403; check the App's runner permission and runner-group access"
329 .into(),
330 )
331 } else {
332 FailureReason::JitRequestFailed
333 };
334 JitRequestFailure {
335 terminal: error.is_terminal(),
336 reason,
337 retry_after: error
338 .rate_limited()
339 .map(|limit| limit.delay_from(self.now())),
340 }
341 })
342 }
343
344 async fn observe(
345 &self,
346 target: &ScaleTarget,
347 attempt: AttemptId,
348 cancel: &CancelToken,
349 ) -> LifecycleGithubObservation {
350 let expected_name = runner_name(attempt);
351 match self.list_runners(target, cancel).await {
352 Ok(inventory) => inventory
353 .runners()
354 .iter()
355 .find(|runner| runner.name == expected_name)
356 .map_or(LifecycleGithubObservation::not_registered(), |runner| {
357 LifecycleGithubObservation::registered(runner.id, runner.busy)
358 }),
359 Err(_) => LifecycleGithubObservation::unreachable(),
360 }
361 }
362
363 async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool {
364 self.remove_runner(target, runner_id, cancel).await.is_ok()
365 }
366}
367
368#[async_trait]
370pub trait RuntimePackages: fmt::Debug + Send + Sync {
371 async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason>;
372 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason>;
373 fn prune_obsolete_guarded(
374 &self,
375 authority: PruneAuthority<'_>,
376 current: &RunnerVersion,
377 attempts: &[RunnerAttempt],
378 ) -> Result<(), FailureReason>;
379}
380
381pub struct PruneAuthority<'a> {
386 _guard: &'a AllocationGuard,
387}
388
389impl fmt::Debug for PruneAuthority<'_> {
390 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
391 f.write_str("PruneAuthority")
392 }
393}
394
395impl<'a> PruneAuthority<'a> {
396 fn from_launch_request(guard: &'a AllocationGuard) -> Self {
397 Self { _guard: guard }
398 }
399}
400
401#[derive(Debug)]
404pub struct CachedRuntimePackages {
405 cache: Arc<PackageCache>,
406}
407
408impl CachedRuntimePackages {
409 #[must_use]
410 pub fn new(cache: Arc<PackageCache>) -> Self {
411 Self { cache }
412 }
413}
414
415#[async_trait]
416impl RuntimePackages for CachedRuntimePackages {
417 async fn materialize(&self, attempt: &RunnerAttempt) -> Result<RunnerVersion, FailureReason> {
418 let installed = self
419 .cache
420 .ensure_installed()
421 .await
422 .map_err(package_failure)?;
423 copy_package_tree(installed.root(), attempt.runtime_path())
424 .map_err(|_| FailureReason::ProcessStartFailed)?;
425 if let Err(error) = self.cache.lease(attempt, installed.version()) {
426 let _ = remove_materialized_package(attempt);
430 return Err(package_failure(error));
431 }
432 Ok(installed.version().clone())
433 }
434
435 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
436 self.cache.release(attempt).map_err(package_failure)
437 }
438
439 fn prune_obsolete_guarded(
440 &self,
441 _authority: PruneAuthority<'_>,
442 current: &RunnerVersion,
443 attempts: &[RunnerAttempt],
444 ) -> Result<(), FailureReason> {
445 for installed in self.cache.installed().map_err(package_failure)? {
446 if installed.version() != current {
447 match self.cache.prune(installed.version(), attempts) {
448 Ok(()) | Err(PackageError::VersionInUse { .. }) => {}
449 Err(error) => return Err(package_failure(error)),
450 }
451 }
452 }
453 Ok(())
454 }
455}
456
457fn package_failure(error: PackageError) -> FailureReason {
458 error.failure_reason().unwrap_or(FailureReason::Other(
459 "runner package cache operation failed".into(),
460 ))
461}
462
463fn package_failure_is_terminal(reason: &FailureReason) -> bool {
464 matches!(
465 reason,
466 FailureReason::RunnerPackageUnverified | FailureReason::RunnerVersionRejected
467 )
468}
469
470const WORKSPACE_NAME_LEN: usize = 12;
493
494fn workspace_name(id: AttemptId) -> String {
496 let full = id.to_string();
497 full.chars()
498 .filter(|c| *c != '-')
499 .take(WORKSPACE_NAME_LEN)
500 .collect()
501}
502
503#[derive(Debug, Clone)]
510struct Placement {
511 runtime: PathBuf,
512 workspace: AttemptWorkspace,
513}
514
515fn root_failure(error: RunnerRootError) -> LifecycleError {
522 tracing::warn!(
539 error_kind = error.kind(),
540 "the runner root refused this launch, so no attempt was created; the host will \
541 retry every poll until the cause is resolved. Re-running `host set-runtime-root` \
542 with the same path re-runs this check and prints the directory and the \
543 remediation in full"
544 );
545 LifecycleError::Failed(FailureReason::Other(error.to_string()))
546}
547
548fn lowest_free_slot(leases: &[RunnerAttempt], ceiling: NonZeroU16) -> Option<NonZeroU16> {
557 let held: BTreeSet<u16> = leases
558 .iter()
559 .filter_map(|attempt| attempt.workspace().slot_number())
560 .collect();
561 (1..=ceiling.get())
562 .find(|slot| !held.contains(slot))
563 .and_then(NonZeroU16::new)
564}
565
566fn create_or_validate_slot(slot: &Path) -> Result<(), LifecycleError> {
574 match fs::symlink_metadata(slot) {
575 Ok(metadata) if is_link_like(&metadata) => Err(slot_refusal(
581 slot,
582 "is a symbolic link, junction or other reparse point, which could place runner \
583 files outside the configured root",
584 )),
585 Ok(metadata) if !metadata.is_dir() => Err(slot_refusal(slot, "is not a directory")),
586 Ok(_) => Ok(()),
587 Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(slot)
588 .map_err(|source| slot_refusal(slot, format!("could not be created: {source}"))),
589 Err(source) => Err(slot_refusal(
590 slot,
591 format!("could not be inspected: {source}"),
592 )),
593 }
594}
595
596fn accept_reusable_slot(slot: &Path) -> Result<(), LifecycleError> {
608 let unreadable =
609 |source: std::io::Error| slot_refusal(slot, format!("could not be read: {source}"));
610 let entries = fs::read_dir(slot).map_err(unreadable)?;
611 let mut refused: Vec<String> = Vec::new();
612 for entry in entries {
613 let entry = entry.map_err(unreadable)?;
614 let name = entry.file_name();
615 let metadata = fs::symlink_metadata(entry.path()).map_err(|source| {
616 slot_refusal(
617 slot,
618 format!("entry {name:?} could not be inspected: {source}"),
619 )
620 })?;
621 if is_retainable_work_folder(&name, &metadata) {
626 continue;
627 }
628 refused.push(name.to_string_lossy().into_owned());
629 }
630 if refused.is_empty() {
631 return Ok(());
632 }
633 refused.sort();
634 Err(slot_refusal(
635 slot,
636 format!(
637 "holds {} that this attempt may not reuse: [{}]. A reusable slot is empty or holds \
638 one real `{DEFAULT_WORK_FOLDER}` directory and nothing else; remove or move the \
639 entries listed, or let cleanup and recovery resolve them",
640 if refused.len() == 1 {
641 "an entry"
642 } else {
643 "entries"
644 },
645 refused.join(", ")
646 ),
647 ))
648}
649
650fn slot_refusal(slot: &Path, detail: impl fmt::Display) -> LifecycleError {
651 LifecycleError::Failed(FailureReason::Other(format!(
652 "the persistent slot {} {detail}",
653 slot.display()
654 )))
655}
656
657fn is_work_folder(name: &OsStr) -> bool {
667 if cfg!(windows) {
668 name.eq_ignore_ascii_case(DEFAULT_WORK_FOLDER)
669 } else {
670 name == OsStr::new(DEFAULT_WORK_FOLDER)
671 }
672}
673
674fn is_link_like(metadata: &fs::Metadata) -> bool {
683 if metadata.file_type().is_symlink() {
684 return true;
685 }
686 #[cfg(windows)]
687 {
688 use std::os::windows::fs::MetadataExt;
689
690 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
691 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
692 }
693 #[cfg(not(windows))]
694 false
695}
696
697fn is_retainable_work_folder(name: &OsStr, metadata: &fs::Metadata) -> bool {
705 is_work_folder(name) && metadata.is_dir() && !is_link_like(metadata)
706}
707
708fn remove_materialized_package(attempt: &RunnerAttempt) -> std::io::Result<()> {
715 match attempt.workspace() {
716 AttemptWorkspace::Ephemeral => remove_runtime_tree(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
722fn remove_runtime_tree(path: &Path) -> std::io::Result<()> {
730 #[cfg(windows)]
731 {
732 remove_dir_all::remove_dir_all(path)
733 }
734 #[cfg(not(windows))]
735 {
736 fs::remove_dir_all(path)
737 }
738}
739
740#[derive(Debug, Clone, Copy, PartialEq, Eq)]
755enum SlotRefusal {
756 NotTheJournalledSlot,
758 PolicyRootDisagrees,
760 Containment,
762 SlotNotADirectory,
764 Enumeration,
766 WorkNotADirectory,
768 Deletion,
770 Residue,
772}
773
774impl SlotRefusal {
775 const fn class(self) -> &'static str {
777 match self {
778 Self::NotTheJournalledSlot => "slot_path_is_not_the_journalled_slot",
779 Self::PolicyRootDisagrees => "slot_root_disagrees_with_policy",
780 Self::Containment => "slot_escapes_its_root",
781 Self::SlotNotADirectory => "slot_is_not_a_directory",
782 Self::Enumeration => "slot_could_not_be_enumerated",
783 Self::WorkNotADirectory => "retained_work_is_not_a_directory",
784 Self::Deletion => "slot_entry_could_not_be_removed",
785 Self::Residue => "slot_still_holds_runner_state",
786 }
787 }
788
789 const fn remediation(self) -> &'static str {
791 match self {
792 Self::NotTheJournalledSlot | Self::PolicyRootDisagrees | Self::Containment => {
793 "the attempt keeps its slot lease and nothing was removed; correct the \
794 repository's persistent workspace path, or remove the slot directory by hand \
795 once you have confirmed what is in it"
796 }
797 Self::SlotNotADirectory | Self::WorkNotADirectory => {
798 "the attempt keeps its slot lease and nothing was removed; a job replaced the \
799 slot or its `_work` with a link, so inspect it before deleting anything and \
800 treat the retained workspace as untrusted"
801 }
802 Self::Enumeration | Self::Deletion | Self::Residue => {
803 "the attempt keeps its slot lease and will be cleaned again on the next pass; \
804 release whatever is holding the files open, or remove the slot's contents by \
805 hand leaving only `_work`"
806 }
807 }
808 }
809}
810
811#[derive(Debug, Clone, PartialEq, Eq)]
818struct SlotQuarantine {
819 refusal: SlotRefusal,
820 detail: String,
821}
822
823impl SlotQuarantine {
824 fn new(refusal: SlotRefusal, detail: impl Into<String>) -> Self {
825 Self {
826 refusal,
827 detail: detail.into(),
828 }
829 }
830}
831
832impl fmt::Display for SlotQuarantine {
833 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
834 write!(f, "{}; {}", self.detail, self.refusal.remediation())
835 }
836}
837
838fn verify_journalled_slot(
852 runtime: &Path,
853 slot: NonZeroU16,
854 configured: Option<&LocalAbsolutePath>,
855) -> Result<(), SlotQuarantine> {
856 let mislaid = || {
857 SlotQuarantine::new(
858 SlotRefusal::NotTheJournalledSlot,
859 format!(
860 "the journalled runtime {} is not the slot s{slot} this attempt was allocated as",
861 runtime.display()
862 ),
863 )
864 };
865 let local = |path: &Path| {
866 path.to_str()
867 .and_then(|raw| LocalAbsolutePath::new(raw).ok())
868 .ok_or_else(mislaid)
869 };
870
871 let runtime_path = local(runtime)?;
872 let root = local(runtime.parent().ok_or_else(mislaid)?)?;
873 let name = AttemptWorkspace::persistent_slot(slot)
880 .slot_directory_name()
881 .expect("a persistent workspace names its slot directory");
882 let derived = runner_root::derive_child(&root, &name).map_err(|_| mislaid())?;
883 if derived != runtime_path {
884 return Err(mislaid());
885 }
886 if let Some(configured) = configured
887 && configured != &root
888 {
889 return Err(SlotQuarantine::new(
890 SlotRefusal::PolicyRootDisagrees,
891 format!(
892 "the journalled slot {} is not under the repository's configured persistent root \
893 {}",
894 runtime.display(),
895 configured.as_str()
896 ),
897 ));
898 }
899 runner_root::verify_containment(&root, &derived).map_err(|source| {
902 SlotQuarantine::new(
903 SlotRefusal::Containment,
904 format!("the journalled slot is not inside the root it was allocated from: {source}"),
905 )
906 })
907}
908
909fn slot_is_present(slot: &Path) -> Result<bool, SlotQuarantine> {
920 match fs::symlink_metadata(slot) {
921 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
922 Err(source) => Err(SlotQuarantine::new(
923 SlotRefusal::SlotNotADirectory,
924 format!(
925 "the slot {} could not be inspected: {:?}",
926 slot.display(),
927 source.kind()
928 ),
929 )),
930 Ok(metadata) if !metadata.is_dir() || is_link_like(&metadata) => Err(SlotQuarantine::new(
931 SlotRefusal::SlotNotADirectory,
932 format!(
933 "the slot {} is a link or a file rather than a real directory",
934 slot.display()
935 ),
936 )),
937 Ok(_) => Ok(true),
938 }
939}
940
941fn scrub_slot_entries(slot: &Path) -> Result<(), SlotQuarantine> {
956 let unreadable = |source: std::io::Error| {
957 SlotQuarantine::new(
958 SlotRefusal::Enumeration,
959 format!(
960 "the entries of {} could not be listed: {:?}",
961 slot.display(),
962 source.kind()
963 ),
964 )
965 };
966 for entry in fs::read_dir(slot).map_err(unreadable)? {
967 let name = entry.map_err(unreadable)?.file_name();
968 let path = slot.join(&name);
969 let Some(metadata) = listed_entry_metadata(&path).map_err(unreadable)? else {
972 continue;
973 };
974 if is_work_folder(&name) {
975 if is_retainable_work_folder(&name, &metadata) {
976 continue;
977 }
978 return Err(SlotQuarantine::new(
979 SlotRefusal::WorkNotADirectory,
980 format!(
981 "the retained `{DEFAULT_WORK_FOLDER}` in {} is a link or a file rather than a \
982 real directory",
983 slot.display()
984 ),
985 ));
986 }
987 remove_slot_entry(&path, &metadata).map_err(|source| {
988 SlotQuarantine::new(
989 SlotRefusal::Deletion,
990 format!(
991 "an entry of {} could not be removed: {:?}",
992 slot.display(),
993 source.kind()
994 ),
995 )
996 })?;
997 }
998 Ok(())
999}
1000
1001fn listed_entry_metadata(path: &Path) -> std::io::Result<Option<fs::Metadata>> {
1007 match fs::symlink_metadata(path) {
1008 Ok(metadata) => Ok(Some(metadata)),
1009 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1010 Err(source) => Err(source),
1011 }
1012}
1013
1014fn remove_slot_entry(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
1019 let removed = if is_link_like(metadata) {
1020 fs::remove_file(path).or_else(|_| fs::remove_dir(path))
1023 } else if metadata.is_dir() {
1024 remove_runtime_tree(path)
1025 } else {
1026 fs::remove_file(path)
1027 };
1028 match removed {
1029 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1030 other => other,
1031 }
1032}
1033
1034fn verify_slot_scrubbed(slot: &Path) -> Result<(), SlotQuarantine> {
1051 let unreadable = |source: std::io::Error| {
1052 SlotQuarantine::new(
1053 SlotRefusal::Enumeration,
1054 format!(
1055 "the entries of {} could not be listed to verify the scrub: {:?}",
1056 slot.display(),
1057 source.kind()
1058 ),
1059 )
1060 };
1061 let mut residue = 0_usize;
1062 let mut named: Vec<String> = Vec::new();
1063 for entry in fs::read_dir(slot).map_err(unreadable)? {
1064 let name = entry.map_err(unreadable)?.file_name();
1065 let Some(metadata) = listed_entry_metadata(&slot.join(&name)).map_err(unreadable)? else {
1069 continue;
1070 };
1071 if is_retainable_work_folder(&name, &metadata) {
1072 continue;
1073 }
1074 residue = residue.saturating_add(1);
1075 if name
1076 .to_string_lossy()
1077 .starts_with(RestrictiveHandoff::NAME_PREFIX)
1078 {
1079 named.push("an encoded JIT handoff".to_owned());
1080 }
1081 }
1082 named.extend(
1083 SENSITIVE_SLOT_ENTRIES
1084 .iter()
1085 .filter(|entry| fs::symlink_metadata(slot.join(entry)).is_ok())
1086 .map(|entry| format!("`{entry}`")),
1087 );
1088 if residue == 0 && named.is_empty() {
1089 return Ok(());
1090 }
1091 named.sort_unstable();
1092 named.dedup();
1093 Err(SlotQuarantine::new(
1094 SlotRefusal::Residue,
1095 residue_detail(slot, residue, &named),
1096 ))
1097}
1098
1099fn residue_detail(slot: &Path, residue: usize, named: &[String]) -> String {
1109 if residue == 0 {
1110 format!(
1114 "the listing of {} reported nothing but `{DEFAULT_WORK_FOLDER}`, yet {} survived \
1115 cleanup",
1116 slot.display(),
1117 named.join(", ")
1118 )
1119 } else {
1120 format!(
1121 "{residue} entr{} other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}{}",
1122 if residue == 1 { "y" } else { "ies" },
1123 slot.display(),
1124 if named.is_empty() {
1125 String::new()
1126 } else {
1127 format!(", including {}", named.join(", "))
1128 }
1129 )
1130 }
1131}
1132
1133fn replacement_operation(outcome: &AttemptOutcome) -> Option<&'static str> {
1134 match outcome {
1135 AttemptOutcome::Failed {
1136 reason: FailureReason::JitExpired,
1137 } => Some("jit_expired_replacement"),
1138 AttemptOutcome::Failed {
1139 reason: FailureReason::ProcessExitedUnexpectedly,
1140 } => Some("exit_before_acceptance_replacement"),
1141 _ => None,
1142 }
1143}
1144
1145fn copy_package_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
1160 if source.join(DEFAULT_WORK_FOLDER).exists() {
1161 return Err(std::io::Error::new(
1162 std::io::ErrorKind::InvalidData,
1163 "a cached runner package contains a _work folder, which means it was used \
1164 to run a job before it was archived; the cache must only contain clean \
1165 extracts to prevent data leakage",
1166 ));
1167 }
1168
1169 #[cfg(unix)]
1170 {
1171 let status = std::process::Command::new("cp")
1172 .arg("-a")
1173 .arg(format!("{}/.", source.display()))
1174 .arg(destination)
1175 .status()?;
1176 if status.success() {
1177 Ok(())
1178 } else {
1179 Err(std::io::Error::other("cp failed"))
1180 }
1181 }
1182 #[cfg(not(unix))]
1183 copy_package_entries(source, destination, true)
1184}
1185
1186#[cfg(not(unix))]
1187fn copy_package_entries(source: &Path, destination: &Path, top_level: bool) -> std::io::Result<()> {
1188 fs::create_dir_all(destination)?;
1189 for entry in fs::read_dir(source)? {
1190 let entry = entry?;
1191 if top_level && is_work_folder(&entry.file_name()) {
1192 return Err(std::io::Error::new(
1193 std::io::ErrorKind::InvalidData,
1194 format!(
1195 "the runner package holds a top-level `{DEFAULT_WORK_FOLDER}`; copying \
1196 it would overwrite the job workspace a persistent slot retains"
1197 ),
1198 ));
1199 }
1200 let target = destination.join(entry.file_name());
1201 if entry.file_type()?.is_dir() {
1202 copy_package_entries(&entry.path(), &target, false)?;
1203 } else {
1204 fs::copy(entry.path(), target)?;
1205 }
1206 }
1207 Ok(())
1208}
1209
1210#[derive(Debug, Clone, PartialEq, Eq)]
1213pub struct ProcessStartFailure {
1214 pub reason: FailureReason,
1215 pub retryable: bool,
1218 pub live_pid: Option<u32>,
1221}
1222
1223impl ProcessStartFailure {
1224 fn before_spawn(reason: FailureReason) -> Self {
1225 Self {
1226 reason,
1227 retryable: true,
1228 live_pid: None,
1229 }
1230 }
1231
1232 fn after_spawn_stopped() -> Self {
1233 Self {
1234 reason: FailureReason::ProcessStartFailed,
1235 retryable: false,
1236 live_pid: None,
1237 }
1238 }
1239
1240 fn after_spawn_live(pid: u32) -> Self {
1241 Self::after_spawn_live_with_reason(pid, FailureReason::ProcessStartFailed)
1242 }
1243
1244 fn after_spawn_live_with_reason(pid: u32, reason: FailureReason) -> Self {
1245 Self {
1246 reason,
1247 retryable: false,
1248 live_pid: Some(pid),
1249 }
1250 }
1251}
1252
1253pub trait ProcessSupervisor: fmt::Debug + Send + Sync {
1254 fn spawn(
1255 &self,
1256 attempt: &RunnerAttempt,
1257 config: &EncodedJitConfig,
1258 ) -> Result<u32, ProcessStartFailure>;
1259 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason>;
1260 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason>;
1263 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool;
1266 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
1267 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool;
1268 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
1269}
1270
1271#[derive(Debug, Default)]
1274pub struct NativeProcesses {
1275 children: Mutex<BTreeMap<AttemptId, ChildProcess>>,
1276 successful_exits: Mutex<BTreeMap<AttemptId, bool>>,
1277 #[cfg(test)]
1278 post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
1279 #[cfg(test)]
1280 post_spawn_reaps: std::sync::atomic::AtomicUsize,
1281 #[cfg(test)]
1282 post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
1283 #[cfg(test)]
1284 use_long_lived_test_listener: std::sync::atomic::AtomicBool,
1285}
1286
1287#[cfg(test)]
1288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1289enum PostSpawnBoundary {
1290 HandoffDelete,
1291 IdentitySerialize,
1292 IdentityWrite,
1293 ChildMapInsert,
1294}
1295
1296impl NativeProcesses {
1297 #[must_use]
1298 pub fn new() -> Self {
1299 Self::default()
1300 }
1301
1302 #[cfg(test)]
1303 fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
1304 self.post_spawn_faults.lock().unwrap().push_back(boundary);
1305 }
1306
1307 #[cfg(test)]
1308 fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
1309 let mut faults = self.post_spawn_faults.lock().unwrap();
1310 if faults.front() == Some(&boundary) {
1311 faults.pop_front();
1312 true
1313 } else {
1314 false
1315 }
1316 }
1317
1318 #[cfg(test)]
1319 fn fail_post_spawn_stops(&self, count: usize) {
1320 self.post_spawn_stop_failures
1321 .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
1322 }
1323
1324 #[cfg(test)]
1325 fn fail_next_post_spawn_stop(&self) {
1326 self.fail_post_spawn_stops(1);
1327 }
1328
1329 #[cfg(test)]
1330 fn use_long_lived_test_listener(&self) {
1331 self.use_long_lived_test_listener
1332 .store(true, std::sync::atomic::Ordering::SeqCst);
1333 }
1334
1335 fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
1336 #[cfg(test)]
1337 if self
1338 .post_spawn_stop_failures
1339 .fetch_update(
1340 std::sync::atomic::Ordering::SeqCst,
1341 std::sync::atomic::Ordering::SeqCst,
1342 |left| if left > 0 { Some(left - 1) } else { None },
1343 )
1344 .is_ok()
1345 {
1346 return Err(FailureReason::Other("injected runner stop failure".into()));
1347 }
1348 child
1349 .stop(Duration::from_secs(1))
1350 .map(|_| ())
1351 .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
1352 }
1353
1354 fn abort_spawned_child(
1355 &self,
1356 mut child: ChildProcess,
1357 attempt: &RunnerAttempt,
1358 remove_identity: bool,
1359 ) -> ProcessStartFailure {
1360 let mut reaped = self.stop_spawned_child(&mut child).is_ok();
1361 if reaped {
1362 if remove_identity {
1363 Self::remove_identity_files(attempt);
1364 }
1365 } else {
1366 let identity_durable =
1369 serde_json::to_vec(child.identity())
1370 .ok()
1371 .is_some_and(|identity| {
1372 self.persist_identity(attempt, &identity).is_ok()
1373 || self.persist_fallback_identity(attempt, &identity).is_ok()
1374 });
1375 if !identity_durable {
1376 for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
1381 if self.stop_spawned_child(&mut child).is_ok() {
1382 reaped = true;
1383 break;
1384 }
1385 }
1386 if !reaped {
1387 let pid = child.pid();
1392 let marker = write_durable_file(
1393 &Self::unresolved_process_path(attempt),
1394 pid.to_string().as_bytes(),
1395 );
1396 self.children
1397 .lock()
1398 .unwrap_or_else(std::sync::PoisonError::into_inner)
1399 .insert(attempt.id, child);
1400 let reason = if marker.is_ok() {
1401 FailureReason::Other(
1402 "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
1403 .into(),
1404 )
1405 } else {
1406 FailureReason::Other(
1407 "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
1408 .into(),
1409 )
1410 };
1411 return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
1412 }
1413 } else {
1414 let pid = child.pid();
1415 self.children
1416 .lock()
1417 .unwrap_or_else(std::sync::PoisonError::into_inner)
1418 .insert(attempt.id, child);
1419 return ProcessStartFailure::after_spawn_live(pid);
1420 }
1421 }
1422 #[cfg(test)]
1423 if reaped {
1424 self.post_spawn_reaps
1425 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1426 }
1427 #[cfg(not(test))]
1428 let _ = reaped;
1429 ProcessStartFailure::after_spawn_stopped()
1430 }
1431
1432 fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
1433 attempt.runtime_path().join(IDENTITY_FILE)
1434 }
1435
1436 fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
1437 attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
1438 }
1439
1440 fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
1441 attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
1442 }
1443
1444 fn remove_identity_files(attempt: &RunnerAttempt) {
1445 let _ = fs::remove_file(Self::identity_path(attempt));
1446 let _ = fs::remove_file(Self::fallback_identity_path(attempt));
1447 let _ = fs::remove_file(Self::unresolved_process_path(attempt));
1448 }
1449
1450 fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
1451 self.persist_identity_at(&Self::identity_path(attempt), bytes)
1452 }
1453
1454 fn persist_fallback_identity(
1455 &self,
1456 attempt: &RunnerAttempt,
1457 bytes: &[u8],
1458 ) -> std::io::Result<()> {
1459 self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
1460 }
1461
1462 fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1463 #[cfg(test)]
1464 if self.faults_at(PostSpawnBoundary::IdentityWrite) {
1465 return Err(std::io::Error::other("injected identity write failure"));
1466 }
1467 write_durable_file(path, bytes)
1468 }
1469
1470 fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
1471 attempt.runtime_path().join(TERMINATE_INTENT_FILE)
1472 }
1473
1474 fn read_identity(attempt: &RunnerAttempt) -> Result<Option<ProcessIdentity>, FailureReason> {
1475 match Self::read_identity_at(&Self::identity_path(attempt))? {
1476 Some(identity) => Ok(Some(identity)),
1477 None => Self::read_identity_at(&Self::fallback_identity_path(attempt)),
1478 }
1479 }
1480
1481 fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
1482 match fs::read(path) {
1483 Ok(bytes) => serde_json::from_slice(&bytes)
1484 .map(Some)
1485 .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
1486 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1487 Err(_) => Err(FailureReason::Other(
1488 "process identity journal could not be read".into(),
1489 )),
1490 }
1491 }
1492}
1493
1494impl ProcessSupervisor for NativeProcesses {
1495 fn spawn(
1496 &self,
1497 attempt: &RunnerAttempt,
1498 config: &EncodedJitConfig,
1499 ) -> Result<u32, ProcessStartFailure> {
1500 let handoff = RestrictiveHandoff::create(
1501 attempt.runtime_path(),
1502 SecretString::from(config.expose().to_owned()),
1503 )
1504 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
1505 #[cfg(windows)]
1506 let program = attempt
1507 .runtime_path()
1508 .join("bin")
1509 .join("Runner.Listener.exe");
1510 #[cfg(not(windows))]
1511 let program = attempt.runtime_path().join("bin").join("Runner.Listener");
1512 if !program.is_file() {
1515 return Err(ProcessStartFailure::before_spawn(
1516 FailureReason::ProcessStartFailed,
1517 ));
1518 }
1519 #[cfg(test)]
1520 let spec = if self
1521 .use_long_lived_test_listener
1522 .load(std::sync::atomic::Ordering::SeqCst)
1523 {
1524 SpawnSpec::new(program)
1525 .args([
1526 "--ignored",
1527 "--exact",
1528 "lifecycle::tests::long_lived_native_listener_helper",
1529 "--nocapture",
1530 ])
1531 .env(
1532 "RUNNER_MANAGER_TEST_LISTENER_READY",
1533 attempt.runtime_path().join(TEST_LISTENER_READY),
1534 )
1535 .working_dir(attempt.runtime_path())
1536 } else {
1537 runner_listener_spec(program, attempt.runtime_path())
1538 };
1539 #[cfg(not(test))]
1540 let spec = runner_listener_spec(program, attempt.runtime_path());
1541 let child = spec
1542 .spawn_runner_with_handoff(&handoff)
1543 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
1544 #[cfg(test)]
1546 if self.faults_at(PostSpawnBoundary::HandoffDelete) {
1547 drop(handoff);
1548 return Err(self.abort_spawned_child(child, attempt, false));
1549 }
1550 if handoff.delete().is_err() {
1551 return Err(self.abort_spawned_child(child, attempt, false));
1552 }
1553 #[cfg(test)]
1554 if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
1555 return Err(self.abort_spawned_child(child, attempt, false));
1556 }
1557 let identity = match serde_json::to_vec(child.identity()) {
1558 Ok(identity) => identity,
1559 Err(_) => {
1560 return Err(self.abort_spawned_child(child, attempt, false));
1561 }
1562 };
1563 if self.persist_identity(attempt, &identity).is_err() {
1564 return Err(self.abort_spawned_child(child, attempt, true));
1565 }
1566 let pid = child.pid();
1567 #[cfg(test)]
1568 if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
1569 return Err(self.abort_spawned_child(child, attempt, true));
1570 }
1571 let mut children = self
1572 .children
1573 .lock()
1574 .unwrap_or_else(std::sync::PoisonError::into_inner);
1575 children.insert(attempt.id, child);
1576 Ok(pid)
1577 }
1578
1579 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
1580 let mut children = self
1581 .children
1582 .lock()
1583 .unwrap_or_else(std::sync::PoisonError::into_inner);
1584 if let Some(child) = children.get_mut(&attempt.id) {
1585 return match child
1586 .try_exit_status()
1587 .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
1588 {
1589 None => Ok(true),
1590 Some(status) => {
1591 if let Ok(mut exits) = self.successful_exits.lock() {
1592 exits.insert(attempt.id, status.success());
1593 }
1594 Ok(false)
1595 }
1596 };
1597 }
1598 let Some(identity) = Self::read_identity(attempt)? else {
1599 if attempt.process_id().is_some() || Self::unresolved_process_path(attempt).is_file() {
1600 return Err(FailureReason::Other(
1601 "runner process identity is missing; refusing recovery until the process is resolved"
1602 .into(),
1603 ));
1604 }
1605 return Ok(false);
1606 };
1607 match identity.recheck() {
1608 Ok(Adoption::Live) => Ok(true),
1609 Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
1610 Err(_) => Ok(false),
1611 }
1612 }
1613
1614 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
1615 Ok(Self::read_identity(attempt)?.map(|identity| identity.pid()))
1616 }
1617
1618 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
1619 self.successful_exits
1620 .lock()
1621 .ok()
1622 .and_then(|exits| exits.get(&attempt.id).copied())
1623 .unwrap_or(false)
1624 }
1625
1626 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
1627 let path = Self::intent_path(attempt);
1628 write_durable_file(&path, b"registration-timeout\n")
1629 .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
1630 }
1631
1632 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
1633 Self::intent_path(attempt).is_file()
1634 }
1635
1636 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
1637 let mut children = self
1638 .children
1639 .lock()
1640 .unwrap_or_else(std::sync::PoisonError::into_inner);
1641 if let Some(child) = children.get_mut(&attempt.id) {
1642 child
1643 .stop(Duration::from_secs(10))
1644 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
1645 return Ok(());
1646 }
1647 let Some(identity) = Self::read_identity(attempt)? else {
1648 return Ok(());
1649 };
1650 match identity
1651 .terminate(Duration::from_secs(10))
1652 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
1653 {
1654 Termination::Terminated | Termination::AlreadyGone => Ok(()),
1655 Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
1656 "runner PID was recycled; refusing to signal it".into(),
1657 )),
1658 }
1659 }
1660}
1661
1662pub struct LifecyclePorts {
1663 pub store: Arc<dyn Store>,
1664 pub github: Arc<dyn LifecycleGithub>,
1665 pub packages: Arc<dyn RuntimePackages>,
1666 pub processes: Arc<dyn ProcessSupervisor>,
1667 pub clock: Arc<dyn Clock>,
1668 pub demand: Arc<dyn DemandPersistence>,
1669 pub delay: Arc<dyn RetryDelay>,
1670 pub events: Arc<dyn AttemptEventSink>,
1671 pub reconcile_events: Arc<dyn EventSink>,
1672}
1673
1674impl fmt::Debug for LifecyclePorts {
1675 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1676 f.debug_struct("LifecyclePorts")
1677 .field("store", &self.store)
1678 .field("github", &self.github)
1679 .field("packages", &self.packages)
1680 .field("processes", &self.processes)
1681 .finish_non_exhaustive()
1682 }
1683}
1684
1685#[derive(Debug, thiserror::Error)]
1686pub enum LifecycleError {
1687 #[error("attempt journal operation failed")]
1688 Journal,
1689 #[error("attempt {0} is not in the journal")]
1690 Missing(AttemptId),
1691 #[error("attempt lifecycle transition was refused")]
1692 Transition,
1693 #[error("startup recovery has not completed")]
1694 RecoveryIncomplete,
1695 #[error("the persistent slot was not cleaned: {detail}")]
1705 SlotQuarantined {
1706 class: &'static str,
1708 detail: String,
1709 },
1710 #[error("attempt workspace could not be removed")]
1714 WorkspaceCleanupDeferred,
1715 #[error("runner lifecycle failed: {0}")]
1716 Failed(FailureReason),
1717}
1718
1719impl LifecycleError {
1720 fn reason(&self) -> FailureReason {
1721 match self {
1722 Self::Failed(reason) => reason.clone(),
1723 Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
1724 Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
1725 Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
1726 Self::Transition => FailureReason::Other("attempt transition was refused".into()),
1727 Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
1730 Self::WorkspaceCleanupDeferred => FailureReason::Other(self.to_string()),
1731 }
1732 }
1733}
1734
1735#[derive(Debug)]
1737pub struct LifecycleLauncher {
1738 host_id: HostId,
1739 app_paths: runner_manager_platform::paths::AppPaths,
1740 diagnostics_root: PathBuf,
1741 runner_group_id: u64,
1742 timeouts: RecoveryTimeouts,
1743 retry: RetryPolicy,
1744 cancel: CancelToken,
1745 ports: LifecyclePorts,
1746 recovery_complete: Mutex<bool>,
1747 versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
1748 pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
1749}
1750
1751#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1752enum ReconcileProgress {
1753 Reconciled,
1754 Deferred,
1755 Replacement {
1756 attempt: AttemptId,
1757 operation: &'static str,
1758 },
1759}
1760
1761impl LifecycleLauncher {
1762 #[must_use]
1763 pub fn new(
1764 host_id: HostId,
1765 app_paths: runner_manager_platform::paths::AppPaths,
1766 diagnostics_root: impl Into<PathBuf>,
1767 runner_group_id: u64,
1768 timeouts: RecoveryTimeouts,
1769 retry: RetryPolicy,
1770 ports: LifecyclePorts,
1771 ) -> Self {
1772 Self {
1773 host_id,
1774 app_paths,
1775 diagnostics_root: diagnostics_root.into(),
1776 runner_group_id,
1777 timeouts,
1778 retry,
1779 cancel: CancelToken::new(),
1780 ports,
1781 recovery_complete: Mutex::new(false),
1782 versions: Mutex::new(BTreeMap::new()),
1783 pending_replacements: Mutex::new(BTreeMap::new()),
1784 }
1785 }
1786
1787 pub async fn recover_startup(
1791 &self,
1792 policies: &[ScalePolicy],
1793 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
1794 let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
1795 let attempts = self
1796 .ports
1797 .store
1798 .attempts()
1799 .map_err(|_| LifecycleError::Journal)?;
1800 let mut unresolved = false;
1801 for attempt in attempts {
1802 let Some(policy) = by_id.get(&attempt.policy_id) else {
1803 if !attempt.is_terminal() && attempt.state() != AttemptState::Cleaned {
1804 unresolved = true;
1805 }
1806 continue;
1807 };
1808 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
1809 match self.reconcile_one(policy, attempt).await? {
1810 ReconcileProgress::Deferred => unresolved = true,
1811 ReconcileProgress::Replacement { attempt, operation } => {
1812 self.pending_replacements
1813 .lock()
1814 .map_err(|_| LifecycleError::Journal)?
1815 .insert(
1816 attempt,
1817 ReplacementIntent {
1818 policy: policy.id,
1819 previous_attempt: attempt,
1820 operation,
1821 },
1822 );
1823 }
1824 ReconcileProgress::Reconciled => {}
1825 }
1826 }
1827 if unresolved {
1828 return Err(LifecycleError::RecoveryIncomplete);
1829 }
1830 *self
1831 .recovery_complete
1832 .lock()
1833 .map_err(|_| LifecycleError::Journal)? = true;
1834 Ok(self
1835 .pending_replacements
1836 .lock()
1837 .map_err(|_| LifecycleError::Journal)?
1838 .values()
1839 .copied()
1840 .collect())
1841 }
1842
1843 pub async fn supervise(
1845 &self,
1846 policy: &ScalePolicy,
1847 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
1848 let mut replacements = Vec::new();
1849 self.pending_replacements
1850 .lock()
1851 .map_err(|_| LifecycleError::Journal)?
1852 .retain(|_, intent| {
1853 if intent.policy == policy.id {
1854 replacements.push(*intent);
1855 false
1856 } else {
1857 true
1858 }
1859 });
1860 let attempts = self
1861 .ports
1862 .store
1863 .attempts_for_policy(policy.id)
1864 .map_err(|_| LifecycleError::Journal)?;
1865 for attempt in attempts {
1866 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
1867 if let ReconcileProgress::Replacement { attempt, operation } =
1868 self.reconcile_one(policy, attempt).await?
1869 {
1870 replacements.push(ReplacementIntent {
1871 policy: policy.id,
1872 previous_attempt: attempt,
1873 operation,
1874 });
1875 }
1876 }
1877 Ok(replacements)
1878 }
1879
1880 async fn reconcile_one(
1881 &self,
1882 policy: &ScalePolicy,
1883 mut attempt: RunnerAttempt,
1884 ) -> Result<ReconcileProgress, LifecycleError> {
1885 if attempt.state() == AttemptState::Cleaned {
1886 if matches!(attempt.workspace(), AttemptWorkspace::Ephemeral)
1895 && attempt.runtime_path().exists()
1896 {
1897 match self.scrub_workspace(&attempt) {
1903 Ok(()) => {}
1904 Err(LifecycleError::WorkspaceCleanupDeferred) => {
1905 self.ports
1906 .reconcile_events
1907 .emit(LifecycleEvent::AttemptCleanFailed {
1908 policy: attempt.policy_id,
1909 attempt: attempt.id,
1910 reason: "late_ephemeral_workspace_could_not_be_removed",
1911 });
1912 tracing::warn!(
1913 policy_id = %attempt.policy_id,
1914 attempt_id = %attempt.id,
1915 reason = "late_ephemeral_workspace_could_not_be_removed",
1916 "a cleaned ephemeral attempt left late workspace residue; cleanup will retry without blocking the daemon"
1917 );
1918 }
1919 Err(error) => return Err(error),
1920 }
1921 }
1922 return Ok(ReconcileProgress::Reconciled);
1923 }
1924 if attempt.is_terminal() {
1925 self.clean_or_quarantine(&mut attempt)?;
1926 return Ok(ReconcileProgress::Reconciled);
1927 }
1928 let process_alive = self
1929 .ports
1930 .processes
1931 .is_alive(&attempt)
1932 .map_err(LifecycleError::Failed)?;
1933 let github = self
1934 .ports
1935 .github
1936 .observe(&policy.target, attempt.id, &self.cancel)
1937 .await;
1938
1939 if let Some(runner_id) = github.runner_id
1944 && read_runner_id(attempt.runtime_path()).is_none()
1945 {
1946 write_runner_id(attempt.runtime_path(), runner_id)?;
1947 self.ports
1948 .events
1949 .emit(AttemptEvent::RemoteIdentityRecovered {
1950 attempt: attempt.id,
1951 runner_id,
1952 });
1953 }
1954
1955 if attempt.state() == AttemptState::JitReceived
1960 && process_alive
1961 && let Some(pid) = self
1962 .ports
1963 .processes
1964 .recovered_pid(&attempt)
1965 .map_err(LifecycleError::Failed)?
1966 {
1967 attempt
1968 .started(pid, self.ports.clock.now())
1969 .map_err(|_| LifecycleError::Transition)?;
1970 self.record(&attempt)?;
1971 }
1972
1973 if attempt.state() == AttemptState::Busy
1978 && !process_alive
1979 && github.status == GithubRunnerObservation::NotRegistered
1980 && self.ports.processes.completed_successfully(&attempt)
1981 {
1982 self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
1983 self.clean_or_quarantine(&mut attempt)?;
1984 return Ok(ReconcileProgress::Reconciled);
1985 }
1986
1987 if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
1990 self.deregister_runner(policy, &attempt).await;
1991 self.conclude(
1992 &mut attempt,
1993 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
1994 )?;
1995 self.clean_or_quarantine(&mut attempt)?;
1996 return Ok(ReconcileProgress::Replacement {
1997 attempt: attempt.id,
1998 operation: "registration_timeout_replacement",
1999 });
2000 }
2001
2002 if matches!(
2008 attempt.state(),
2009 AttemptState::Allocated | AttemptState::JitReceived
2010 ) && !process_alive
2011 && matches!(github.status, GithubRunnerObservation::Registered { .. })
2012 {
2013 if attempt.state() == AttemptState::Allocated {
2014 attempt
2015 .jit_received(self.ports.clock.now())
2016 .map_err(|_| LifecycleError::Transition)?;
2017 self.record(&attempt)?;
2018 }
2019 self.deregister_runner(policy, &attempt).await;
2020 self.conclude(
2021 &mut attempt,
2022 AttemptOutcome::failed(FailureReason::JitExpired),
2023 )?;
2024 self.clean_or_quarantine(&mut attempt)?;
2025 return Ok(ReconcileProgress::Replacement {
2026 attempt: attempt.id,
2027 operation: "jit_expired_replacement",
2028 });
2029 }
2030
2031 match recovery_decision(
2032 &attempt,
2033 RecoveryObservation {
2034 process_alive,
2035 github: github.status,
2036 },
2037 self.timeouts,
2038 self.ports.clock.as_ref(),
2039 ) {
2040 RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
2041 RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
2042 RecoveryDecision::Adopt => {
2043 self.ports.events.emit(AttemptEvent::Adopted {
2044 attempt: attempt.id,
2045 });
2046 Ok(ReconcileProgress::Reconciled)
2047 }
2048 RecoveryDecision::Clean => {
2049 self.clean_or_quarantine(&mut attempt)?;
2050 Ok(ReconcileProgress::Reconciled)
2051 }
2052 RecoveryDecision::Observe(state) => {
2053 let runner_id = attempt
2054 .github_runner_id()
2055 .or(github.runner_id)
2056 .or_else(|| read_runner_id(attempt.runtime_path()))
2057 .ok_or(LifecycleError::Transition)?;
2058 match state {
2059 AttemptState::JitReceived => attempt
2060 .jit_received(self.ports.clock.now())
2061 .map_err(|_| LifecycleError::Transition)?,
2062 AttemptState::Starting => {
2063 let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
2064 attempt
2065 .started(pid, self.ports.clock.now())
2066 .map_err(|_| LifecycleError::Transition)?;
2067 }
2068 AttemptState::Idle => attempt
2069 .registered_idle(runner_id, self.ports.clock.now())
2070 .map_err(|_| LifecycleError::Transition)?,
2071 AttemptState::Busy => attempt
2072 .assigned_job(runner_id, self.ports.clock.now())
2073 .map_err(|_| LifecycleError::Transition)?,
2074 _ => return Err(LifecycleError::Transition),
2075 }
2076 self.record(&attempt)?;
2077 Ok(ReconcileProgress::Reconciled)
2078 }
2079 RecoveryDecision::Conclude(outcome) => {
2080 let replacement = replacement_operation(&outcome);
2081 if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
2086 self.deregister_runner(policy, &attempt).await;
2087 }
2088 self.conclude(&mut attempt, outcome)?;
2089 self.clean_or_quarantine(&mut attempt)?;
2090 Ok(
2091 replacement.map_or(ReconcileProgress::Reconciled, |operation| {
2092 ReconcileProgress::Replacement {
2093 attempt: attempt.id,
2094 operation,
2095 }
2096 }),
2097 )
2098 }
2099 RecoveryDecision::Terminate(payload) => {
2100 let idle_exit = payload.is_idle_exit();
2110 self.ports
2111 .processes
2112 .record_terminate_intent(&attempt)
2113 .map_err(LifecycleError::Failed)?;
2114 self.ports.events.emit(AttemptEvent::TerminateIntent {
2115 attempt: attempt.id,
2116 });
2117 self.ports
2118 .processes
2119 .terminate(&attempt)
2120 .map_err(LifecycleError::Failed)?;
2121 if self
2122 .ports
2123 .processes
2124 .is_alive(&attempt)
2125 .map_err(LifecycleError::Failed)?
2126 {
2127 return Ok(ReconcileProgress::Deferred);
2128 }
2129 self.ports.events.emit(AttemptEvent::Terminated {
2130 attempt: attempt.id,
2131 });
2132 let outcome = if idle_exit {
2138 AttemptOutcome::ExitedIdleWithoutWork
2139 } else {
2140 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
2141 };
2142 self.deregister_runner(policy, &attempt).await;
2143 self.conclude(&mut attempt, outcome)?;
2144 self.clean_or_quarantine(&mut attempt)?;
2145 if idle_exit {
2149 Ok(ReconcileProgress::Reconciled)
2150 } else {
2151 Ok(ReconcileProgress::Replacement {
2152 attempt: attempt.id,
2153 operation: "registration_timeout_replacement",
2154 })
2155 }
2156 }
2157 }
2158 }
2159
2160 fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2161 self.ports
2162 .store
2163 .record_attempt(attempt)
2164 .map_err(|_| LifecycleError::Journal)?;
2165 self.ports.events.emit(AttemptEvent::State {
2166 attempt: attempt.id,
2167 state: attempt.state(),
2168 });
2169 Ok(())
2170 }
2171
2172 async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
2193 let Some(runner_id) = attempt
2194 .github_runner_id()
2195 .or_else(|| read_runner_id(attempt.runtime_path()))
2196 else {
2197 return;
2198 };
2199 if self
2200 .ports
2201 .github
2202 .deregister(&policy.target, runner_id, &self.cancel)
2203 .await
2204 {
2205 self.ports.events.emit(AttemptEvent::Deregistered {
2206 attempt: attempt.id,
2207 runner_id,
2208 });
2209 } else {
2210 tracing::warn!(
2211 attempt = %attempt.id,
2212 runner_id,
2213 "the runner registration could not be removed from GitHub; it will show in the \
2214 target's runner settings until GitHub retires it or a later pass removes it"
2215 );
2216 }
2217 }
2218
2219 fn conclude(
2220 &self,
2221 attempt: &mut RunnerAttempt,
2222 outcome: AttemptOutcome,
2223 ) -> Result<(), LifecycleError> {
2224 attempt
2225 .conclude(outcome.clone(), self.ports.clock.now())
2226 .map_err(|_| LifecycleError::Transition)?;
2227 self.record(attempt)?;
2228 self.ports.events.emit(AttemptEvent::Concluded {
2229 attempt: attempt.id,
2230 outcome: OutcomeKind::of(&outcome),
2231 });
2232 Ok(())
2233 }
2234
2235 fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
2251 match self.clean_attempt(attempt) {
2252 Err(LifecycleError::SlotQuarantined { class, .. }) => {
2253 self.ports
2254 .reconcile_events
2255 .emit(LifecycleEvent::AttemptCleanFailed {
2256 policy: attempt.policy_id,
2257 attempt: attempt.id,
2258 reason: class,
2259 });
2260 Ok(())
2261 }
2262 Err(LifecycleError::WorkspaceCleanupDeferred) => {
2263 self.ports
2264 .reconcile_events
2265 .emit(LifecycleEvent::AttemptCleanFailed {
2266 policy: attempt.policy_id,
2267 attempt: attempt.id,
2268 reason: "ephemeral_workspace_could_not_be_removed",
2269 });
2270 Ok(())
2271 }
2272 other => other,
2273 }
2274 }
2275
2276 fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
2277 let outcome = attempt
2278 .outcome()
2279 .cloned()
2280 .ok_or(LifecycleError::Transition)?;
2281 self.preserve_diagnostics(attempt, &outcome)?;
2282 self.scrub_workspace(attempt)?;
2283 self.ports
2284 .packages
2285 .release(attempt.id)
2286 .map_err(LifecycleError::Failed)?;
2287 attempt
2288 .clean(self.ports.clock.now())
2289 .map_err(|_| LifecycleError::Transition)?;
2290 self.record(attempt)?;
2291 let kind = OutcomeKind::of(&outcome);
2292 self.ports.events.emit(AttemptEvent::Cleaned {
2293 attempt: attempt.id,
2294 outcome: kind,
2295 });
2296 self.ports
2297 .reconcile_events
2298 .emit(LifecycleEvent::AttemptCleaned {
2299 policy: attempt.policy_id,
2300 attempt: attempt.id,
2301 outcome: kind,
2302 });
2303 Ok(())
2304 }
2305
2306 fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2316 #[cfg(test)]
2317 {
2318 if matches!(
2322 std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
2323 Ok("skip_workspace_cleanup" | "reuse_job_workspace")
2324 ) {
2325 return Ok(());
2326 }
2327 }
2328 match attempt.workspace() {
2329 AttemptWorkspace::Ephemeral => match remove_runtime_tree(attempt.runtime_path()) {
2330 Ok(()) => Ok(()),
2331 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
2332 Err(_) => Err(LifecycleError::WorkspaceCleanupDeferred),
2333 },
2334 AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
2335 }
2336 }
2337
2338 fn scrub_persistent_slot(
2351 &self,
2352 attempt: &RunnerAttempt,
2353 slot: NonZeroU16,
2354 ) -> Result<(), LifecycleError> {
2355 let configured = self
2360 .ports
2361 .store
2362 .policy(attempt.policy_id)
2363 .map_err(|_| LifecycleError::Journal)?
2364 .and_then(|policy| match policy.workspace_policy() {
2365 WorkspacePolicy::Persistent { root } => Some(root.clone()),
2366 WorkspacePolicy::Ephemeral => None,
2367 });
2368 let runtime = attempt.runtime_path();
2369 self.quarantine_on_refusal(
2370 attempt,
2371 verify_journalled_slot(runtime, slot, configured.as_ref())
2372 .and_then(|()| slot_is_present(runtime))
2373 .and_then(|present| {
2374 if present {
2375 scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
2376 } else {
2377 Ok(())
2378 }
2379 }),
2380 )
2381 }
2382
2383 fn quarantine_on_refusal(
2392 &self,
2393 attempt: &RunnerAttempt,
2394 outcome: Result<(), SlotQuarantine>,
2395 ) -> Result<(), LifecycleError> {
2396 let Err(quarantine) = outcome else {
2397 return Ok(());
2398 };
2399 let detail = quarantine.to_string();
2400 tracing::warn!(
2401 attempt = %attempt.id,
2402 policy = %attempt.policy_id,
2403 slot = attempt.workspace().slot_number(),
2404 refusal = quarantine.refusal.class(),
2405 "{detail}"
2406 );
2407 Err(LifecycleError::SlotQuarantined {
2408 class: quarantine.refusal.class(),
2409 detail,
2410 })
2411 }
2412
2413 fn preserve_diagnostics(
2414 &self,
2415 attempt: &RunnerAttempt,
2416 outcome: &AttemptOutcome,
2417 ) -> Result<(), LifecycleError> {
2418 fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
2419 LifecycleError::Failed(FailureReason::Other(
2420 "diagnostics directory could not be created".into(),
2421 ))
2422 })?;
2423 let diagnostic = format!(
2426 "attempt_id={}\npolicy_id={}\noutcome={}\n",
2427 attempt.id,
2428 attempt.policy_id,
2429 OutcomeKind::of(outcome).as_str()
2430 );
2431 fs::write(
2432 self.diagnostics_root.join(format!("{}.log", attempt.id)),
2433 diagnostic,
2434 )
2435 .map_err(|_| {
2436 LifecycleError::Failed(FailureReason::Other(
2437 "redacted diagnostics could not be preserved".into(),
2438 ))
2439 })
2440 }
2441
2442 async fn materialize_with_retry(
2443 &self,
2444 policy: &ScalePolicy,
2445 attempt: &RunnerAttempt,
2446 ) -> Result<RunnerVersion, FailureReason> {
2447 let mut issued = 0_u32;
2448 loop {
2449 issued = issued.saturating_add(1);
2450 match self.ports.packages.materialize(attempt).await {
2451 Ok(version) => return Ok(version),
2452 Err(reason)
2453 if package_failure_is_terminal(&reason)
2454 || issued >= self.retry.max_attempts.max(1) =>
2455 {
2456 return Err(reason);
2457 }
2458 Err(reason) => {
2459 if !self.ports.demand.persists(policy.id).await {
2460 return Err(reason);
2461 }
2462 let delay = self.retry.delay(issued);
2463 self.ports.events.emit(AttemptEvent::Retry {
2464 attempt: attempt.id,
2465 operation: "package_materialization",
2466 delay,
2467 });
2468 self.ports.delay.wait(delay).await;
2469 if !self.ports.demand.persists(policy.id).await {
2470 return Err(reason);
2471 }
2472 }
2473 }
2474 }
2475 }
2476
2477 async fn register_with_retry(
2478 &self,
2479 policy: &ScalePolicy,
2480 attempt: AttemptId,
2481 request: &JitRunnerRequest,
2482 ) -> Result<JitRegistration, LifecycleError> {
2483 let mut issued = 0_u32;
2484 loop {
2485 issued = issued.saturating_add(1);
2486 match self
2487 .ports
2488 .github
2489 .register(&policy.target, request, &self.cancel)
2490 .await
2491 {
2492 Ok(registration) => return Ok(registration),
2493 Err(error) if error.terminal => {
2494 return Err(LifecycleError::Failed(error.reason));
2495 }
2496 Err(error) => {
2497 if issued >= self.retry.max_attempts.max(1)
2498 || !self.ports.demand.persists(policy.id).await
2499 {
2500 return Err(LifecycleError::Failed(error.reason));
2501 }
2502 let delay = error
2503 .retry_after
2504 .unwrap_or_else(|| self.retry.delay(issued));
2505 self.ports.events.emit(AttemptEvent::Retry {
2506 attempt,
2507 operation: "jit_request",
2508 delay,
2509 });
2510 self.ports.delay.wait(delay).await;
2511 if !self.ports.demand.persists(policy.id).await {
2512 return Err(LifecycleError::Failed(error.reason));
2513 }
2514 }
2515 }
2516 }
2517 }
2518
2519 fn allocate_workspace(
2529 &self,
2530 policy: &ScalePolicy,
2531 id: AttemptId,
2532 ) -> Result<Placement, LifecycleError> {
2533 let placement = match policy.workspace_policy() {
2534 WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
2539 WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
2540 };
2541 if placement.is_ok() {
2544 self.root_accepted(policy.id);
2545 }
2546 placement
2547 }
2548
2549 fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
2556 let host = self
2557 .ports
2558 .store
2559 .host(self.host_id)
2560 .map_err(|_| LifecycleError::Journal)?
2561 .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
2562 Ok(host.runner_root_override.clone())
2563 }
2564
2565 fn effective_host_root(
2570 &self,
2571 policy: &ScalePolicy,
2572 ) -> Result<LocalAbsolutePath, LifecycleError> {
2573 match self.configured_host_root()? {
2574 Some(configured) => Ok(configured),
2575 None => default_runner_root(&self.app_paths).map_err(|error| {
2576 self.root_refused(policy.id, "the platform default runner root", error)
2579 }),
2580 }
2581 }
2582
2583 fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
2596 let _ = runner_manager_platform::service::record_runner_root_refusal(
2597 &self.app_paths,
2598 &policy.to_string(),
2599 self.ports.clock.now(),
2600 error.kind(),
2601 root,
2602 &error.to_string(),
2603 );
2604 root_failure(error)
2605 }
2606
2607 fn root_accepted(&self, policy: PolicyId) {
2614 let _ = runner_manager_platform::service::clear_runner_root_refusal(
2615 &self.app_paths,
2616 &policy.to_string(),
2617 );
2618 }
2619
2620 fn allocate_disposable(
2623 &self,
2624 policy: &ScalePolicy,
2625 id: AttemptId,
2626 ) -> Result<Placement, LifecycleError> {
2627 let effective_root = self.effective_host_root(policy)?;
2628 RootPreflight::new(&self.app_paths)
2629 .check(&RootOwner::Host, &effective_root)
2630 .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
2631 let runtime = effective_root.as_path().join({
2632 #[cfg(test)]
2633 {
2634 if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
2635 == Ok("reuse_job_workspace")
2636 {
2637 "mutant-shared-workspace".to_owned()
2638 } else {
2639 workspace_name(id)
2640 }
2641 }
2642 #[cfg(not(test))]
2643 {
2644 workspace_name(id)
2645 }
2646 });
2647 fs::create_dir_all(&runtime)
2648 .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
2649 Ok(Placement {
2650 runtime,
2651 workspace: AttemptWorkspace::Ephemeral,
2652 })
2653 }
2654
2655 fn allocate_persistent_slot(
2667 &self,
2668 policy: &ScalePolicy,
2669 root: &LocalAbsolutePath,
2670 ) -> Result<Placement, LifecycleError> {
2671 let ceiling = policy.max_capacity().ok_or_else(|| {
2674 LifecycleError::Failed(FailureReason::Other(
2675 "a persistent workspace needs the policy's max_capacity to bound its slots"
2676 .to_string(),
2677 ))
2678 })?;
2679 let leases = self
2680 .ports
2681 .store
2682 .slot_leases_for_policy(policy.id)
2683 .map_err(|_| LifecycleError::Journal)?;
2684 let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
2685 LifecycleError::Failed(FailureReason::Other(format!(
2686 "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
2687 not been cleaned, so no slot is free; raise the repository's max capacity, or \
2688 finish cleaning a concluded attempt",
2689 policy.target
2690 )))
2691 })?;
2692 let workspace = AttemptWorkspace::persistent_slot(slot);
2693 let name = workspace
2694 .slot_directory_name()
2695 .expect("a persistent allocation names its slot directory");
2696
2697 let host_root = self
2709 .configured_host_root()?
2710 .or_else(|| default_runner_root(&self.app_paths).ok());
2711 let mut preflight = RootPreflight::new(&self.app_paths);
2712 if let Some(host_root) = host_root {
2713 preflight = preflight.against(RootOwner::Host, host_root);
2714 }
2715 let checked = preflight
2716 .check(&RootOwner::Repository(policy.target.to_string()), root)
2717 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
2718 if let Some(leaf) = checked.leaf_to_create() {
2719 fs::create_dir(leaf).map_err(|source| {
2720 LifecycleError::Failed(FailureReason::Other(format!(
2721 "the persistent workspace root {} could not be created: {source}",
2722 leaf.display()
2723 )))
2724 })?;
2725 }
2726
2727 let slot_path = runner_root::derive_child(root, &name)
2731 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
2732 create_or_validate_slot(slot_path.as_path())?;
2733 runner_root::verify_containment(root, &slot_path)
2734 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
2735 accept_reusable_slot(slot_path.as_path())?;
2736 Ok(Placement {
2737 runtime: slot_path.as_path().to_path_buf(),
2738 workspace,
2739 })
2740 }
2741
2742 fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
2754 match self.ports.store.record_attempt(attempt) {
2755 Ok(()) => {
2756 self.ports.events.emit(AttemptEvent::State {
2757 attempt: attempt.id,
2758 state: attempt.state(),
2759 });
2760 Ok(())
2761 }
2762 Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
2763 FailureReason::Other(error.to_string()),
2764 )),
2765 Err(_) => Err(LifecycleError::Journal),
2766 }
2767 }
2768
2769 async fn launch_attempt(
2770 &self,
2771 policy: &ScalePolicy,
2772 allocation_guard: &AllocationGuard,
2773 ) -> Result<RunnerAttempt, LifecycleError> {
2774 if !*self
2775 .recovery_complete
2776 .lock()
2777 .map_err(|_| LifecycleError::Journal)?
2778 {
2779 return Err(LifecycleError::RecoveryIncomplete);
2780 }
2781 let labels = policy
2782 .routing_labels()
2783 .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
2784 let id = AttemptId::new_random();
2785 let placement = self.allocate_workspace(policy, id)?;
2786 let mut attempt = RunnerAttempt::allocate_in(
2787 id,
2788 policy.id,
2789 placement.runtime,
2790 placement.workspace,
2791 self.ports.clock.now(),
2792 );
2793 self.record_allocation(&attempt)?;
2798
2799 let version = match self.materialize_with_retry(policy, &attempt).await {
2800 Ok(version) => version,
2801 Err(reason) => return self.fail_launch(&mut attempt, reason),
2802 };
2803 self.prune_under_allocation_lock(allocation_guard, &version)?;
2804 self.versions
2805 .lock()
2806 .map_err(|_| LifecycleError::Journal)?
2807 .insert(id, version);
2808
2809 let jit_request =
2810 JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
2811 let registration = match self.register_with_retry(policy, id, &jit_request).await {
2812 Ok(registration) => registration,
2813 Err(error) => return self.fail_launch(&mut attempt, error.reason()),
2814 };
2815 let runner_id = registration.runner().id;
2816 write_runner_id(attempt.runtime_path(), runner_id)?;
2817 attempt
2818 .jit_received(self.ports.clock.now())
2819 .map_err(|_| LifecycleError::Transition)?;
2820 self.record(&attempt)?;
2821 let config = registration.into_config();
2822 let mut issued = 0_u32;
2823 let pid = loop {
2824 issued = issued.saturating_add(1);
2825 match self.ports.processes.spawn(&attempt, &config) {
2826 Ok(pid) => break pid,
2827 Err(error) => {
2828 if let Some(pid) = error.live_pid {
2829 attempt
2830 .started(pid, self.ports.clock.now())
2831 .map_err(|_| LifecycleError::Transition)?;
2832 self.record(&attempt)?;
2833 return Err(LifecycleError::Failed(error.reason));
2834 }
2835 if !error.retryable
2836 || issued >= self.retry.max_attempts.max(1)
2837 || !self.ports.demand.persists(policy.id).await
2838 {
2839 return self.fail_launch(&mut attempt, error.reason);
2840 }
2841 let delay = self.retry.delay(issued);
2842 self.ports.events.emit(AttemptEvent::Retry {
2843 attempt: attempt.id,
2844 operation: "process_start",
2845 delay,
2846 });
2847 self.ports.delay.wait(delay).await;
2848 if !self.ports.demand.persists(policy.id).await {
2849 return self.fail_launch(&mut attempt, error.reason);
2850 }
2851 }
2852 }
2853 };
2854 attempt
2855 .started(pid, self.ports.clock.now())
2856 .map_err(|_| LifecycleError::Transition)?;
2857 self.record(&attempt)?;
2858 Ok(attempt)
2859 }
2860
2861 fn fail_launch<T>(
2862 &self,
2863 attempt: &mut RunnerAttempt,
2864 reason: FailureReason,
2865 ) -> Result<T, LifecycleError> {
2866 self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
2867 Err(LifecycleError::Failed(reason))
2868 }
2869
2870 fn prune_under_allocation_lock(
2873 &self,
2874 guard: &AllocationGuard,
2875 version: &RunnerVersion,
2876 ) -> Result<(), LifecycleError> {
2877 let attempts = self
2878 .ports
2879 .store
2880 .attempts()
2881 .map_err(|_| LifecycleError::Journal)?;
2882 self.ports
2883 .packages
2884 .prune_obsolete_guarded(
2885 PruneAuthority::from_launch_request(guard),
2886 version,
2887 &attempts,
2888 )
2889 .map_err(LifecycleError::Failed)
2890 }
2891}
2892
2893#[async_trait]
2894impl RunnerLauncher for LifecycleLauncher {
2895 async fn supervise(
2896 &self,
2897 policy: &ScalePolicy,
2898 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
2899 LifecycleLauncher::supervise(self, policy)
2900 .await
2901 .map_err(|error| LaunchFailure::new(error.reason()))
2902 }
2903
2904 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
2905 self.ports.store.attempts().map_err(|_| {
2906 LaunchFailure::new(FailureReason::Other(
2907 "attempt journal could not be read".into(),
2908 ))
2909 })
2910 }
2911
2912 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
2913 self.launch_attempt(request.policy, request.allocation_guard)
2914 .await
2915 .map_err(|error| LaunchFailure::new(error.reason()))
2916 }
2917
2918 async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
2919 let mut attempt = self
2920 .ports
2921 .store
2922 .attempt(id)
2923 .map_err(|_| {
2924 LaunchFailure::new(FailureReason::Other(
2925 "attempt journal could not be read".into(),
2926 ))
2927 })?
2928 .ok_or_else(|| {
2929 LaunchFailure::new(FailureReason::Other(
2930 "attempt disappeared from the journal".into(),
2931 ))
2932 })?;
2933 self.clean_attempt(&mut attempt)
2934 .map_err(|error| LaunchFailure::new(error.reason()))
2935 }
2936}
2937
2938fn runner_name(attempt: AttemptId) -> String {
2939 format!("runner-manager-{attempt}")
2940}
2941
2942fn read_runner_id(runtime: &Path) -> Option<u64> {
2943 fs::read_to_string(runtime.join(RUNNER_ID_FILE))
2944 .ok()?
2945 .trim()
2946 .parse()
2947 .ok()
2948}
2949
2950fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
2951 let target = runtime.join(RUNNER_ID_FILE);
2952 if let Some(existing) = read_runner_id(runtime) {
2953 return (existing == runner_id)
2954 .then_some(())
2955 .ok_or(LifecycleError::Journal);
2956 }
2957 let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
2958 write_durable_file(&temporary, runner_id.to_string().as_bytes())
2959 .map_err(|_| LifecycleError::Journal)?;
2960 match fs::rename(&temporary, &target) {
2961 Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
2962 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
2963 let _ = fs::remove_file(&temporary);
2964 (read_runner_id(runtime) == Some(runner_id))
2965 .then_some(())
2966 .ok_or(LifecycleError::Journal)
2967 }
2968 Err(_) => {
2969 let _ = fs::remove_file(&temporary);
2970 Err(LifecycleError::Journal)
2971 }
2972 }
2973}
2974
2975fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
2976 let mut file = fs::OpenOptions::new()
2977 .create(true)
2978 .truncate(true)
2979 .write(true)
2980 .open(path)?;
2981 file.write_all(bytes)?;
2982 file.sync_all()?;
2983 let parent = path.parent().ok_or_else(|| {
2984 std::io::Error::new(
2985 std::io::ErrorKind::InvalidInput,
2986 "file has no parent directory",
2987 )
2988 })?;
2989 sync_directory(parent)
2990}
2991
2992#[cfg(unix)]
2993fn sync_directory(path: &Path) -> std::io::Result<()> {
2994 fs::File::open(path)?.sync_all()
2995}
2996
2997#[cfg(windows)]
2998fn sync_directory(path: &Path) -> std::io::Result<()> {
2999 use std::os::windows::fs::OpenOptionsExt;
3000
3001 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
3002 const FILE_SHARE_ALL: u32 = 0x0000_0007;
3003 const GENERIC_WRITE: u32 = 0x4000_0000;
3004 fs::OpenOptions::new()
3005 .access_mode(GENERIC_WRITE)
3006 .share_mode(FILE_SHARE_ALL)
3007 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
3008 .open(path)?
3009 .sync_all()
3010}
3011
3012#[cfg(test)]
3013mod tests {
3014 use super::*;
3015 use std::collections::BTreeSet;
3016 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
3017
3018 use crate::reconcile::{AllocationLock, InProcessAllocationLock};
3019 use runner_manager_domain::model::{Elapsed, TargetScope};
3020 use runner_manager_domain::store::SqliteStore;
3021 use runner_manager_github::jit::JitRunner;
3022 use runner_manager_testkit::clock::FakeClock;
3023 use runner_manager_testkit::fixtures;
3024
3025 type CapturedFields = Vec<(String, String)>;
3027
3028 #[derive(Clone, Default)]
3036 struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
3037
3038 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
3039 fn on_event(
3040 &self,
3041 event: &tracing::Event<'_>,
3042 _context: tracing_subscriber::layer::Context<'_, S>,
3043 ) {
3044 struct Collect(Vec<(String, String)>);
3045 impl tracing::field::Visit for Collect {
3046 fn record_debug(
3047 &mut self,
3048 field: &tracing::field::Field,
3049 value: &dyn std::fmt::Debug,
3050 ) {
3051 self.0.push((
3054 field.name().to_owned(),
3055 format!("{value:?}").trim_matches('"').to_owned(),
3056 ));
3057 }
3058 }
3059 let mut collected = Collect(Vec::new());
3060 event.record(&mut collected);
3061 self.0
3062 .lock()
3063 .expect("the capture mutex is not poisoned")
3064 .push(collected.0);
3065 }
3066 }
3067
3068 #[test]
3086 fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
3087 use runner_manager_platform::logging;
3088 use tracing_subscriber::layer::SubscriberExt as _;
3089
3090 let captured = CapturedEvents::default();
3091 let error = RunnerRootError::DeniedByPrivacyPolicy {
3092 requested: PathBuf::from("/Volumes/NVME/runners"),
3093 refused: PathBuf::from("/Volumes/NVME"),
3094 remediation: RootOwner::Host.remediation(),
3095 };
3096 let kind = error.kind();
3097
3098 let failure = tracing::subscriber::with_default(
3099 tracing_subscriber::registry().with(captured.clone()),
3100 || root_failure(error),
3101 );
3102
3103 assert!(
3108 matches!(
3109 &failure,
3110 LifecycleError::Failed(FailureReason::Other(detail))
3111 if detail.contains("/Volumes/NVME/runners")
3112 && detail.contains("Full Disk Access")
3113 ),
3114 "the reason must still carry the detail: {failure:?}"
3115 );
3116
3117 let events = captured
3118 .0
3119 .lock()
3120 .expect("the capture mutex is not poisoned")
3121 .clone();
3122 let event = events
3123 .iter()
3124 .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
3125 .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
3126
3127 for (name, value) in event {
3132 assert!(
3133 logging::is_field_allowed(name),
3134 "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
3135 logging::REDACTION
3136 );
3137 assert_eq!(
3138 &logging::redact(value),
3139 value,
3140 "`{name}` does not survive value-shape scrubbing: {event:?}"
3141 );
3142 }
3143 }
3144
3145 fn nz(slot: u16) -> NonZeroU16 {
3147 NonZeroU16::new(slot).expect("a positive slot")
3148 }
3149
3150 const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
3151
3152 #[derive(Debug, Default)]
3153 struct FakeGithubLifecycle {
3154 registration_failures: Mutex<VecDeque<bool>>,
3155 observations: Mutex<VecDeque<LifecycleGithubObservation>>,
3156 registrations: AtomicUsize,
3157 remaining_runners: AtomicUsize,
3158 deregistrations: Mutex<Vec<u64>>,
3162 deregistration_fails: AtomicBool,
3165 journal: Mutex<Option<Arc<SqliteStore>>>,
3170 registration_facts: Mutex<Vec<RegistrationFact>>,
3172 }
3173
3174 #[derive(Debug, Clone)]
3181 struct RegistrationFact {
3182 leased_slots: Vec<u16>,
3184 work_folder: String,
3186 runner_name: String,
3189 }
3190
3191 impl FakeGithubLifecycle {
3192 fn fail(mut self, terminal: bool) -> Self {
3193 self.registration_failures
3194 .get_mut()
3195 .expect("unpoisoned")
3196 .push_back(terminal);
3197 self
3198 }
3199
3200 fn watch_journal(&self, store: Arc<SqliteStore>) {
3201 *self.journal.lock().unwrap() = Some(store);
3202 }
3203
3204 fn registration_facts(&self) -> Vec<RegistrationFact> {
3205 self.registration_facts.lock().unwrap().clone()
3206 }
3207
3208 fn observe(&self, observation: GithubRunnerObservation) {
3209 let observation = match observation {
3210 GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
3211 GithubRunnerObservation::NotRegistered => {
3212 LifecycleGithubObservation::not_registered()
3213 }
3214 GithubRunnerObservation::Registered { busy } => {
3215 LifecycleGithubObservation::registered(73, busy)
3216 }
3217 };
3218 self.observations.lock().unwrap().push_back(observation);
3219 }
3220 }
3221
3222 #[async_trait]
3223 impl LifecycleGithub for FakeGithubLifecycle {
3224 async fn register(
3225 &self,
3226 _target: &ScaleTarget,
3227 request: &JitRunnerRequest,
3228 _cancel: &CancelToken,
3229 ) -> Result<JitRegistration, JitRequestFailure> {
3230 self.registrations.fetch_add(1, Ordering::SeqCst);
3231 if let Some(store) = self.journal.lock().unwrap().as_ref() {
3232 let slots = store
3233 .attempts()
3234 .expect("the journal is readable")
3235 .iter()
3236 .filter_map(|attempt| attempt.workspace().slot_number())
3237 .collect();
3238 self.registration_facts
3239 .lock()
3240 .unwrap()
3241 .push(RegistrationFact {
3242 leased_slots: slots,
3243 work_folder: request.work_folder().to_string(),
3244 runner_name: request.name().to_string(),
3245 });
3246 }
3247 if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
3248 return Err(JitRequestFailure {
3249 terminal,
3250 reason: if terminal {
3251 FailureReason::Other("GitHub refused JIT registration with 403".into())
3252 } else {
3253 FailureReason::JitRequestFailed
3254 },
3255 retry_after: None,
3256 });
3257 }
3258 self.remaining_runners.store(1, Ordering::SeqCst);
3259 Ok(JitRegistration::new(
3260 EncodedJitConfig::new(JIT),
3261 JitRunner {
3262 id: 73,
3263 name: request.name().to_string(),
3264 os: "windows".into(),
3265 status: "offline".into(),
3266 busy: false,
3267 runner_group_id: Some(1),
3268 labels: request.labels().to_vec(),
3269 },
3270 ))
3271 }
3272
3273 async fn observe(
3274 &self,
3275 _target: &ScaleTarget,
3276 _attempt: AttemptId,
3277 _cancel: &CancelToken,
3278 ) -> LifecycleGithubObservation {
3279 let observation = self
3280 .observations
3281 .lock()
3282 .unwrap()
3283 .pop_front()
3284 .unwrap_or(LifecycleGithubObservation::not_registered());
3285 if observation.status == GithubRunnerObservation::NotRegistered {
3286 self.remaining_runners.store(0, Ordering::SeqCst);
3287 }
3288 observation
3289 }
3290
3291 async fn deregister(
3292 &self,
3293 _target: &ScaleTarget,
3294 runner_id: u64,
3295 _cancel: &CancelToken,
3296 ) -> bool {
3297 self.deregistrations.lock().unwrap().push(runner_id);
3298 if self.deregistration_fails.load(Ordering::SeqCst) {
3299 return false;
3300 }
3301 self.remaining_runners.store(0, Ordering::SeqCst);
3302 true
3303 }
3304 }
3305
3306 #[derive(Debug)]
3307 struct FakePackages {
3308 version: RunnerVersion,
3309 leases: Mutex<BTreeSet<AttemptId>>,
3310 materializations: AtomicUsize,
3311 materialization_failures: AtomicUsize,
3312 release_failures: AtomicUsize,
3313 releases: AtomicUsize,
3314 prunes: AtomicUsize,
3315 prune_currents: Mutex<Vec<RunnerVersion>>,
3316 }
3317
3318 impl Default for FakePackages {
3319 fn default() -> Self {
3320 Self {
3321 version: RunnerVersion::parse("2.330.0").unwrap(),
3322 leases: Mutex::new(BTreeSet::new()),
3323 materializations: AtomicUsize::new(0),
3324 materialization_failures: AtomicUsize::new(0),
3325 release_failures: AtomicUsize::new(0),
3326 releases: AtomicUsize::new(0),
3327 prunes: AtomicUsize::new(0),
3328 prune_currents: Mutex::new(Vec::new()),
3329 }
3330 }
3331 }
3332
3333 impl FakePackages {
3334 fn fail_materializations(&self, count: usize) {
3335 self.materialization_failures.store(count, Ordering::SeqCst);
3336 }
3337
3338 fn fail_releases(&self, count: usize) {
3339 self.release_failures.store(count, Ordering::SeqCst);
3340 }
3341 }
3342
3343 #[async_trait]
3344 impl RuntimePackages for FakePackages {
3345 async fn materialize(
3346 &self,
3347 attempt: &RunnerAttempt,
3348 ) -> Result<RunnerVersion, FailureReason> {
3349 self.materializations.fetch_add(1, Ordering::SeqCst);
3350 if self
3351 .materialization_failures
3352 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
3353 if left > 0 { Some(left - 1) } else { None }
3354 })
3355 .is_ok()
3356 {
3357 return Err(FailureReason::Other(
3358 "runner package materialization failed transiently".into(),
3359 ));
3360 }
3361 fs::create_dir_all(attempt.runtime_path()).unwrap();
3362 fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
3363 self.leases.lock().unwrap().insert(attempt.id);
3364 Ok(self.version.clone())
3365 }
3366
3367 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
3368 if self
3369 .release_failures
3370 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
3371 if left > 0 { Some(left - 1) } else { None }
3372 })
3373 .is_ok()
3374 {
3375 return Err(FailureReason::Other(
3376 "runner package lease could not be released".into(),
3377 ));
3378 }
3379 self.leases.lock().unwrap().remove(&attempt);
3380 self.releases.fetch_add(1, Ordering::SeqCst);
3381 Ok(())
3382 }
3383
3384 fn prune_obsolete_guarded(
3385 &self,
3386 _authority: PruneAuthority<'_>,
3387 current: &RunnerVersion,
3388 _attempts: &[RunnerAttempt],
3389 ) -> Result<(), FailureReason> {
3390 self.prunes.fetch_add(1, Ordering::SeqCst);
3391 self.prune_currents.lock().unwrap().push(current.clone());
3392 Ok(())
3393 }
3394 }
3395
3396 #[derive(Debug, Default)]
3397 struct FakeProcesses {
3398 alive: AtomicBool,
3399 completed_successfully: AtomicBool,
3400 spawns: AtomicUsize,
3401 spawn_failures: AtomicUsize,
3402 live_spawn_failure: AtomicBool,
3403 terminations: AtomicUsize,
3404 intent: AtomicBool,
3405 intent_failure: AtomicBool,
3406 actions: Mutex<Vec<&'static str>>,
3407 saw_secret: AtomicBool,
3408 }
3409
3410 impl FakeProcesses {
3411 fn fail_spawns(&self, count: usize) {
3412 self.spawn_failures.store(count, Ordering::SeqCst);
3413 }
3414
3415 fn fail_spawn_with_live_child(&self) {
3416 self.live_spawn_failure.store(true, Ordering::SeqCst);
3417 }
3418
3419 fn set_alive(&self, alive: bool) {
3420 self.alive.store(alive, Ordering::SeqCst);
3421 }
3422
3423 fn finish_successfully(&self) {
3424 self.completed_successfully.store(true, Ordering::SeqCst);
3425 self.alive.store(false, Ordering::SeqCst);
3426 }
3427
3428 fn fail_intent(&self) {
3429 self.intent_failure.store(true, Ordering::SeqCst);
3430 }
3431 }
3432
3433 impl ProcessSupervisor for FakeProcesses {
3434 fn spawn(
3435 &self,
3436 attempt: &RunnerAttempt,
3437 config: &EncodedJitConfig,
3438 ) -> Result<u32, ProcessStartFailure> {
3439 self.spawns.fetch_add(1, Ordering::SeqCst);
3440 let handoff = RestrictiveHandoff::create(
3443 attempt.runtime_path(),
3444 SecretString::from(config.expose().to_owned()),
3445 )
3446 .unwrap();
3447 self.saw_secret
3448 .store(config.expose() == JIT, Ordering::SeqCst);
3449 let handoff_path = handoff.path().to_path_buf();
3450 let failing = self
3451 .spawn_failures
3452 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
3453 if left > 0 { Some(left - 1) } else { None }
3454 })
3455 .is_ok();
3456 drop(handoff);
3457 assert!(!handoff_path.exists(), "handoff must be absent on return");
3458 if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
3459 self.alive.store(true, Ordering::SeqCst);
3460 return Err(ProcessStartFailure::after_spawn_live(4242));
3461 }
3462 if failing {
3463 return Err(ProcessStartFailure::before_spawn(
3464 FailureReason::ProcessStartFailed,
3465 ));
3466 }
3467 self.alive.store(true, Ordering::SeqCst);
3468 Ok(4242)
3469 }
3470
3471 fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
3472 self.actions.lock().unwrap().push("observe_process");
3473 Ok(self.alive.load(Ordering::SeqCst))
3474 }
3475
3476 fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
3477 Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
3478 }
3479
3480 fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
3481 self.completed_successfully.load(Ordering::SeqCst)
3482 }
3483
3484 fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3485 self.actions.lock().unwrap().push("terminate_intent");
3486 if self.intent_failure.load(Ordering::SeqCst) {
3487 return Err(FailureReason::Other(
3488 "terminate intent directory sync failed".into(),
3489 ));
3490 }
3491 self.intent.store(true, Ordering::SeqCst);
3492 Ok(())
3493 }
3494
3495 fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
3496 self.intent.load(Ordering::SeqCst)
3497 }
3498
3499 fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3500 assert!(
3501 self.intent.load(Ordering::SeqCst),
3502 "the durable intent must exist before signalling"
3503 );
3504 self.actions.lock().unwrap().push("terminate");
3505 self.terminations.fetch_add(1, Ordering::SeqCst);
3506 self.alive.store(false, Ordering::SeqCst);
3507 Ok(())
3508 }
3509 }
3510
3511 #[derive(Debug, Default)]
3512 struct FakeDemand {
3513 answers: Mutex<VecDeque<bool>>,
3514 }
3515
3516 impl FakeDemand {
3517 fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
3518 Self {
3519 answers: Mutex::new(answers.into_iter().collect()),
3520 }
3521 }
3522 }
3523
3524 #[async_trait]
3525 impl DemandPersistence for FakeDemand {
3526 async fn persists(&self, _policy: PolicyId) -> bool {
3527 self.answers.lock().unwrap().pop_front().unwrap_or(true)
3528 }
3529 }
3530
3531 #[derive(Debug, Default)]
3532 struct FakeDelay(Mutex<Vec<Duration>>);
3533
3534 #[async_trait]
3535 impl RetryDelay for FakeDelay {
3536 async fn wait(&self, duration: Duration) {
3537 self.0.lock().unwrap().push(duration);
3538 }
3539 }
3540
3541 struct Harness {
3542 _root: tempfile::TempDir,
3543 app_paths: runner_manager_platform::paths::AppPaths,
3544 launcher: LifecycleLauncher,
3545 demand: Arc<dyn DemandPersistence>,
3546 store: Arc<SqliteStore>,
3547 github: Arc<FakeGithubLifecycle>,
3548 packages: Arc<FakePackages>,
3549 processes: Arc<FakeProcesses>,
3550 clock: Arc<FakeClock>,
3551 events: Arc<AttemptEventLog>,
3552 reconcile_events: Arc<crate::reconcile::EventLog>,
3553 delay: Arc<FakeDelay>,
3554 host: runner_manager_domain::model::Host,
3555 policy: ScalePolicy,
3556 allocation_lock: InProcessAllocationLock,
3557 workspace_root: Option<LocalAbsolutePath>,
3559 }
3560
3561 impl Harness {
3562 fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
3563 let root = tempfile::tempdir().unwrap();
3564 let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
3565 paths.create_all().unwrap();
3566 let policy = fixtures::policy()
3567 .repository("octo/repo")
3568 .autoscale("home", 2)
3569 .active()
3570 .build();
3571 let host_root = root.path().join("host-root");
3577 fs::create_dir_all(&host_root).unwrap();
3578 let mut host = fixtures::host().build();
3579 host.runner_root_override = Some(
3580 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
3581 .expect("a local absolute host root"),
3582 );
3583 let store = Arc::new(SqliteStore::open_in_memory().unwrap());
3584 store.put_host(&host).unwrap();
3585 let github = Arc::new(github);
3586 let packages = Arc::new(FakePackages::default());
3587 let processes = Arc::new(FakeProcesses::default());
3588 let clock = Arc::new(FakeClock::default());
3589 let events = Arc::new(AttemptEventLog::default());
3590 let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
3591 let delay = Arc::new(FakeDelay::default());
3592 let ports = LifecyclePorts {
3593 store: Arc::clone(&store) as Arc<dyn Store>,
3594 github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
3595 packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
3596 processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
3597 clock: Arc::clone(&clock) as Arc<dyn Clock>,
3598 demand: Arc::clone(&demand),
3599 delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
3600 events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
3601 reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
3602 };
3603 let launcher = Self::launcher_over(policy.host_id, &paths, ports);
3604 Self {
3605 _root: root,
3606 app_paths: paths,
3607 launcher,
3608 demand,
3609 store,
3610 github,
3611 packages,
3612 processes,
3613 clock,
3614 events,
3615 reconcile_events,
3616 delay,
3617 host,
3618 policy,
3619 allocation_lock: InProcessAllocationLock::new(),
3620 workspace_root: None,
3621 }
3622 }
3623
3624 fn with_host_runner_root(mut self) -> Self {
3631 let host_root = self.host_root();
3632 fs::create_dir_all(&host_root).unwrap();
3633 self.host.runner_root_override = Some(
3634 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
3635 .expect("a local absolute host root"),
3636 );
3637 self.store.put_host(&self.host).unwrap();
3638 self
3639 }
3640
3641 fn with_persistent_workspace(mut self, capacity: u16) -> Self {
3643 self = self.with_host_runner_root();
3644 let root = self._root.path().join("persist");
3645 let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
3646 .expect("a local absolute workspace root");
3647 self.policy = fixtures::policy()
3648 .repository("octo/repo")
3649 .autoscale("home", capacity)
3650 .active()
3651 .build();
3652 self.policy
3653 .set_workspace_policy(
3654 WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
3655 .expect("a repository may be persistent"),
3656 )
3657 .expect("a repository may be persistent");
3658 self.workspace_root = Some(root);
3659 self.store.insert_policy(&self.policy).unwrap();
3662 self
3663 }
3664
3665 fn workspace_root(&self) -> &LocalAbsolutePath {
3666 self.workspace_root
3667 .as_ref()
3668 .expect("this harness configured a persistent workspace")
3669 }
3670
3671 fn slot_path(&self, slot: u16) -> PathBuf {
3672 self.workspace_root().as_path().join(format!("s{slot}"))
3673 }
3674
3675 fn host_root(&self) -> PathBuf {
3676 self._root.path().join("host-root")
3677 }
3678
3679 fn attempt(&self, id: AttemptId) -> RunnerAttempt {
3680 self.store
3681 .attempt(id)
3682 .unwrap()
3683 .expect("the attempt is journalled")
3684 }
3685
3686 fn conclude(&self, id: AttemptId) -> RunnerAttempt {
3688 let mut attempt = self.attempt(id);
3689 attempt
3690 .conclude(
3691 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
3692 self.clock.now(),
3693 )
3694 .unwrap();
3695 self.store.record_attempt(&attempt).unwrap();
3696 attempt
3697 }
3698
3699 async fn cleanup_retaining_work(&self, id: AttemptId) {
3700 self.conclude(id);
3701 self.launcher
3702 .clean(id)
3703 .await
3704 .expect("the slot is scrubbed and the lease released");
3705 }
3706
3707 fn launcher_over(
3710 host: HostId,
3711 paths: &runner_manager_platform::paths::AppPaths,
3712 ports: LifecyclePorts,
3713 ) -> LifecycleLauncher {
3714 LifecycleLauncher::new(
3715 host,
3716 paths.clone(),
3717 paths.logs_dir(),
3718 1,
3719 RecoveryTimeouts::new(
3720 Elapsed::seconds(10),
3721 Elapsed::seconds(10),
3722 Elapsed::seconds(10),
3723 ),
3724 RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
3725 ports,
3726 )
3727 }
3728
3729 fn restart(&self) -> LifecycleLauncher {
3732 Self::launcher_over(
3733 self.policy.host_id,
3734 &self.app_paths,
3735 LifecyclePorts {
3736 store: Arc::clone(&self.store) as Arc<dyn Store>,
3737 github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
3738 packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
3739 processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
3740 clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
3741 demand: Arc::clone(&self.demand),
3742 delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
3743 events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
3744 reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
3745 },
3746 )
3747 }
3748
3749 async fn ready(&self) {
3750 self.launcher
3751 .recover_startup(std::slice::from_ref(&self.policy))
3752 .await
3753 .unwrap();
3754 }
3755
3756 async fn launch(&self) -> RunnerAttempt {
3757 self.launch_result().await.unwrap()
3758 }
3759
3760 async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
3761 let guard = self.allocation_lock.acquire().await.unwrap();
3762 self.launcher
3763 .launch(LaunchRequest {
3764 host: &self.host,
3765 policy: &self.policy,
3766 allocation_guard: &guard,
3767 })
3768 .await
3769 }
3770
3771 fn only_attempt(&self) -> RunnerAttempt {
3772 self.store.attempts().unwrap().into_iter().next().unwrap()
3773 }
3774 }
3775
3776 #[tokio::test]
3785 async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
3786 use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
3787
3788 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
3789 .with_host_runner_root();
3790 harness.ready().await;
3791
3792 let unusable = harness
3796 ._root
3797 .path()
3798 .join("absent")
3799 .join("deeper")
3800 .join("runners");
3801 let mut host = harness.host.clone();
3802 host.runner_root_override = Some(
3803 LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
3804 .expect("a local absolute host root"),
3805 );
3806 harness.store.put_host(&host).unwrap();
3807
3808 let failure = harness
3809 .launch_result()
3810 .await
3811 .expect_err("a root whose parents are missing cannot hold a runner");
3812 assert!(
3813 matches!(failure.reason, FailureReason::Other(_)),
3814 "{failure:?}"
3815 );
3816
3817 let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
3818 let refusal = refusals
3819 .first()
3820 .expect("the refusal reached the one surface that can hold it");
3821 assert_eq!(refusal.policy, harness.policy.id.to_string());
3822 assert_eq!(refusal.kind, "missing_parents");
3823 assert!(
3824 refusal.root.contains("runners") && refusal.detail.contains("runners"),
3825 "the directory must be named in full: {refusal:?}"
3826 );
3827
3828 harness.store.put_host(&harness.host).unwrap();
3831 harness.launch().await;
3832 assert!(
3833 runner_root_refusals(&harness.app_paths)
3834 .expect("readable")
3835 .is_empty(),
3836 "a successful placement clears that policy's record"
3837 );
3838
3839 clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
3840 .expect("cleanup");
3841 }
3842
3843 #[tokio::test]
3844 async fn a_job_walks_every_state_and_cleans_every_artifact() {
3845 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3846 harness.ready().await;
3847 let started = harness.launch().await;
3848 assert_eq!(started.state(), AttemptState::Starting);
3849 assert_eq!(read_runner_id(started.runtime_path()), Some(73));
3850
3851 harness
3852 .github
3853 .observe(GithubRunnerObservation::Registered { busy: false });
3854 harness.launcher.supervise(&harness.policy).await.unwrap();
3855 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
3856
3857 harness
3858 .github
3859 .observe(GithubRunnerObservation::Registered { busy: true });
3860 harness.launcher.supervise(&harness.policy).await.unwrap();
3861 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
3862
3863 harness.processes.finish_successfully();
3864 harness
3865 .github
3866 .observe(GithubRunnerObservation::NotRegistered);
3867 harness.launcher.supervise(&harness.policy).await.unwrap();
3868 let cleaned = harness.only_attempt();
3869 assert_eq!(cleaned.state(), AttemptState::Cleaned);
3870 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
3871 assert!(!started.runtime_path().exists());
3872 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
3873 assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
3874
3875 let states: Vec<_> = harness
3876 .events
3877 .events()
3878 .into_iter()
3879 .filter_map(|event| match event {
3880 AttemptEvent::State { state, .. } => Some(state),
3881 _ => None,
3882 })
3883 .collect();
3884 assert_eq!(
3885 states,
3886 vec![
3887 AttemptState::Allocated,
3888 AttemptState::JitReceived,
3889 AttemptState::Starting,
3890 AttemptState::Idle,
3891 AttemptState::Busy,
3892 AttemptState::Finished,
3893 AttemptState::Cleaned,
3894 ]
3895 );
3896 }
3897
3898 #[tokio::test]
3899 async fn a_cleaned_ephemeral_attempt_reaps_a_directory_recreated_after_cleanup() {
3900 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3901 harness.ready().await;
3902 let started = harness.launch().await;
3903 let runtime = started.runtime_path().to_path_buf();
3904
3905 harness
3906 .github
3907 .observe(GithubRunnerObservation::Registered { busy: true });
3908 harness.launcher.supervise(&harness.policy).await.unwrap();
3909 harness.processes.finish_successfully();
3910 harness
3911 .github
3912 .observe(GithubRunnerObservation::NotRegistered);
3913 harness.launcher.supervise(&harness.policy).await.unwrap();
3914
3915 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
3916 assert!(!runtime.exists());
3917 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
3918
3919 let residue = runtime.join("_work").join("late-node-process");
3923 fs::create_dir_all(&residue).unwrap();
3924 fs::write(residue.join("node_modules.lock"), b"late residue").unwrap();
3925
3926 harness.launcher.supervise(&harness.policy).await.unwrap();
3927
3928 assert!(!runtime.exists(), "late ephemeral residue is reaped");
3929 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
3930 assert_eq!(
3931 harness.packages.releases.load(Ordering::SeqCst),
3932 1,
3933 "reaping residue does not release the package lease twice"
3934 );
3935 }
3936
3937 #[tokio::test]
3938 async fn locked_late_residue_never_blocks_startup_recovery() {
3939 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
3940 harness.ready().await;
3941 let started = harness.launch().await;
3942 let runtime = started.runtime_path().to_path_buf();
3943
3944 harness
3945 .github
3946 .observe(GithubRunnerObservation::Registered { busy: true });
3947 harness.launcher.supervise(&harness.policy).await.unwrap();
3948 harness.processes.finish_successfully();
3949 harness
3950 .github
3951 .observe(GithubRunnerObservation::NotRegistered);
3952 harness.launcher.supervise(&harness.policy).await.unwrap();
3953 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
3954
3955 let held = runtime.join("_work").join("late-node-process");
3956 fs::create_dir_all(&held).unwrap();
3957 fs::write(held.join("node_modules.lock"), b"late residue").unwrap();
3958 let Some(block) = BlockedDeletion::inject(&held) else {
3959 eprintln!(
3960 "skipped: this account cannot be refused a deletion, so locked late residue cannot be injected"
3961 );
3962 return;
3963 };
3964
3965 let restarted = harness.restart();
3966 restarted
3967 .recover_startup(std::slice::from_ref(&harness.policy))
3968 .await
3969 .expect("late residue cannot take the daemon offline");
3970 assert!(runtime.exists(), "the locked residue remains for a retry");
3971 assert!(
3972 harness
3973 .reconcile_events
3974 .events()
3975 .iter()
3976 .any(|event| matches!(
3977 event,
3978 LifecycleEvent::AttemptCleanFailed {
3979 attempt,
3980 reason: "late_ephemeral_workspace_could_not_be_removed",
3981 ..
3982 } if *attempt == started.id
3983 )),
3984 "the non-blocking cleanup failure remains visible"
3985 );
3986
3987 block.release();
3988 restarted
3989 .supervise(&harness.policy)
3990 .await
3991 .expect("the ordinary retry succeeds after the lock is released");
3992 assert!(!runtime.exists(), "the retry removes the late residue");
3993 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
3994 assert_eq!(
3995 harness.packages.releases.load(Ordering::SeqCst),
3996 1,
3997 "retrying residue never releases the package lease twice"
3998 );
3999 }
4000
4001 #[tokio::test]
4002 async fn mixed_locked_cleanup_residue_isolated_from_the_whole_startup_pass() {
4003 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4004 harness.ready().await;
4005
4006 let terminal = harness.launch().await;
4010 let terminal_runtime = terminal.runtime_path().to_path_buf();
4011 harness.conclude(terminal.id);
4012 let Some(terminal_block) = BlockedDeletion::inject(&terminal_runtime.join("late-child"))
4013 else {
4014 eprintln!("skipped: this account cannot inject the two independent deletion refusals");
4015 return;
4016 };
4017
4018 let cleaned = harness.launch().await;
4022 let cleaned_runtime = cleaned.runtime_path().to_path_buf();
4023 harness.conclude(cleaned.id);
4024 harness
4025 .launcher
4026 .clean(cleaned.id)
4027 .await
4028 .expect("the second attempt initially cleans");
4029 let Some(cleaned_block) =
4030 BlockedDeletion::inject(&cleaned_runtime.join("_work").join("late-child"))
4031 else {
4032 terminal_block.release();
4033 eprintln!("skipped: this account cannot inject the two independent deletion refusals");
4034 return;
4035 };
4036
4037 let restarted = harness.restart();
4038 for _ in 0..2 {
4039 restarted
4040 .recover_startup(std::slice::from_ref(&harness.policy))
4041 .await
4042 .expect("repeated recovery remains ready while both residues are locked");
4043 }
4044 assert_ne!(
4045 harness.attempt(terminal.id).state(),
4046 AttemptState::Cleaned,
4047 "the journal does not claim the terminal workspace was removed"
4048 );
4049 assert_eq!(
4050 harness.attempt(cleaned.id).state(),
4051 AttemptState::Cleaned,
4052 "late residue does not undo an already durable cleaned transition"
4053 );
4054 assert!(terminal_runtime.exists());
4055 assert!(cleaned_runtime.exists());
4056 assert_eq!(
4057 harness.packages.releases.load(Ordering::SeqCst),
4058 1,
4059 "repeated recovery neither loses nor duplicates package leases"
4060 );
4061
4062 terminal_block.release();
4063 cleaned_block.release();
4064 restarted
4065 .supervise(&harness.policy)
4066 .await
4067 .expect("one ordinary pass clears both residues after their locks disappear");
4068 assert!(!terminal_runtime.exists());
4069 assert!(!cleaned_runtime.exists());
4070 assert_eq!(harness.attempt(terminal.id).state(), AttemptState::Cleaned);
4071 assert_eq!(harness.attempt(cleaned.id).state(), AttemptState::Cleaned);
4072 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 2);
4073 }
4074
4075 #[tokio::test]
4076 async fn a_terminal_attempt_whose_runtime_is_already_gone_recovers_cleanly() {
4077 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4078 harness.ready().await;
4079 let attempt = harness.launch().await;
4080 let runtime = attempt.runtime_path().to_path_buf();
4081 harness.conclude(attempt.id);
4082 fs::remove_dir_all(&runtime).unwrap();
4083
4084 harness
4085 .restart()
4086 .recover_startup(std::slice::from_ref(&harness.policy))
4087 .await
4088 .expect("an already absent disposable runtime is successful cleanup");
4089
4090 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
4091 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
4092 }
4093
4094 #[tokio::test]
4095 async fn a_non_workspace_cleanup_failure_still_fails_closed_and_can_retry() {
4096 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4097 harness.ready().await;
4098 let attempt = harness.launch().await;
4099 let runtime = attempt.runtime_path().to_path_buf();
4100 harness.conclude(attempt.id);
4101 harness.packages.fail_releases(1);
4102
4103 let restarted = harness.restart();
4104 let failure = restarted
4105 .recover_startup(std::slice::from_ref(&harness.policy))
4106 .await
4107 .expect_err("a package-accounting failure is not safe to downgrade to residue");
4108 assert!(
4109 failure
4110 .reason()
4111 .to_string()
4112 .contains("package lease could not be released")
4113 );
4114 assert!(
4115 !runtime.exists(),
4116 "workspace removal completed before release failed"
4117 );
4118 assert_ne!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
4119
4120 restarted
4121 .recover_startup(std::slice::from_ref(&harness.policy))
4122 .await
4123 .expect("the same journal entry retries safely after the transient failure");
4124 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
4125 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
4126 }
4127
4128 #[tokio::test]
4129 async fn startup_never_scrubs_a_cleaned_persistent_work_directory() {
4130 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
4131 .with_persistent_workspace(1);
4132 harness.ready().await;
4133 let attempt = harness.launch().await;
4134 let retained = attempt.runtime_path().join("_work").join("checkout-marker");
4135 fs::create_dir_all(retained.parent().unwrap()).unwrap();
4136 fs::write(&retained, b"persistent checkout").unwrap();
4137 harness.cleanup_retaining_work(attempt.id).await;
4138
4139 harness
4140 .restart()
4141 .recover_startup(std::slice::from_ref(&harness.policy))
4142 .await
4143 .expect("a cleaned persistent slot is already reconciled");
4144
4145 assert_eq!(fs::read(&retained).unwrap(), b"persistent checkout");
4146 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
4147 }
4148
4149 #[tokio::test]
4150 async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
4151 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4152 harness.ready().await;
4153 let started = harness.launch().await;
4154 harness
4155 .github
4156 .observe(GithubRunnerObservation::Registered { busy: false });
4157 harness.launcher.supervise(&harness.policy).await.unwrap();
4158 harness.clock.advance_secs(11);
4159 harness.processes.set_alive(false);
4160 harness
4161 .github
4162 .observe(GithubRunnerObservation::NotRegistered);
4163 harness.launcher.supervise(&harness.policy).await.unwrap();
4164
4165 let cleaned = harness.only_attempt();
4166 assert!(cleaned.outcome().unwrap().is_idle_exit());
4167 assert!(!cleaned.outcome().unwrap().is_failure());
4168 assert!(!started.runtime_path().exists());
4169 assert!(
4170 harness
4171 .reconcile_events
4172 .events()
4173 .iter()
4174 .any(|event| matches!(
4175 event,
4176 LifecycleEvent::AttemptCleaned {
4177 outcome: OutcomeKind::IdleExit,
4178 ..
4179 }
4180 ))
4181 );
4182 assert!(!harness.events.events().iter().any(|event| matches!(
4183 event,
4184 AttemptEvent::Concluded {
4185 outcome: OutcomeKind::Failed,
4186 ..
4187 }
4188 )));
4189 }
4190
4191 #[tokio::test]
4192 async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
4193 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4194 harness.processes.fail_spawns(2);
4195 harness.ready().await;
4196 let attempt = harness.launch().await;
4197 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
4198 assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
4199 let names: Vec<_> = fs::read_dir(attempt.runtime_path())
4200 .unwrap()
4201 .map(|entry| entry.unwrap().file_name())
4202 .collect();
4203 assert!(
4204 names.iter().all(|name| {
4205 !name
4206 .to_string_lossy()
4207 .starts_with(RestrictiveHandoff::NAME_PREFIX)
4208 }),
4209 "JIT artifact survived: {names:?}"
4210 );
4211 assert_eq!(
4212 *harness.delay.0.lock().unwrap(),
4213 vec![Duration::from_millis(10), Duration::from_millis(20)]
4214 );
4215 }
4216
4217 #[tokio::test]
4218 async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
4219 let gone = Harness::new(
4220 FakeGithubLifecycle::default().fail(false),
4221 Arc::new(FakeDemand::answering([false])),
4222 );
4223 gone.ready().await;
4224 assert!(gone.launch_result().await.is_err());
4225 assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
4226 assert!(gone.delay.0.lock().unwrap().is_empty());
4227
4228 let forbidden = Harness::new(
4229 FakeGithubLifecycle::default().fail(true),
4230 Arc::new(PersistentDemand),
4231 );
4232 forbidden.ready().await;
4233 assert!(forbidden.launch_result().await.is_err());
4234 assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
4235 assert!(forbidden.delay.0.lock().unwrap().is_empty());
4236 assert!(matches!(
4237 forbidden.only_attempt().outcome(),
4238 Some(AttemptOutcome::Failed {
4239 reason: FailureReason::Other(action)
4240 }) if action.contains("403")
4241 ));
4242
4243 let transient = Harness::new(
4244 FakeGithubLifecycle::default().fail(false).fail(false),
4245 Arc::new(PersistentDemand),
4246 );
4247 transient.ready().await;
4248 transient.launch().await;
4249 assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
4250 assert_eq!(
4251 *transient.delay.0.lock().unwrap(),
4252 vec![Duration::from_millis(10), Duration::from_millis(20)]
4253 );
4254 }
4255
4256 #[test]
4264 fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
4265 const MAX_PATH: usize = 260;
4266 let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
4268 let repo = "GitHub-Runner-Scaler-UI";
4272 let deepest = format!(
4273 r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
4274 "0".repeat(40)
4275 );
4276
4277 let name = workspace_name(AttemptId::new_random());
4278 assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
4279 assert!(
4280 name.chars().all(|c| c.is_ascii_hexdigit()),
4281 "a directory name must not carry the identifier's dashes: {name}"
4282 );
4283
4284 let full = format!(r"{root}\{name}\{deepest}");
4285 assert!(
4286 full.len() < MAX_PATH,
4287 "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
4288 full.len()
4289 );
4290
4291 let old = format!(
4294 r"{root}\{}\{}\{deepest}",
4295 PolicyId::new_random(),
4296 AttemptId::new_random()
4297 );
4298 assert!(
4299 old.len() > MAX_PATH,
4300 "the old layout is supposed to be the thing that did not fit: {} characters",
4301 old.len()
4302 );
4303 }
4304
4305 #[tokio::test]
4306 async fn two_attempts_never_share_a_workspace_even_after_failure() {
4307 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4308 harness.ready().await;
4309 let first = harness.launch().await;
4310 fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
4311 harness
4312 .github
4313 .observe(GithubRunnerObservation::Registered { busy: false });
4314 harness.launcher.supervise(&harness.policy).await.unwrap();
4315 harness.clock.advance_secs(11);
4316 harness.processes.set_alive(false);
4317 harness
4318 .github
4319 .observe(GithubRunnerObservation::NotRegistered);
4320 harness.launcher.supervise(&harness.policy).await.unwrap();
4321 assert!(!first.runtime_path().exists());
4322
4323 let second = harness.launch().await;
4324 assert_ne!(first.runtime_path(), second.runtime_path());
4325 assert!(!second.runtime_path().join("hostile-leftover").exists());
4326
4327 fs::write(
4328 second.runtime_path().join("hostile-on-failure"),
4329 b"second job",
4330 )
4331 .unwrap();
4332 harness.processes.set_alive(false);
4333 harness
4334 .github
4335 .observe(GithubRunnerObservation::NotRegistered);
4336 harness.launcher.supervise(&harness.policy).await.unwrap();
4337 assert!(
4338 !second.runtime_path().exists(),
4339 "failed workspace was retained"
4340 );
4341 }
4342
4343 #[tokio::test]
4344 async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
4345 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4346 harness.ready().await;
4347 let attempt = harness.launch().await;
4348
4349 harness
4353 .github
4354 .observe(GithubRunnerObservation::Registered { busy: false });
4355 harness.launcher.supervise(&harness.policy).await.unwrap();
4356 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
4357
4358 harness.clock.advance_secs(9);
4361 harness
4362 .github
4363 .observe(GithubRunnerObservation::Registered { busy: false });
4364 let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
4365 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
4366 assert!(none_yet.is_empty());
4367 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
4368
4369 harness.clock.advance_secs(1);
4371 harness
4372 .github
4373 .observe(GithubRunnerObservation::Registered { busy: false });
4374 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4375
4376 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
4377 assert_eq!(
4378 concluded.outcome(),
4379 Some(&AttemptOutcome::ExitedIdleWithoutWork),
4380 "a surplus runner did not fail; recording one as a failure sends an operator \
4381 hunting a fault that does not exist"
4382 );
4383 assert_eq!(concluded.state(), AttemptState::Cleaned);
4384 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
4385 assert!(!attempt.runtime_path().exists());
4386
4387 assert_eq!(
4390 *harness.github.deregistrations.lock().unwrap(),
4391 vec![73],
4392 "the attempt's own runner id, deleted exactly once"
4393 );
4394
4395 assert!(
4398 replacements.is_empty(),
4399 "a surplus exit must not request a replacement"
4400 );
4401 }
4402
4403 #[tokio::test]
4404 async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
4405 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4409 harness.ready().await;
4410 let attempt = harness.launch().await;
4411 harness
4412 .github
4413 .observe(GithubRunnerObservation::Registered { busy: false });
4414 harness.launcher.supervise(&harness.policy).await.unwrap();
4415
4416 harness
4417 .github
4418 .deregistration_fails
4419 .store(true, Ordering::SeqCst);
4420 harness.clock.advance_secs(11);
4421 harness
4422 .github
4423 .observe(GithubRunnerObservation::Registered { busy: false });
4424 harness.launcher.supervise(&harness.policy).await.unwrap();
4425
4426 assert_eq!(
4427 *harness.github.deregistrations.lock().unwrap(),
4428 vec![73],
4429 "the delete was attempted"
4430 );
4431 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
4432 assert_eq!(
4433 concluded.outcome(),
4434 Some(&AttemptOutcome::ExitedIdleWithoutWork),
4435 "the attempt concluded anyway"
4436 );
4437 assert_eq!(concluded.state(), AttemptState::Cleaned);
4438 assert!(!attempt.runtime_path().exists());
4439 }
4440
4441 #[tokio::test]
4442 async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
4443 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4444 harness.ready().await;
4445 let first = harness.launch().await;
4446 harness.processes.set_alive(false);
4447 harness
4448 .github
4449 .observe(GithubRunnerObservation::NotRegistered);
4450 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4451 let failed = harness.store.attempt(first.id).unwrap().unwrap();
4452 assert!(matches!(
4453 failed.outcome(),
4454 Some(AttemptOutcome::Failed {
4455 reason: FailureReason::ProcessExitedUnexpectedly
4456 })
4457 ));
4458 assert!(!first.runtime_path().exists());
4459
4460 assert_eq!(
4461 replacements,
4462 vec![ReplacementIntent {
4463 policy: harness.policy.id,
4464 previous_attempt: first.id,
4465 operation: "exit_before_acceptance_replacement",
4466 }]
4467 );
4468 assert_eq!(harness.store.attempts().unwrap().len(), 1);
4469 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
4470 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
4471 assert!(harness.delay.0.lock().unwrap().is_empty());
4472 }
4473
4474 #[tokio::test]
4475 async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
4476 let harness = Harness::new(
4477 FakeGithubLifecycle::default(),
4478 Arc::new(FakeDemand::answering([false])),
4479 );
4480 let id = AttemptId::new_random();
4481 let runtime = harness
4482 .launcher
4483 .app_paths
4484 .runtime_dir()
4485 .join(harness.policy.id.to_string())
4486 .join(id.to_string());
4487 fs::create_dir_all(&runtime).unwrap();
4488 let mut attempt =
4489 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4490 attempt.jit_received(harness.clock.now()).unwrap();
4491 harness.store.record_attempt(&attempt).unwrap();
4492 harness.clock.advance_secs(11);
4493 let replacements = harness
4494 .launcher
4495 .recover_startup(std::slice::from_ref(&harness.policy))
4496 .await
4497 .unwrap();
4498 assert_eq!(
4499 replacements,
4500 vec![ReplacementIntent {
4501 policy: harness.policy.id,
4502 previous_attempt: id,
4503 operation: "jit_expired_replacement",
4504 }]
4505 );
4506
4507 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4508 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4509 assert!(matches!(
4510 cleaned.outcome(),
4511 Some(AttemptOutcome::Failed {
4512 reason: FailureReason::JitExpired
4513 })
4514 ));
4515 assert!(!runtime.exists());
4516 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
4517 assert!(harness.delay.0.lock().unwrap().is_empty());
4518 }
4519
4520 #[tokio::test]
4521 async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
4522 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4523 let id = AttemptId::new_random();
4524 let runtime = harness
4525 .launcher
4526 .app_paths
4527 .runtime_dir()
4528 .join("expired-with-demand");
4529 fs::create_dir_all(&runtime).unwrap();
4530 let mut attempt =
4531 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4532 attempt.jit_received(harness.clock.now()).unwrap();
4533 harness.store.record_attempt(&attempt).unwrap();
4534 harness.clock.advance_secs(11);
4535 let replacements = harness
4536 .launcher
4537 .recover_startup(std::slice::from_ref(&harness.policy))
4538 .await
4539 .unwrap();
4540
4541 let attempts = harness.store.attempts().unwrap();
4542 assert_eq!(attempts.len(), 1);
4543 assert_eq!(
4544 attempts
4545 .iter()
4546 .find(|attempt| attempt.id == id)
4547 .unwrap()
4548 .state(),
4549 AttemptState::Cleaned
4550 );
4551 assert_eq!(
4552 replacements,
4553 vec![ReplacementIntent {
4554 policy: harness.policy.id,
4555 previous_attempt: id,
4556 operation: "jit_expired_replacement",
4557 }]
4558 );
4559 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
4560 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
4561 assert!(harness.delay.0.lock().unwrap().is_empty());
4562 }
4563
4564 #[tokio::test]
4565 async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
4566 let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4567 persistent.packages.fail_materializations(2);
4568 persistent.ready().await;
4569 persistent.launch().await;
4570 assert_eq!(
4571 persistent.packages.materializations.load(Ordering::SeqCst),
4572 3
4573 );
4574 assert_eq!(
4575 *persistent.delay.0.lock().unwrap(),
4576 vec![Duration::from_millis(10), Duration::from_millis(20)]
4577 );
4578
4579 let gone_before_wait = Harness::new(
4580 FakeGithubLifecycle::default(),
4581 Arc::new(FakeDemand::answering([false])),
4582 );
4583 gone_before_wait.packages.fail_materializations(3);
4584 gone_before_wait.ready().await;
4585 assert!(gone_before_wait.launch_result().await.is_err());
4586 assert_eq!(
4587 gone_before_wait
4588 .packages
4589 .materializations
4590 .load(Ordering::SeqCst),
4591 1
4592 );
4593 assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
4594
4595 let gone_during_wait = Harness::new(
4596 FakeGithubLifecycle::default(),
4597 Arc::new(FakeDemand::answering([true, false])),
4598 );
4599 gone_during_wait.packages.fail_materializations(3);
4600 gone_during_wait.ready().await;
4601 assert!(gone_during_wait.launch_result().await.is_err());
4602 assert_eq!(
4603 gone_during_wait
4604 .packages
4605 .materializations
4606 .load(Ordering::SeqCst),
4607 1
4608 );
4609 assert_eq!(
4610 *gone_during_wait.delay.0.lock().unwrap(),
4611 vec![Duration::from_millis(10)]
4612 );
4613 }
4614
4615 #[tokio::test]
4616 async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
4617 let harness = Harness::new(
4618 FakeGithubLifecycle::default(),
4619 Arc::new(FakeDemand::answering([true, false])),
4620 );
4621 harness.ready().await;
4622 let first = harness.launch().await;
4623 harness.processes.set_alive(false);
4624 harness
4625 .github
4626 .observe(GithubRunnerObservation::NotRegistered);
4627 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4628
4629 assert_eq!(harness.store.attempts().unwrap().len(), 1);
4630 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
4631 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
4632 assert!(harness.delay.0.lock().unwrap().is_empty());
4633 assert_eq!(
4634 replacements,
4635 vec![ReplacementIntent {
4636 policy: harness.policy.id,
4637 previous_attempt: first.id,
4638 operation: "exit_before_acceptance_replacement",
4639 }]
4640 );
4641 assert_eq!(
4642 harness.store.attempt(first.id).unwrap().unwrap().state(),
4643 AttemptState::Cleaned
4644 );
4645 }
4646
4647 #[tokio::test]
4648 async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
4649 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4650 let before = harness.launch_result().await;
4651 assert!(before.is_err());
4652 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
4653
4654 let id = AttemptId::new_random();
4655 let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
4656 fs::create_dir_all(&runtime).unwrap();
4657 let mut attempt =
4658 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
4659 attempt.jit_received(harness.clock.now()).unwrap();
4660 attempt.started(4242, harness.clock.now()).unwrap();
4661 harness.store.record_attempt(&attempt).unwrap();
4662 harness.processes.set_alive(true);
4663 harness
4664 .github
4665 .observe(GithubRunnerObservation::NotRegistered);
4666 let replacements = harness
4667 .launcher
4668 .recover_startup(std::slice::from_ref(&harness.policy))
4669 .await
4670 .unwrap();
4671 assert!(replacements.is_empty());
4672 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
4673 assert!(
4674 harness
4675 .events
4676 .events()
4677 .contains(&AttemptEvent::Adopted { attempt: id })
4678 );
4679 }
4680
4681 #[tokio::test]
4682 async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
4683 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4684 let id = AttemptId::new_random();
4685 let runtime = harness
4686 .launcher
4687 .app_paths
4688 .runtime_dir()
4689 .join("spawn-before-starting");
4690 fs::create_dir_all(&runtime).unwrap();
4691 let mut attempt =
4692 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4693 attempt.jit_received(harness.clock.now()).unwrap();
4694 harness.store.record_attempt(&attempt).unwrap();
4695 harness.processes.set_alive(true);
4696 harness
4697 .github
4698 .observe(GithubRunnerObservation::Registered { busy: true });
4699
4700 let replacements = harness
4701 .launcher
4702 .recover_startup(std::slice::from_ref(&harness.policy))
4703 .await
4704 .unwrap();
4705 assert!(replacements.is_empty());
4706 let recovered = harness.store.attempt(id).unwrap().unwrap();
4707 assert_eq!(recovered.state(), AttemptState::Busy);
4708 assert_eq!(recovered.process_id(), Some(4242));
4709 assert_eq!(recovered.github_runner_id(), Some(73));
4710 let events = harness.events.events();
4711 let starting = events
4712 .iter()
4713 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
4714 .unwrap();
4715 let busy = events
4716 .iter()
4717 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
4718 .unwrap();
4719 assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
4720
4721 harness.processes.finish_successfully();
4722 harness
4723 .github
4724 .observe(GithubRunnerObservation::NotRegistered);
4725 assert!(
4726 harness
4727 .launcher
4728 .supervise(&harness.policy)
4729 .await
4730 .unwrap()
4731 .is_empty()
4732 );
4733 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4734 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4735 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
4736 assert!(!runtime.exists());
4737 }
4738
4739 #[tokio::test]
4740 async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
4741 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4742 harness.processes.fail_spawn_with_live_child();
4743 harness.ready().await;
4744 assert!(harness.launch_result().await.is_err());
4745
4746 let attempt = harness.only_attempt();
4747 assert_eq!(attempt.state(), AttemptState::Starting);
4748 assert_eq!(attempt.process_id(), Some(4242));
4749 assert!(attempt.outcome().is_none());
4750 assert!(attempt.state().counts_against_capacity());
4751 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
4752 assert!(harness.delay.0.lock().unwrap().is_empty());
4753
4754 harness.processes.set_alive(false);
4755 harness
4756 .github
4757 .observe(GithubRunnerObservation::NotRegistered);
4758 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4759 assert_eq!(replacements.len(), 1);
4760 assert_eq!(
4761 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
4762 AttemptState::Cleaned
4763 );
4764 }
4765
4766 #[tokio::test]
4767 async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
4768 for sidecar_already_present in [false, true] {
4769 let harness = Harness::new(
4770 FakeGithubLifecycle::default(),
4771 Arc::new(FakeDemand::answering([false])),
4772 );
4773 let id = AttemptId::new_random();
4774 let runtime =
4775 harness
4776 .launcher
4777 .app_paths
4778 .runtime_dir()
4779 .join(if sidecar_already_present {
4780 "after-id-sidecar"
4781 } else {
4782 "before-id-sidecar"
4783 });
4784 fs::create_dir_all(&runtime).unwrap();
4785 let mut attempt =
4786 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4787 if sidecar_already_present {
4788 write_runner_id(&runtime, 73).unwrap();
4789 attempt.jit_received(harness.clock.now()).unwrap();
4790 }
4791 harness.store.record_attempt(&attempt).unwrap();
4792 harness.processes.set_alive(true);
4793 harness
4794 .github
4795 .observe(GithubRunnerObservation::Registered { busy: false });
4796 harness
4797 .launcher
4798 .recover_startup(std::slice::from_ref(&harness.policy))
4799 .await
4800 .unwrap();
4801
4802 assert_eq!(read_runner_id(&runtime), Some(73));
4803 assert!(
4804 harness
4805 .store
4806 .attempt(id)
4807 .unwrap()
4808 .unwrap()
4809 .outcome()
4810 .is_none()
4811 );
4812 let events = harness.events.events();
4813 let recovered = events.iter().position(|event| {
4814 matches!(
4815 event,
4816 AttemptEvent::RemoteIdentityRecovered {
4817 attempt,
4818 runner_id: 73
4819 } if *attempt == id
4820 )
4821 });
4822 assert_eq!(recovered.is_some(), !sidecar_already_present);
4823 if let Some(recovered) = recovered {
4824 let adopted = events
4825 .iter()
4826 .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
4827 .unwrap();
4828 assert!(
4829 recovered < adopted,
4830 "identity was not durable before adoption: {events:?}"
4831 );
4832 }
4833 assert!(runtime.exists());
4834 }
4835 }
4836
4837 #[tokio::test]
4838 async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
4839 let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4840 let unknown_attempt = RunnerAttempt::allocate(
4841 AttemptId::new_random(),
4842 PolicyId::from_u128(0xfeed),
4843 unknown
4844 .launcher
4845 .app_paths
4846 .runtime_dir()
4847 .join("unknown-policy"),
4848 unknown.clock.now(),
4849 );
4850 unknown.store.record_attempt(&unknown_attempt).unwrap();
4851 let expired_id = AttemptId::new_random();
4852 let expired_runtime = unknown
4853 .launcher
4854 .app_paths
4855 .runtime_dir()
4856 .join("expired-beside-unknown");
4857 fs::create_dir_all(&expired_runtime).unwrap();
4858 let mut expired = RunnerAttempt::allocate(
4859 expired_id,
4860 unknown.policy.id,
4861 expired_runtime,
4862 unknown.clock.now(),
4863 );
4864 expired.jit_received(unknown.clock.now()).unwrap();
4865 unknown.store.record_attempt(&expired).unwrap();
4866 unknown.clock.advance_secs(11);
4867 assert!(matches!(
4868 unknown
4869 .launcher
4870 .recover_startup(std::slice::from_ref(&unknown.policy))
4871 .await,
4872 Err(LifecycleError::RecoveryIncomplete)
4873 ));
4874 assert!(unknown.launch_result().await.is_err());
4875 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
4876 let recovered_policy = fixtures::policy()
4877 .id(PolicyId::from_u128(0xfeed))
4878 .repository("octo/repo")
4879 .autoscale("home", 2)
4880 .active()
4881 .build();
4882 let pending = unknown
4883 .launcher
4884 .recover_startup(&[unknown.policy.clone(), recovered_policy])
4885 .await
4886 .unwrap();
4887 assert_eq!(
4888 pending,
4889 vec![ReplacementIntent {
4890 policy: unknown.policy.id,
4891 previous_attempt: expired_id,
4892 operation: "jit_expired_replacement",
4893 }]
4894 );
4895 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
4896
4897 let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4898 let id = AttemptId::new_random();
4899 let runtime = unreachable
4900 .launcher
4901 .app_paths
4902 .runtime_dir()
4903 .join("unreachable");
4904 fs::create_dir_all(&runtime).unwrap();
4905 unreachable
4906 .store
4907 .record_attempt(&RunnerAttempt::allocate(
4908 id,
4909 unreachable.policy.id,
4910 runtime,
4911 unreachable.clock.now(),
4912 ))
4913 .unwrap();
4914 unreachable
4915 .github
4916 .observe(GithubRunnerObservation::Unreachable);
4917 assert!(matches!(
4918 unreachable
4919 .launcher
4920 .recover_startup(std::slice::from_ref(&unreachable.policy))
4921 .await,
4922 Err(LifecycleError::RecoveryIncomplete)
4923 ));
4924 assert!(unreachable.launch_result().await.is_err());
4925 assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
4926 }
4927
4928 #[tokio::test]
4929 async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
4930 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4931 let id = AttemptId::new_random();
4932 let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
4933 fs::create_dir_all(&runtime).unwrap();
4934 let mut attempt =
4935 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
4936 attempt.jit_received(harness.clock.now()).unwrap();
4937 attempt.started(4242, harness.clock.now()).unwrap();
4938 attempt.assigned_job(73, harness.clock.now()).unwrap();
4939 harness.store.record_attempt(&attempt).unwrap();
4940 harness.processes.set_alive(false);
4941 harness
4942 .github
4943 .observe(GithubRunnerObservation::NotRegistered);
4944 harness
4945 .launcher
4946 .recover_startup(std::slice::from_ref(&harness.policy))
4947 .await
4948 .unwrap();
4949 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4950 assert_eq!(cleaned.state(), AttemptState::Cleaned);
4951 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
4952 assert!(!runtime.exists());
4953 }
4954
4955 #[tokio::test]
4956 async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
4957 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
4958 harness.ready().await;
4959 let id = AttemptId::new_random();
4960 let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
4961 fs::create_dir_all(&runtime).unwrap();
4962 let mut attempt =
4963 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
4964 attempt.jit_received(harness.clock.now()).unwrap();
4965 attempt.started(4242, harness.clock.now()).unwrap();
4966 harness.store.record_attempt(&attempt).unwrap();
4967 harness.clock.advance_secs(11);
4968 harness.processes.set_alive(true);
4969 harness
4970 .github
4971 .observe(GithubRunnerObservation::NotRegistered);
4972 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
4973 assert_eq!(
4974 replacements,
4975 vec![ReplacementIntent {
4976 policy: harness.policy.id,
4977 previous_attempt: id,
4978 operation: "registration_timeout_replacement",
4979 }]
4980 );
4981
4982 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
4983 assert!(!harness.processes.alive.load(Ordering::SeqCst));
4984 let actions = harness.processes.actions.lock().unwrap().clone();
4985 let intent = actions
4986 .iter()
4987 .position(|action| *action == "terminate_intent")
4988 .unwrap();
4989 let signal = actions
4990 .iter()
4991 .position(|action| *action == "terminate")
4992 .unwrap();
4993 assert!(
4994 intent < signal,
4995 "intent was not durable before signal: {actions:?}"
4996 );
4997
4998 let cleaned = harness.store.attempt(id).unwrap().unwrap();
4999 assert!(matches!(
5000 cleaned.outcome(),
5001 Some(AttemptOutcome::Failed {
5002 reason: FailureReason::TerminatedAfterRegistrationTimeout
5003 })
5004 ));
5005 let events = harness.events.events();
5006 let intent = events
5007 .iter()
5008 .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
5009 .unwrap();
5010 let stopped = events
5011 .iter()
5012 .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
5013 .unwrap();
5014 let concluded = events
5015 .iter()
5016 .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
5017 .unwrap();
5018 assert!(intent < stopped && stopped < concluded, "{events:?}");
5019 }
5020
5021 #[tokio::test]
5022 async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
5023 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5024 let id = AttemptId::new_random();
5025 let runtime = harness
5026 .launcher
5027 .app_paths
5028 .runtime_dir()
5029 .join("timeout-after-crash");
5030 fs::create_dir_all(&runtime).unwrap();
5031 let mut attempt =
5032 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
5033 attempt.jit_received(harness.clock.now()).unwrap();
5034 attempt.started(4242, harness.clock.now()).unwrap();
5035 harness.store.record_attempt(&attempt).unwrap();
5036 harness.processes.intent.store(true, Ordering::SeqCst);
5037 harness.processes.set_alive(false);
5038 harness
5039 .github
5040 .observe(GithubRunnerObservation::NotRegistered);
5041
5042 let replacements = harness
5043 .launcher
5044 .recover_startup(std::slice::from_ref(&harness.policy))
5045 .await
5046 .unwrap();
5047 assert_eq!(
5048 replacements,
5049 vec![ReplacementIntent {
5050 policy: harness.policy.id,
5051 previous_attempt: id,
5052 operation: "registration_timeout_replacement",
5053 }]
5054 );
5055 let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
5056 .await
5057 .unwrap();
5058 assert_eq!(consumed, replacements);
5059 assert!(
5060 RunnerLauncher::supervise(&harness.launcher, &harness.policy)
5061 .await
5062 .unwrap()
5063 .is_empty(),
5064 "startup replacement evidence must be consumed exactly once by e1"
5065 );
5066 assert!(matches!(
5067 harness.store.attempt(id).unwrap().unwrap().outcome(),
5068 Some(AttemptOutcome::Failed {
5069 reason: FailureReason::TerminatedAfterRegistrationTimeout
5070 })
5071 ));
5072 }
5073
5074 #[tokio::test]
5075 async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
5076 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5077 let id = AttemptId::new_random();
5078 let runtime = harness
5079 .launcher
5080 .app_paths
5081 .runtime_dir()
5082 .join("timeout-sync-failure");
5083 fs::create_dir_all(&runtime).unwrap();
5084 let mut attempt =
5085 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
5086 attempt.jit_received(harness.clock.now()).unwrap();
5087 attempt.started(4242, harness.clock.now()).unwrap();
5088 harness.store.record_attempt(&attempt).unwrap();
5089 harness.clock.advance_secs(11);
5090 harness.processes.set_alive(true);
5091 harness.processes.fail_intent();
5092 harness
5093 .github
5094 .observe(GithubRunnerObservation::NotRegistered);
5095
5096 assert!(
5097 harness
5098 .launcher
5099 .recover_startup(std::slice::from_ref(&harness.policy))
5100 .await
5101 .is_err()
5102 );
5103 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
5104 assert!(harness.processes.alive.load(Ordering::SeqCst));
5105 assert_eq!(
5106 harness.store.attempt(id).unwrap().unwrap().state(),
5107 AttemptState::Starting
5108 );
5109 assert!(!harness.events.events().iter().any(|event| matches!(
5110 event,
5111 AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
5112 if *attempt == id
5113 )));
5114 }
5115
5116 #[tokio::test]
5117 async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
5118 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5119 harness.ready().await;
5120 let attempt = harness.launch().await;
5121 harness
5122 .github
5123 .observe(GithubRunnerObservation::Registered { busy: false });
5124 harness.launcher.supervise(&harness.policy).await.unwrap();
5125 harness.clock.advance_secs(11);
5126 harness.processes.set_alive(false);
5127 harness
5128 .github
5129 .observe(GithubRunnerObservation::NotRegistered);
5130 harness.launcher.supervise(&harness.policy).await.unwrap();
5131 let diagnostic = fs::read_to_string(
5132 harness
5133 .launcher
5134 .diagnostics_root
5135 .join(format!("{}.log", attempt.id)),
5136 )
5137 .unwrap();
5138 assert!(diagnostic.contains("exited_idle_without_work"));
5139 assert!(!diagnostic.contains(JIT));
5140 assert!(!diagnostic.contains("ghp_"));
5141 assert!(!attempt.runtime_path().exists());
5142 }
5143
5144 #[test]
5145 fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
5146 let root = tempfile::tempdir().unwrap();
5147 let policy = fixtures::policy()
5148 .repository("octo/repo")
5149 .autoscale("home", 1)
5150 .active()
5151 .build();
5152 let runtime = root.path().join("successful");
5153 fs::create_dir_all(&runtime).unwrap();
5154 let processes = NativeProcesses::new();
5155 let config = EncodedJitConfig::new(JIT);
5156 let handoff =
5157 RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
5158 .unwrap();
5159 let mut child = native_inspection_spec()
5160 .spawn_runner_with_handoff(&handoff)
5161 .expect("native child starts");
5162 let pid = child.pid();
5163 handoff.delete().unwrap();
5164 let command_line = native_command_line(pid);
5165 assert!(
5166 !command_line.contains(JIT),
5167 "the encoded JIT configuration appeared in the native process listing"
5168 );
5169 assert_no_jit_file(&runtime);
5170 child
5171 .stop(Duration::from_secs(1))
5172 .expect("native child stops");
5173
5174 let failed_runtime = root.path().join("failed");
5175 fs::create_dir_all(&failed_runtime).unwrap();
5176 let failed = RunnerAttempt::allocate(
5177 AttemptId::new_random(),
5178 policy.id,
5179 &failed_runtime,
5180 FakeClock::default().now(),
5181 );
5182 assert!(
5183 processes
5184 .spawn(&failed, &EncodedJitConfig::new(JIT))
5185 .is_err(),
5186 "a runtime with no runner executable must fail"
5187 );
5188 assert_no_jit_file(&failed_runtime);
5189 processes
5190 .record_terminate_intent(&failed)
5191 .expect("the intent file and its directory entry are durably synced");
5192 assert_eq!(
5193 fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
5194 b"registration-timeout\n"
5195 );
5196 }
5197
5198 #[test]
5199 fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
5200 let root = tempfile::tempdir().unwrap();
5201 let policy = fixtures::policy()
5202 .repository("octo/repo")
5203 .autoscale("home", 1)
5204 .active()
5205 .build();
5206 let processes = NativeProcesses::new();
5207 processes.use_long_lived_test_listener();
5208 for (index, boundary) in [
5209 PostSpawnBoundary::HandoffDelete,
5210 PostSpawnBoundary::IdentitySerialize,
5211 PostSpawnBoundary::IdentityWrite,
5212 PostSpawnBoundary::ChildMapInsert,
5213 ]
5214 .into_iter()
5215 .enumerate()
5216 {
5217 let runtime = root.path().join(format!("post-spawn-{index}"));
5218 let bin = runtime.join("bin");
5219 fs::create_dir_all(&bin).unwrap();
5220 #[cfg(windows)]
5221 let listener = bin.join("Runner.Listener.exe");
5222 #[cfg(not(windows))]
5223 let listener = bin.join("Runner.Listener");
5224 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
5225 let attempt = RunnerAttempt::allocate(
5226 AttemptId::new_random(),
5227 policy.id,
5228 &runtime,
5229 FakeClock::default().now(),
5230 );
5231 processes.fail_post_spawn_at(boundary);
5232 let failure = processes
5233 .spawn(&attempt, &EncodedJitConfig::new(JIT))
5234 .expect_err("fault must cross the post-spawn cleanup path");
5235 assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
5236 assert!(
5237 !processes.is_alive(&attempt).unwrap(),
5238 "{boundary:?} left a child"
5239 );
5240 assert!(!NativeProcesses::identity_path(&attempt).exists());
5241 assert_no_jit_file(&runtime);
5242 }
5243 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
5244
5245 let runtime = root.path().join("identity-and-stop-fail");
5246 let bin = runtime.join("bin");
5247 fs::create_dir_all(&bin).unwrap();
5248 #[cfg(windows)]
5249 let listener = bin.join("Runner.Listener.exe");
5250 #[cfg(not(windows))]
5251 let listener = bin.join("Runner.Listener");
5252 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
5253 let attempt = RunnerAttempt::allocate(
5254 AttemptId::new_random(),
5255 policy.id,
5256 &runtime,
5257 FakeClock::default().now(),
5258 );
5259 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
5263 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
5264 processes.fail_next_post_spawn_stop();
5265 let failure = processes
5266 .spawn(&attempt, &EncodedJitConfig::new(JIT))
5267 .expect_err("the identity boundary must fail closed");
5268 assert!(failure.live_pid.is_some());
5269 assert_long_lived_listener_ready(&processes, &attempt);
5270 assert!(processes.is_alive(&attempt).unwrap());
5271 assert!(!NativeProcesses::identity_path(&attempt).exists());
5272 assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
5273 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
5274 processes.terminate(&attempt).unwrap();
5275
5276 let runtime = root.path().join("persistent-stop-and-identity-failures");
5277 let bin = runtime.join("bin");
5278 fs::create_dir_all(&bin).unwrap();
5279 #[cfg(windows)]
5280 let listener = bin.join("Runner.Listener.exe");
5281 #[cfg(not(windows))]
5282 let listener = bin.join("Runner.Listener");
5283 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
5284 let mut unresolved = RunnerAttempt::allocate(
5285 AttemptId::new_random(),
5286 policy.id,
5287 &runtime,
5288 FakeClock::default().now(),
5289 );
5290 for _ in 0..3 {
5291 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
5292 }
5293 processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
5294 let failure = processes
5295 .spawn(&unresolved, &EncodedJitConfig::new(JIT))
5296 .expect_err("bounded cleanup must return even when every stop errors");
5297 let pid = failure
5298 .live_pid
5299 .expect("the owned child remains supervised in this invocation");
5300 assert!(matches!(failure.reason, FailureReason::Other(_)));
5301 assert_long_lived_listener_ready(&processes, &unresolved);
5302 unresolved.jit_received(FakeClock::default().now()).unwrap();
5303 unresolved.started(pid, FakeClock::default().now()).unwrap();
5304 let journal = SqliteStore::open_in_memory().unwrap();
5305 journal.record_attempt(&unresolved).unwrap();
5306 let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
5307 assert_eq!(recovered.process_id(), Some(pid));
5308 assert_eq!(recovered.state(), AttemptState::Starting);
5309 assert!(processes.is_alive(&unresolved).unwrap());
5310 assert!(!NativeProcesses::identity_path(&unresolved).exists());
5311 assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
5312 assert_eq!(
5313 fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
5314 pid.to_string(),
5315 "bounded cleanup must leave durable unresolved-process evidence before returning"
5316 );
5317 assert!(
5318 NativeProcesses::new().is_alive(&recovered).is_err(),
5319 "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
5320 );
5321 processes.terminate(&unresolved).unwrap();
5322
5323 let runtime = root.path().join("post-spawn-stop-failed");
5324 let bin = runtime.join("bin");
5325 fs::create_dir_all(&bin).unwrap();
5326 #[cfg(windows)]
5327 let listener = bin.join("Runner.Listener.exe");
5328 #[cfg(not(windows))]
5329 let listener = bin.join("Runner.Listener");
5330 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
5331 let attempt = RunnerAttempt::allocate(
5332 AttemptId::new_random(),
5333 policy.id,
5334 &runtime,
5335 FakeClock::default().now(),
5336 );
5337 processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
5338 processes.fail_next_post_spawn_stop();
5339 let failure = processes
5340 .spawn(&attempt, &EncodedJitConfig::new(JIT))
5341 .expect_err("the injected stop failure must preserve supervision");
5342 let live_pid = failure
5343 .live_pid
5344 .expect("live PID is returned to the journal");
5345 assert!(!failure.retryable);
5346 assert_long_lived_listener_ready(&processes, &attempt);
5347 assert!(NativeProcesses::identity_path(&attempt).is_file());
5348 assert_eq!(
5349 NativeProcesses::read_identity(&attempt)
5350 .unwrap()
5351 .unwrap()
5352 .pid(),
5353 live_pid
5354 );
5355 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
5356 processes.terminate(&attempt).unwrap();
5357 }
5358
5359 #[test]
5360 #[ignore = "spawned only as the platform-stable native listener fixture"]
5361 fn long_lived_native_listener_helper() {
5362 let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
5363 .map(PathBuf::from)
5364 .expect("the parent supplies the readiness path");
5365 fs::write(ready, b"ready\n").expect("the listener publishes readiness");
5366 std::thread::sleep(Duration::from_secs(30));
5367 }
5368
5369 fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
5370 let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
5371 let deadline = std::time::Instant::now() + Duration::from_secs(5);
5372 loop {
5373 if ready.is_file() {
5374 assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
5375 return;
5376 }
5377 assert!(
5378 processes.is_alive(attempt).unwrap(),
5379 "the native listener exited before publishing readiness"
5380 );
5381 assert!(
5382 std::time::Instant::now() < deadline,
5383 "the native listener stayed alive but never published readiness"
5384 );
5385 std::thread::sleep(Duration::from_millis(10));
5386 }
5387 }
5388
5389 #[tokio::test]
5390 async fn every_production_launch_prunes_under_the_same_allocation_guard() {
5391 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
5392 harness.ready().await;
5393 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
5394 harness.launch().await;
5395 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
5396 assert_eq!(
5397 *harness.packages.prune_currents.lock().unwrap(),
5398 vec![harness.packages.version.clone()],
5399 "the leased current version is an exclusion, never the prune target"
5400 );
5401 }
5402
5403 fn assert_no_jit_file(runtime: &Path) {
5404 for entry in fs::read_dir(runtime).unwrap() {
5405 let path = entry.unwrap().path();
5406 if path.is_file() {
5407 let bytes = fs::read(&path).unwrap();
5408 assert!(
5409 !bytes
5410 .windows(JIT.len())
5411 .any(|window| window == JIT.as_bytes()),
5412 "a JIT payload survived in a runtime file"
5413 );
5414 }
5415 }
5416 }
5417
5418 #[test]
5419 fn production_listener_command_uses_the_supported_jit_contract() {
5420 let runtime = Path::new("runtime");
5421 let spec = runner_listener_spec(PathBuf::from("Runner.Listener"), runtime);
5422 let arguments: Vec<_> = spec
5423 .arguments()
5424 .iter()
5425 .map(|argument| argument.to_string_lossy().into_owned())
5426 .collect();
5427
5428 assert_eq!(arguments, ["run"]);
5429 assert!(
5430 !arguments
5431 .iter()
5432 .any(|argument| argument == "--jit-config-file"),
5433 "the obsolete file option would be rejected by Runner.Listener 2.336.0"
5434 );
5435 }
5436
5437 #[cfg(windows)]
5438 fn native_inspection_spec() -> SpawnSpec {
5439 SpawnSpec::new("powershell.exe").args([
5440 "-NoProfile",
5441 "-NonInteractive",
5442 "-Command",
5443 "Start-Sleep -Seconds 30",
5444 ])
5445 }
5446
5447 #[cfg(unix)]
5448 fn native_inspection_spec() -> SpawnSpec {
5449 SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
5450 }
5451
5452 #[cfg(windows)]
5453 fn native_command_line(pid: u32) -> String {
5454 let output = std::process::Command::new("powershell.exe")
5455 .args([
5456 "-NoProfile",
5457 "-NonInteractive",
5458 "-Command",
5459 &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
5460 ])
5461 .output()
5462 .expect("PowerShell can inspect the native child");
5463 assert!(output.status.success(), "native process inspection failed");
5464 String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
5465 }
5466
5467 #[cfg(target_os = "linux")]
5468 fn native_command_line(pid: u32) -> String {
5469 fs::read(format!("/proc/{pid}/cmdline"))
5470 .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
5471 .expect("/proc exposes the native child command line")
5472 }
5473
5474 #[cfg(target_os = "macos")]
5475 fn native_command_line(pid: u32) -> String {
5476 let output = std::process::Command::new("ps")
5477 .args(["-o", "command=", "-p", &pid.to_string()])
5478 .output()
5479 .expect("ps can inspect the native child");
5480 assert!(output.status.success(), "native process inspection failed");
5481 String::from_utf8(output.stdout).expect("the command line is UTF-8")
5482 }
5483
5484 #[tokio::test]
5487 async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
5488 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5489 .with_persistent_workspace(2);
5490 harness.github.watch_journal(Arc::clone(&harness.store));
5491 harness.ready().await;
5492
5493 let attempt = harness.launch().await;
5494
5495 assert_eq!(
5496 attempt.workspace(),
5497 AttemptWorkspace::persistent_slot(nz(1)),
5498 "the lowest free slot is leased"
5499 );
5500 assert_eq!(attempt.runtime_path(), harness.slot_path(1));
5501 assert!(attempt.holds_slot_lease());
5502 assert_eq!(
5504 harness.attempt(attempt.id).runtime_path(),
5505 harness.slot_path(1)
5506 );
5507
5508 let facts = harness.github.registration_facts();
5512 assert_eq!(facts.len(), 1);
5513 assert_eq!(
5514 facts[0].leased_slots,
5515 vec![1],
5516 "the lease was journalled first"
5517 );
5518 assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
5519 }
5520
5521 #[tokio::test]
5522 async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
5523 let harness = Harness::new(
5524 FakeGithubLifecycle::default().fail(true),
5525 Arc::new(PersistentDemand),
5526 )
5527 .with_persistent_workspace(2);
5528 harness.ready().await;
5529
5530 harness.launch_result().await.unwrap_err();
5532 let first = harness.store.attempts().unwrap().remove(0);
5533 assert_eq!(first.state(), AttemptState::Failed);
5534 assert!(
5535 !first.state().counts_against_capacity(),
5536 "a concluded attempt is invisible to host capacity"
5537 );
5538 assert!(
5539 first.holds_slot_lease(),
5540 "and still owns its directory, so its slot is not free"
5541 );
5542
5543 let second = harness.launch().await;
5544 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
5545 assert_eq!(second.runtime_path(), harness.slot_path(2));
5546 assert_eq!(
5547 harness
5548 .store
5549 .slot_leases_for_policy(harness.policy.id)
5550 .unwrap()
5551 .len(),
5552 2
5553 );
5554 }
5555
5556 #[tokio::test]
5557 async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
5558 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5559 .with_persistent_workspace(1);
5560 harness.ready().await;
5561
5562 let first = harness.launch().await;
5563 assert_eq!(first.runtime_path(), harness.slot_path(1));
5564
5565 let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
5567 fs::create_dir_all(&checkout).unwrap();
5568 fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
5569
5570 harness.cleanup_retaining_work(first.id).await;
5571
5572 let second = harness.launch().await;
5573 assert_ne!(second.id, first.id);
5574 assert_eq!(
5575 second.workspace(),
5576 AttemptWorkspace::persistent_slot(nz(1)),
5577 "a released slot is leased again rather than skipped"
5578 );
5579 assert_eq!(
5580 second.runtime_path(),
5581 first.runtime_path(),
5582 "the same slot is the same exact path"
5583 );
5584 assert_eq!(
5585 fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
5586 "from the first job",
5587 "the retained job workspace survived the second allocation"
5588 );
5589 assert!(harness.slot_path(1).join("runner-package").exists());
5591 }
5592
5593 #[tokio::test]
5594 async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
5595 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5596 .with_persistent_workspace(2);
5597 harness.ready().await;
5598
5599 let first = harness.launch().await;
5600 let second = harness.launch().await;
5601 assert_eq!(second.runtime_path(), harness.slot_path(2));
5602 let kept = harness
5603 .slot_path(2)
5604 .join(DEFAULT_WORK_FOLDER)
5605 .join("kept.txt");
5606 fs::create_dir_all(kept.parent().unwrap()).unwrap();
5607 fs::write(&kept, b"s2 was here").unwrap();
5608 harness.cleanup_retaining_work(second.id).await;
5609
5610 harness.policy.set_max_capacity(nz(1)).unwrap();
5612 let refusal = harness.launch_result().await.unwrap_err().to_string();
5613 assert!(
5614 refusal.contains("s1 to s1"),
5615 "the refusal names the ceiling it reached: {refusal}"
5616 );
5617 assert!(
5618 harness.slot_path(2).exists() && kept.exists(),
5619 "lowering capacity deletes nothing; the higher slot is merely unusable"
5620 );
5621
5622 harness.policy.set_max_capacity(nz(2)).unwrap();
5624 let third = harness.launch().await;
5625 assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
5626 assert_eq!(third.runtime_path(), harness.slot_path(2));
5627 assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
5628 assert!(first.holds_slot_lease(), "s1 was never disturbed");
5629 }
5630
5631 #[tokio::test]
5632 async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
5633 for policy in [
5634 fixtures::policy()
5635 .organization("octo")
5636 .autoscale("home", 2)
5637 .active()
5638 .build(),
5639 fixtures::policy()
5640 .repository("octo/repo")
5641 .autoscale("home", 2)
5642 .active()
5643 .build(),
5644 ] {
5645 let mut harness =
5646 Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5647 .with_host_runner_root();
5648 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
5649 harness.policy = policy;
5650 harness.ready().await;
5651
5652 let attempt = harness.launch().await;
5653 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
5654 assert_eq!(attempt.workspace().slot_number(), None);
5655 assert!(!attempt.holds_slot_lease());
5656 assert_eq!(
5657 attempt.runtime_path().parent().unwrap(),
5658 harness.host_root(),
5659 "a disposable attempt is a child of the host root, never of a slot"
5660 );
5661 assert!(
5662 harness
5663 .store
5664 .slot_leases_for_policy(harness.policy.id)
5665 .unwrap()
5666 .is_empty()
5667 );
5668 }
5669 }
5670
5671 #[tokio::test]
5672 async fn two_concurrent_allocations_never_share_a_slot() {
5673 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5674 .with_persistent_workspace(2);
5675 harness.github.watch_journal(Arc::clone(&harness.store));
5676 harness.ready().await;
5677
5678 let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
5682 let first = first.unwrap();
5683 let second = second.unwrap();
5684
5685 let slots: BTreeSet<u16> = [&first, &second]
5686 .iter()
5687 .map(|attempt| {
5688 attempt
5689 .workspace()
5690 .slot_number()
5691 .expect("a persistent attempt leases a slot")
5692 })
5693 .collect();
5694 assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
5695 assert_ne!(first.runtime_path(), second.runtime_path());
5696 assert_eq!(
5697 harness
5698 .store
5699 .slot_leases_for_policy(harness.policy.id)
5700 .unwrap()
5701 .len(),
5702 2
5703 );
5704
5705 let facts = harness.github.registration_facts();
5710 assert_eq!(facts.len(), 2);
5711 for fact in facts {
5712 let attempt = [&first, &second]
5713 .into_iter()
5714 .find(|attempt| runner_name(attempt.id) == fact.runner_name)
5715 .expect("every registration belongs to one of the two attempts");
5716 let slot = attempt
5717 .workspace()
5718 .slot_number()
5719 .expect("a persistent attempt leases a slot");
5720 assert!(
5721 fact.leased_slots.contains(&slot),
5722 "a JIT request never precedes its own lease: s{slot} not in {:?}",
5723 fact.leased_slots
5724 );
5725 assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
5726 }
5727 }
5728
5729 #[tokio::test]
5730 async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
5731 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
5732 .with_persistent_workspace(2);
5733 harness.ready().await;
5734 let first = harness.launch().await;
5735
5736 let clash = RunnerAttempt::allocate_in(
5740 AttemptId::new_random(),
5741 harness.policy.id,
5742 first.runtime_path(),
5743 AttemptWorkspace::persistent_slot(nz(1)),
5744 harness.clock.now(),
5745 );
5746 assert!(matches!(
5747 harness.store.record_attempt(&clash).unwrap_err(),
5748 StoreError::SlotAlreadyLeased { slot: 1, .. }
5749 ));
5750
5751 let error = harness.launcher.record_allocation(&clash).unwrap_err();
5754 let rendered = error.to_string();
5755 assert!(rendered.contains("slot s1"), "{rendered}");
5756 assert!(rendered.contains("nothing was written"), "{rendered}");
5757 assert_eq!(
5758 harness.store.attempts().unwrap().len(),
5759 1,
5760 "the losing allocator journalled nothing"
5761 );
5762 }
5763
5764 #[test]
5765 fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
5766 let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
5767 slots
5768 .iter()
5769 .map(|slot| {
5770 RunnerAttempt::allocate_in(
5771 AttemptId::new_random(),
5772 fixtures::POLICY_ID,
5773 format!("/srv/rman/acme/s{slot}"),
5774 AttemptWorkspace::persistent_slot(nz(*slot)),
5775 fixtures::created_at(),
5776 )
5777 })
5778 .collect()
5779 };
5780
5781 assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
5782 assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
5783 assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
5785 assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
5787 assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
5788 let ephemeral = vec![RunnerAttempt::allocate(
5790 AttemptId::new_random(),
5791 fixtures::POLICY_ID,
5792 "/srv/rman/host/abc",
5793 fixtures::created_at(),
5794 )];
5795 assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
5796 }
5797
5798 #[test]
5799 fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
5800 let root = tempfile::tempdir().unwrap();
5801 let slot = root.path().join("s1");
5802 fs::create_dir(&slot).unwrap();
5803 accept_reusable_slot(&slot).expect("an empty slot is reusable");
5804
5805 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
5806 accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
5807
5808 fs::create_dir(slot.join("bin")).unwrap();
5811 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
5812 let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
5813 assert!(refusal.contains("bin"), "{refusal}");
5814 assert!(refusal.contains(".github-runner-id"), "{refusal}");
5815
5816 let file_work = root.path().join("s2");
5818 fs::create_dir(&file_work).unwrap();
5819 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
5820 assert!(accept_reusable_slot(&file_work).is_err());
5821 }
5822
5823 #[cfg(unix)]
5824 #[test]
5825 fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
5826 let root = tempfile::tempdir().unwrap();
5830 let elsewhere = root.path().join("elsewhere");
5831 fs::create_dir(&elsewhere).unwrap();
5832
5833 let slot = root.path().join("s1");
5834 fs::create_dir(&slot).unwrap();
5835 std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
5836 assert!(accept_reusable_slot(&slot).is_err());
5837
5838 let linked_slot = root.path().join("s2");
5839 std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
5840 assert!(create_or_validate_slot(&linked_slot).is_err());
5841 }
5842
5843 #[test]
5844 fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
5845 let root = tempfile::tempdir().unwrap();
5846 let occupied = root.path().join("s1");
5847 fs::write(&occupied, b"an operator's file").unwrap();
5848 let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
5849 assert!(refusal.contains("is not a directory"), "{refusal}");
5850 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
5851
5852 let fresh = root.path().join("s2");
5853 create_or_validate_slot(&fresh).expect("a missing slot is created");
5854 assert!(fresh.is_dir());
5855 create_or_validate_slot(&fresh).expect("an existing directory is accepted");
5856 }
5857
5858 #[test]
5859 fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
5860 assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
5861 assert!(!is_work_folder(OsStr::new("_work2")));
5862 assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
5866 }
5867
5868 #[test]
5869 fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
5870 let root = tempfile::tempdir().unwrap();
5871 let package = root.path().join("package");
5872 fs::create_dir_all(package.join("bin")).unwrap();
5873 fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
5874 fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
5876
5877 let slot = root.path().join("s1");
5878 let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
5879 fs::create_dir_all(&retained).unwrap();
5880 fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
5881
5882 copy_package_tree(&package, &slot).expect("the package lays out around `_work`");
5883 assert!(slot.join("bin").join("Runner.Listener").exists());
5884 assert!(
5885 slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
5886 "the guard is top-level only"
5887 );
5888 assert_eq!(
5889 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
5890 "from the first job"
5891 );
5892
5893 fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
5895 let error = copy_package_tree(&package, &slot).unwrap_err();
5896 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
5897 assert_eq!(
5898 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
5899 "from the first job"
5900 );
5901 }
5902
5903 #[test]
5904 fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
5905 let root = tempfile::tempdir().unwrap();
5906
5907 let slot = root.path().join("s1");
5908 let retained = slot.join(DEFAULT_WORK_FOLDER);
5909 fs::create_dir_all(retained.join("repo")).unwrap();
5910 fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
5911 fs::create_dir_all(slot.join("bin")).unwrap();
5912 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
5913 let persistent = RunnerAttempt::allocate_in(
5914 AttemptId::new_random(),
5915 fixtures::POLICY_ID,
5916 &slot,
5917 AttemptWorkspace::persistent_slot(nz(1)),
5918 fixtures::created_at(),
5919 );
5920
5921 remove_materialized_package(&persistent).unwrap();
5922 assert!(slot.is_dir(), "the slot itself is not removed");
5923 assert!(!slot.join("bin").exists());
5924 assert!(!slot.join(".github-runner-id").exists());
5925 assert_eq!(
5926 fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
5927 "kept"
5928 );
5929
5930 let disposable_path = root.path().join("abcdef012345");
5931 fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
5932 let disposable = RunnerAttempt::allocate(
5933 AttemptId::new_random(),
5934 fixtures::POLICY_ID,
5935 &disposable_path,
5936 fixtures::created_at(),
5937 );
5938 remove_materialized_package(&disposable).unwrap();
5939 assert!(
5940 !disposable_path.exists(),
5941 "a disposable directory is still removed whole"
5942 );
5943 }
5944
5945 fn litter_the_slot(slot: &Path) {
5955 for directory in ["bin", "externals", "_diag"] {
5956 fs::create_dir_all(slot.join(directory)).unwrap();
5957 }
5958 fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
5959 for file in SENSITIVE_SLOT_ENTRIES
5960 .iter()
5961 .filter(|entry| !slot.join(entry).is_dir())
5962 {
5963 fs::write(slot.join(file), b"runner state").unwrap();
5964 }
5965 fs::write(
5967 slot.join(format!(
5968 "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
5969 RestrictiveHandoff::NAME_PREFIX
5970 )),
5971 JIT.as_bytes(),
5972 )
5973 .unwrap();
5974 }
5975
5976 fn retain_under_work(slot: &Path) -> PathBuf {
5978 let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
5979 fs::create_dir_all(&checkout).unwrap();
5980 let marker = checkout.join("build-output.bin");
5981 fs::write(&marker, RETAINED).unwrap();
5982 marker
5983 }
5984
5985 const RETAINED: &str = "a Git-ignored build output the next job reuses";
5987
5988 fn entries_of(directory: &Path) -> Vec<String> {
5990 let mut names: Vec<String> = fs::read_dir(directory)
5991 .unwrap()
5992 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
5993 .collect();
5994 names.sort();
5995 names
5996 }
5997
5998 fn only_the_job_workspace() -> Vec<String> {
6000 vec![DEFAULT_WORK_FOLDER.to_owned()]
6001 }
6002
6003 #[cfg(unix)]
6004 #[test]
6005 fn disposable_tree_removal_does_not_open_a_dotnet_diagnostic_fifo() {
6006 use std::sync::mpsc;
6007
6008 let temporary = tempfile::tempdir().unwrap();
6009 let tree = temporary.path().join("attempt");
6010 let diagnostic = tree.join("tmp/clr-debug-pipe-runner-in");
6011 fs::create_dir_all(diagnostic.parent().unwrap()).unwrap();
6012 assert!(
6013 std::process::Command::new("mkfifo")
6014 .arg(&diagnostic)
6015 .status()
6016 .unwrap()
6017 .success()
6018 );
6019
6020 let (finished, result) = mpsc::channel();
6021 std::thread::spawn(move || {
6022 let removed = remove_runtime_tree(&tree);
6023 let _ = finished.send(removed);
6024 });
6025
6026 result
6027 .recv_timeout(Duration::from_secs(2))
6028 .expect("runtime deletion must not wait for a FIFO peer")
6029 .unwrap();
6030 assert!(!diagnostic.exists());
6031 }
6032
6033 struct BlockedDeletion {
6049 directory: PathBuf,
6050 #[cfg(windows)]
6051 _handle: fs::File,
6052 }
6053
6054 impl BlockedDeletion {
6055 const HELD: &'static str = "held-open";
6056
6057 fn inject(directory: &Path) -> Option<Self> {
6060 #[cfg(unix)]
6061 if !Self::refusal_is_possible() {
6062 return None;
6063 }
6064 fs::create_dir_all(directory).unwrap();
6065 fs::write(
6066 directory.join(Self::HELD),
6067 b"a file the scrub cannot remove",
6068 )
6069 .unwrap();
6070 #[cfg(windows)]
6071 let handle = {
6072 use std::os::windows::fs::OpenOptionsExt;
6073
6074 fs::OpenOptions::new()
6075 .read(true)
6076 .share_mode(0)
6077 .open(directory.join(Self::HELD))
6078 .expect("the blocking handle opens")
6079 };
6080 #[cfg(unix)]
6081 Self::set_mode(directory, 0o555);
6082 Some(Self {
6083 directory: directory.to_path_buf(),
6084 #[cfg(windows)]
6085 _handle: handle,
6086 })
6087 }
6088
6089 fn release(self) {
6090 drop(self);
6091 }
6092
6093 #[cfg(unix)]
6094 fn refusal_is_possible() -> bool {
6095 let probe = tempfile::tempdir().unwrap();
6096 let directory = probe.path().join("probe");
6097 fs::create_dir(&directory).unwrap();
6098 fs::write(directory.join("file"), b"probe").unwrap();
6099 Self::set_mode(&directory, 0o555);
6100 let refused = fs::remove_dir_all(&directory).is_err();
6101 Self::set_mode(&directory, 0o755);
6102 refused
6103 }
6104
6105 #[cfg(unix)]
6106 fn set_mode(directory: &Path, mode: u32) {
6107 use std::os::unix::fs::PermissionsExt;
6108
6109 let mut permissions = fs::metadata(directory).unwrap().permissions();
6110 permissions.set_mode(mode);
6111 fs::set_permissions(directory, permissions).unwrap();
6112 }
6113 }
6114
6115 impl Drop for BlockedDeletion {
6116 fn drop(&mut self) {
6117 #[cfg(unix)]
6118 Self::set_mode(&self.directory, 0o755);
6119 #[cfg(not(unix))]
6120 let _ = &self.directory;
6121 }
6122 }
6123
6124 #[tokio::test]
6125 async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
6126 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6127 .with_persistent_workspace(1);
6128 harness.ready().await;
6129
6130 let first = harness.launch().await;
6131 let slot = harness.slot_path(1);
6132 assert_eq!(first.runtime_path(), slot);
6133 assert_eq!(
6134 read_runner_id(&slot),
6135 Some(73),
6136 "the attempt registered, so its identity is on disk"
6137 );
6138 let marker = retain_under_work(&slot);
6139 litter_the_slot(&slot);
6140
6141 harness.cleanup_retaining_work(first.id).await;
6142
6143 assert_eq!(entries_of(&slot), only_the_job_workspace());
6146 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
6147 assert_eq!(
6148 read_runner_id(&slot),
6149 None,
6150 "the first attempt's registration identity is gone before the second starts"
6151 );
6152 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
6153 assert!(!harness.attempt(first.id).holds_slot_lease());
6154
6155 let second = harness.launch().await;
6156 assert_ne!(second.id, first.id);
6157 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
6158 assert_eq!(
6159 second.runtime_path(),
6160 slot,
6161 "the same slot, so the same retained `_work`"
6162 );
6163 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
6164 }
6165
6166 #[tokio::test]
6167 async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
6168 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6169 .with_persistent_workspace(1);
6170 harness.ready().await;
6171 let attempt = harness.launch().await;
6172 let slot = harness.slot_path(1);
6173 let marker = retain_under_work(&slot);
6174 litter_the_slot(&slot);
6175 harness.conclude(attempt.id);
6176
6177 harness
6183 .store
6184 .remove_policy(harness.policy.id, harness.policy.revision())
6185 .unwrap();
6186 assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
6187
6188 harness
6189 .launcher
6190 .clean(attempt.id)
6191 .await
6192 .expect("journal facts alone are enough to clean the slot");
6193
6194 assert_eq!(entries_of(&slot), only_the_job_workspace());
6195 assert!(marker.exists());
6196 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
6197 }
6198
6199 #[tokio::test]
6200 async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
6201 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6202 .with_persistent_workspace(2);
6203 harness.ready().await;
6204 let first = harness.launch().await;
6205 let slot = harness.slot_path(1);
6206 let marker = retain_under_work(&slot);
6207 litter_the_slot(&slot);
6208 harness.conclude(first.id);
6209
6210 let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
6211 eprintln!(
6212 "skipped: this account cannot be refused a deletion, so no partial deletion can \
6213 be injected"
6214 );
6215 return;
6216 };
6217
6218 let refusal = harness
6219 .launcher
6220 .clean(first.id)
6221 .await
6222 .expect_err("a deletion that failed may not report a cleaned slot");
6223 let rendered = refusal.reason.to_string();
6224 assert!(rendered.contains("could not be removed"), "{rendered}");
6225
6226 let held = harness.attempt(first.id);
6227 assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
6228 assert!(held.holds_slot_lease(), "so the slot is still leased");
6229 assert!(
6230 !held.state().counts_against_capacity(),
6231 "and a concluded attempt still costs the host no capacity"
6232 );
6233
6234 let restarted = harness.restart();
6239 restarted
6240 .recover_startup(std::slice::from_ref(&harness.policy))
6241 .await
6242 .expect("one quarantined slot does not stop the host recovering");
6243 assert_eq!(
6244 harness.attempt(first.id).state(),
6245 AttemptState::Failed,
6246 "the quarantine survived the restart"
6247 );
6248 assert!(
6249 harness
6250 .reconcile_events
6251 .events()
6252 .iter()
6253 .any(|event| matches!(
6254 event,
6255 LifecycleEvent::AttemptCleanFailed {
6256 reason: "slot_entry_could_not_be_removed",
6257 ..
6258 }
6259 )),
6260 "the refusal is reported rather than retried in silence"
6261 );
6262
6263 let guard = harness.allocation_lock.acquire().await.unwrap();
6266 let second = restarted
6267 .launch(LaunchRequest {
6268 host: &harness.host,
6269 policy: &harness.policy,
6270 allocation_guard: &guard,
6271 })
6272 .await
6273 .expect("the host can still launch");
6274 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
6275 drop(guard);
6276
6277 block.release();
6280 restarted
6281 .clean(first.id)
6282 .await
6283 .expect("the retried cleanup completes");
6284 assert_eq!(entries_of(&slot), only_the_job_workspace());
6285 assert!(marker.exists());
6286 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
6287 }
6288
6289 #[tokio::test]
6300 async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
6301 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6302 harness.ready().await;
6303 let attempt = harness.launch().await;
6304 let runtime = attempt.runtime_path().to_path_buf();
6305 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
6306 harness.conclude(attempt.id);
6307
6308 let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
6309 eprintln!(
6310 "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
6311 );
6312 return;
6313 };
6314
6315 let refusal = harness
6316 .launcher
6317 .clean(attempt.id)
6318 .await
6319 .expect_err("a deletion that failed may not report a removed workspace");
6320 let rendered = refusal.reason.to_string();
6321 assert!(
6322 rendered.contains("could not be removed"),
6323 "the refusal names what happened: {rendered}"
6324 );
6325 assert_ne!(
6326 harness.attempt(attempt.id).state(),
6327 AttemptState::Cleaned,
6328 "an attempt whose directory is still on disk is not cleaned"
6329 );
6330 assert!(
6331 runtime.is_dir(),
6332 "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
6333 );
6334
6335 let restarted = harness.restart();
6336 restarted
6337 .recover_startup(std::slice::from_ref(&harness.policy))
6338 .await
6339 .expect("one locked ephemeral workspace does not stop startup recovery");
6340 assert!(
6341 harness
6342 .reconcile_events
6343 .events()
6344 .iter()
6345 .any(|event| matches!(
6346 event,
6347 LifecycleEvent::AttemptCleanFailed {
6348 attempt: failed_attempt,
6349 reason: "ephemeral_workspace_could_not_be_removed",
6350 ..
6351 } if *failed_attempt == attempt.id
6352 )),
6353 "the deferred cleanup remains visible while the daemon keeps running"
6354 );
6355
6356 block.release();
6359 harness
6360 .launcher
6361 .clean(attempt.id)
6362 .await
6363 .expect("the retried cleanup completes");
6364 assert!(!runtime.exists(), "the whole attempt directory goes");
6365 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
6366 }
6367
6368 #[tokio::test]
6369 async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
6370 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6371 .with_persistent_workspace(1);
6372 harness.ready().await;
6373 let first = harness.launch().await;
6374 let slot = harness.slot_path(1);
6375 let marker = retain_under_work(&slot);
6376 harness.cleanup_retaining_work(first.id).await;
6377
6378 harness
6382 .policy
6383 .set_workspace_policy(WorkspacePolicy::Ephemeral)
6384 .unwrap();
6385
6386 let second = harness.launch().await;
6387 assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
6388 assert_eq!(
6389 second.runtime_path().parent().unwrap(),
6390 harness.host_root(),
6391 "a disposable attempt is a child of the host root"
6392 );
6393 assert!(slot.is_dir(), "the old slot is left where it stands");
6394 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
6395
6396 harness.conclude(second.id);
6399 harness.launcher.clean(second.id).await.unwrap();
6400 assert!(!second.runtime_path().exists());
6401 assert!(marker.exists());
6402 }
6403
6404 #[tokio::test]
6405 async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
6406 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6407 .with_persistent_workspace(1);
6408 harness.ready().await;
6409 let slot = harness.slot_path(1);
6410 fs::create_dir_all(&slot).unwrap();
6411 let marker = retain_under_work(&slot);
6412 litter_the_slot(&slot);
6413
6414 let id = AttemptId::new_random();
6415 let mut attempt = RunnerAttempt::allocate_in(
6416 id,
6417 harness.policy.id,
6418 &slot,
6419 AttemptWorkspace::persistent_slot(nz(1)),
6420 harness.clock.now(),
6421 );
6422 attempt.jit_received(harness.clock.now()).unwrap();
6423 attempt.started(4242, harness.clock.now()).unwrap();
6424 harness.store.record_attempt(&attempt).unwrap();
6425 harness.clock.advance_secs(11);
6426 harness.processes.set_alive(true);
6427 harness
6428 .github
6429 .observe(GithubRunnerObservation::NotRegistered);
6430
6431 harness.launcher.supervise(&harness.policy).await.unwrap();
6432
6433 let actions = harness.processes.actions.lock().unwrap().clone();
6437 let intent = actions
6438 .iter()
6439 .position(|action| *action == "terminate_intent")
6440 .unwrap();
6441 let signal = actions
6442 .iter()
6443 .position(|action| *action == "terminate")
6444 .unwrap();
6445 assert!(intent < signal, "{actions:?}");
6446 assert!(!harness.processes.alive.load(Ordering::SeqCst));
6447
6448 let cleaned = harness.attempt(id);
6449 assert_eq!(cleaned.state(), AttemptState::Cleaned);
6450 assert!(matches!(
6451 cleaned.outcome(),
6452 Some(AttemptOutcome::Failed {
6453 reason: FailureReason::TerminatedAfterRegistrationTimeout
6454 })
6455 ));
6456 assert_eq!(entries_of(&slot), only_the_job_workspace());
6457 assert!(marker.exists());
6458 }
6459
6460 #[test]
6461 fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
6462 let root = tempfile::tempdir().unwrap();
6463 let slot = root.path().join("s1");
6464 fs::create_dir(&slot).unwrap();
6465 let marker = retain_under_work(&slot);
6466 litter_the_slot(&slot);
6467 fs::write(slot.join("runner-package"), b"verified").unwrap();
6468
6469 scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
6470 verify_slot_scrubbed(&slot).expect("and proves it afterwards");
6471
6472 assert_eq!(entries_of(&slot), only_the_job_workspace());
6473 assert!(marker.exists());
6474 }
6475
6476 #[test]
6477 fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
6478 let slot = Path::new("/runners/s1");
6479
6480 let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
6483 assert!(counted.contains("2 entries other than"), "{counted}");
6484 assert!(counted.contains("including `bin`"), "{counted}");
6485 assert_eq!(
6486 residue_detail(slot, 1, &[]),
6487 format!(
6488 "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
6489 slot.display()
6490 )
6491 );
6492
6493 let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
6498 assert!(!raced.contains('0'), "{raced}");
6499 assert!(raced.contains("reported nothing but"), "{raced}");
6500 assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
6501 }
6502
6503 #[test]
6504 fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
6505 let root = tempfile::tempdir().unwrap();
6506 let slot = root.path().join("s1");
6507 fs::create_dir(&slot).unwrap();
6508 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6509 verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
6510
6511 for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
6515 fs::write(slot.join(survivor), b"left behind").unwrap();
6516 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
6517 assert_eq!(quarantine.refusal, SlotRefusal::Residue);
6518 assert!(
6519 quarantine.detail.contains(&format!("`{survivor}`")),
6520 "{quarantine}"
6521 );
6522 fs::remove_file(slot.join(survivor)).unwrap();
6523 }
6524
6525 let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
6528 fs::write(&handoff, JIT.as_bytes()).unwrap();
6529 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
6530 assert!(
6531 quarantine.detail.contains("an encoded JIT handoff"),
6532 "{quarantine}"
6533 );
6534 assert!(!quarantine.detail.contains(JIT), "{quarantine}");
6535 fs::remove_file(&handoff).unwrap();
6536
6537 fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
6541 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
6542 assert!(
6543 quarantine.detail.contains("1 entry other than"),
6544 "{quarantine}"
6545 );
6546 assert!(
6547 !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
6548 "{quarantine}"
6549 );
6550 }
6551
6552 #[test]
6553 fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
6554 let root = tempfile::tempdir().unwrap();
6555 let configured =
6556 LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
6557 let slot = configured.as_path().join("s1");
6558 fs::create_dir(&slot).unwrap();
6559
6560 verify_journalled_slot(&slot, nz(1), Some(&configured))
6561 .expect("the journalled slot agrees");
6562 verify_journalled_slot(&slot, nz(1), None)
6563 .expect("and a policy that is gone removes a check, not the ability to clean");
6564
6565 assert_eq!(
6568 verify_journalled_slot(&slot, nz(2), None)
6569 .unwrap_err()
6570 .refusal,
6571 SlotRefusal::NotTheJournalledSlot
6572 );
6573 for stray in ["s1/nested", "not-a-slot", "s01"] {
6574 let path = configured.as_path().join(stray);
6575 assert_eq!(
6576 verify_journalled_slot(&path, nz(1), None)
6577 .unwrap_err()
6578 .refusal,
6579 SlotRefusal::NotTheJournalledSlot,
6580 "{}",
6581 path.display()
6582 );
6583 }
6584
6585 let elsewhere = tempfile::tempdir().unwrap();
6588 let other =
6589 LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
6590 assert_eq!(
6591 verify_journalled_slot(&slot, nz(1), Some(&other))
6592 .unwrap_err()
6593 .refusal,
6594 SlotRefusal::PolicyRootDisagrees
6595 );
6596 }
6597
6598 #[cfg(unix)]
6599 #[test]
6600 fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
6601 let root = tempfile::tempdir().unwrap();
6605 let outside = root.path().join("operator-data");
6606 fs::create_dir(&outside).unwrap();
6607 let sentinel = outside.join("do-not-delete.txt");
6608 fs::write(
6609 &sentinel,
6610 b"an operator's data, outside every approved root",
6611 )
6612 .unwrap();
6613
6614 let slot = root.path().join("s1");
6615 fs::create_dir(&slot).unwrap();
6616 fs::create_dir(slot.join("bin")).unwrap();
6617 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6618
6619 let quarantine = scrub_slot_entries(&slot).unwrap_err();
6620 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
6621 assert!(
6622 sentinel.exists(),
6623 "the deletion followed the link out of the slot"
6624 );
6625 assert!(outside.is_dir());
6626 assert!(
6627 slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
6628 "the substituted link is left for the operator, never unlinked as if it were ours"
6629 );
6630
6631 let file_work = root.path().join("s2");
6635 fs::create_dir(&file_work).unwrap();
6636 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
6637 assert_eq!(
6638 scrub_slot_entries(&file_work).unwrap_err().refusal,
6639 SlotRefusal::WorkNotADirectory
6640 );
6641 }
6642
6643 #[cfg(unix)]
6644 #[test]
6645 fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
6646 let root = tempfile::tempdir().unwrap();
6647 let outside = root.path().join("operator-data");
6648 fs::create_dir(&outside).unwrap();
6649 let sentinel = outside.join("do-not-delete.txt");
6650 fs::write(
6651 &sentinel,
6652 b"an operator's data, outside every approved root",
6653 )
6654 .unwrap();
6655
6656 let inside = root.path().join("inside");
6659 fs::create_dir(&inside).unwrap();
6660 let slot = inside.join("s1");
6661 std::os::unix::fs::symlink(&outside, &slot).unwrap();
6662
6663 assert_eq!(
6664 verify_journalled_slot(&slot, nz(1), None)
6665 .unwrap_err()
6666 .refusal,
6667 SlotRefusal::Containment
6668 );
6669 assert!(sentinel.exists());
6670 assert!(
6671 slot.symlink_metadata().is_ok(),
6672 "the link is left for the operator rather than removed as if it were ours"
6673 );
6674 }
6675
6676 #[cfg(windows)]
6686 #[test]
6687 fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
6688 let root = tempfile::tempdir().unwrap();
6689 let outside = root.path().join("operator-data");
6690 fs::create_dir(&outside).unwrap();
6691 let sentinel = outside.join("do-not-delete.txt");
6692 fs::write(
6693 &sentinel,
6694 b"an operator's data, outside every approved root",
6695 )
6696 .unwrap();
6697
6698 let inside = root.path().join("inside");
6701 fs::create_dir(&inside).unwrap();
6702 let slot = inside.join("s1");
6703 let Some(()) = plant_junction(&slot, &outside) else {
6704 eprintln!("skipped: this machine would not create a directory junction");
6705 return;
6706 };
6707
6708 assert_eq!(
6709 verify_journalled_slot(&slot, nz(1), None)
6710 .unwrap_err()
6711 .refusal,
6712 SlotRefusal::Containment
6713 );
6714 assert!(
6715 sentinel.exists(),
6716 "the refusal resolved the junction and reached the operator's data"
6717 );
6718 assert!(
6719 slot.symlink_metadata().is_ok(),
6720 "the junction is left for the operator rather than removed as if it were ours"
6721 );
6722 }
6723
6724 #[cfg(windows)]
6733 fn plant_junction(link: &Path, target: &Path) -> Option<()> {
6734 let made = std::process::Command::new("cmd")
6735 .arg("/C")
6736 .arg("mklink")
6737 .arg("/J")
6738 .arg(link)
6739 .arg(target)
6740 .output()
6741 .ok()?;
6742 (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
6743 }
6744
6745 #[cfg(windows)]
6746 #[test]
6747 fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
6748 let root = tempfile::tempdir().unwrap();
6749 let outside = root.path().join("operator-data");
6750 fs::create_dir(&outside).unwrap();
6751 let sentinel = outside.join("do-not-delete.txt");
6752 fs::write(
6753 &sentinel,
6754 b"an operator's data, outside every approved root",
6755 )
6756 .unwrap();
6757
6758 let slot = root.path().join("s1");
6759 fs::create_dir(&slot).unwrap();
6760 fs::create_dir(slot.join("bin")).unwrap();
6761 let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
6762 eprintln!("skipped: this machine would not create a directory junction");
6763 return;
6764 };
6765
6766 let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6770 assert!(is_link_like(&work), "a junction is a reparse point");
6771 let quarantine = scrub_slot_entries(&slot).unwrap_err();
6772 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
6773 assert!(
6774 sentinel.exists(),
6775 "the deletion followed the junction out of the slot"
6776 );
6777 assert!(outside.is_dir());
6778
6779 let elsewhere = root.path().join("s2");
6782 fs::create_dir(&elsewhere).unwrap();
6783 fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
6784 if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
6785 scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
6786 verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
6787 assert!(sentinel.exists(), "the junction was followed, not unlinked");
6788 assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
6789 }
6790 }
6791
6792 #[cfg(unix)]
6793 #[tokio::test]
6794 async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
6795 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6796 .with_persistent_workspace(2);
6797 harness.ready().await;
6798 let first = harness.launch().await;
6799 let slot = harness.slot_path(1);
6800 harness.conclude(first.id);
6801
6802 let outside = harness._root.path().join("operator-data");
6803 fs::create_dir_all(&outside).unwrap();
6804 let sentinel = outside.join("do-not-delete.txt");
6805 fs::write(&sentinel, b"outside every approved root").unwrap();
6806 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
6807
6808 harness
6809 .launcher
6810 .clean(first.id)
6811 .await
6812 .expect_err("a slot whose `_work` was substituted is quarantined");
6813 assert!(sentinel.exists());
6814
6815 let held = harness.attempt(first.id);
6816 assert_eq!(held.state(), AttemptState::Failed);
6817 assert!(held.holds_slot_lease());
6818
6819 let second = harness.launch().await;
6821 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
6822 }
6823
6824 #[test]
6825 fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
6826 let root = tempfile::tempdir().unwrap();
6827 let occupied = root.path().join("s1");
6828 fs::write(&occupied, b"an operator's file").unwrap();
6829 verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
6832 assert_eq!(
6833 slot_is_present(&occupied).unwrap_err().refusal,
6834 SlotRefusal::SlotNotADirectory
6835 );
6836 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
6837
6838 assert!(!slot_is_present(&root.path().join("s2")).unwrap());
6841 let present = root.path().join("s3");
6842 fs::create_dir(&present).unwrap();
6843 assert!(slot_is_present(&present).unwrap());
6844 }
6845
6846 #[test]
6847 fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
6848 let root = tempfile::tempdir().unwrap();
6849
6850 let disposable = root.path().join("abcdef012345");
6854 fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
6855 let ephemeral = RunnerAttempt::allocate(
6856 AttemptId::new_random(),
6857 fixtures::POLICY_ID,
6858 &disposable,
6859 fixtures::created_at(),
6860 );
6861 remove_materialized_package(&ephemeral).unwrap();
6862 assert!(!disposable.exists());
6863
6864 let slot = root.path().join("s1");
6866 fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
6867 fs::create_dir_all(slot.join("bin")).unwrap();
6868 let persistent = RunnerAttempt::allocate_in(
6869 AttemptId::new_random(),
6870 fixtures::POLICY_ID,
6871 &slot,
6872 AttemptWorkspace::persistent_slot(nz(1)),
6873 fixtures::created_at(),
6874 );
6875 remove_materialized_package(&persistent).unwrap();
6876 assert_eq!(entries_of(&slot), only_the_job_workspace());
6877 assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
6878 }
6879
6880 #[test]
6881 fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
6882 let refusals = [
6883 SlotRefusal::NotTheJournalledSlot,
6884 SlotRefusal::PolicyRootDisagrees,
6885 SlotRefusal::Containment,
6886 SlotRefusal::SlotNotADirectory,
6887 SlotRefusal::Enumeration,
6888 SlotRefusal::WorkNotADirectory,
6889 SlotRefusal::Deletion,
6890 SlotRefusal::Residue,
6891 ];
6892 let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
6893 assert_eq!(
6894 classes.len(),
6895 refusals.len(),
6896 "an event class shared by two refusals tells an operator less than it appears to"
6897 );
6898 for refusal in refusals {
6899 assert!(
6902 refusal
6903 .class()
6904 .chars()
6905 .all(|c| c.is_ascii_lowercase() || c == '_'),
6906 "{}",
6907 refusal.class()
6908 );
6909 assert!(
6910 refusal.remediation().contains("slot lease"),
6911 "every refusal has to say the lease is still held: {}",
6912 refusal.class()
6913 );
6914 }
6915 }
6916
6917 #[test]
6918 fn copy_package_tree_copies_files_and_preserves_paths_with_spaces() {
6919 let root = tempfile::tempdir().unwrap();
6920 let source = root.path().join("source with spaces");
6921 let dest = root.path().join("dest with spaces");
6922
6923 fs::create_dir_all(&source).unwrap();
6924 fs::write(source.join("file1.txt"), b"hello").unwrap();
6925
6926 let nested = source.join("nested dir");
6927 fs::create_dir_all(&nested).unwrap();
6928 fs::write(nested.join("file2.txt"), b"world").unwrap();
6929
6930 let nested_work = nested.join(DEFAULT_WORK_FOLDER);
6932 fs::create_dir_all(&nested_work).unwrap();
6933 fs::write(nested_work.join("allowed.txt"), b"allowed").unwrap();
6934
6935 copy_package_tree(&source, &dest).unwrap();
6936
6937 assert_eq!(fs::read_to_string(dest.join("file1.txt")).unwrap(), "hello");
6938 assert_eq!(
6939 fs::read_to_string(dest.join("nested dir").join("file2.txt")).unwrap(),
6940 "world"
6941 );
6942 assert_eq!(
6943 fs::read_to_string(
6944 dest.join("nested dir")
6945 .join(DEFAULT_WORK_FOLDER)
6946 .join("allowed.txt")
6947 )
6948 .unwrap(),
6949 "allowed"
6950 );
6951 }
6952
6953 #[test]
6954 fn copy_package_tree_refuses_top_level_work_folder() {
6955 let root = tempfile::tempdir().unwrap();
6956 let source = root.path().join("source");
6957 let dest = root.path().join("dest");
6958
6959 fs::create_dir_all(&source).unwrap();
6960 fs::write(source.join("file1.txt"), b"hello").unwrap();
6961
6962 let top_work = source.join(DEFAULT_WORK_FOLDER);
6964 fs::create_dir_all(&top_work).unwrap();
6965
6966 let err = copy_package_tree(&source, &dest).unwrap_err();
6967 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
6968 }
6969}