1#[cfg(test)]
12use std::collections::VecDeque;
13use std::collections::{BTreeMap, BTreeSet, HashMap};
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, IsolationProviderFailure,
26 RecoveryDecision, RecoveryObservation, RecoveryTimeouts, RunnerAttempt, authorize,
27 recovery_decision,
28};
29use runner_manager_domain::execution::{AttemptExecution, Backend, ImageReference};
30use runner_manager_domain::model::{AttemptId, Clock, HostId, PolicyId, ScaleTarget};
31use runner_manager_domain::path::LocalAbsolutePath;
32use runner_manager_domain::policy::ScalePolicy;
33use runner_manager_domain::store::{Store, StoreError};
34use runner_manager_domain::workspace::{AttemptWorkspace, WorkspacePolicy};
35use runner_manager_github::jit::{
36 DEFAULT_WORK_FOLDER, EncodedJitConfig, JitError, JitGateway, JitRegistration, JitRunnerRequest,
37};
38use runner_manager_github::rest::{CancelToken, InventoryGateway};
39use runner_manager_platform::process::{
40 Adoption, ChildProcess, ProcessIdentity, RestrictiveHandoff, SpawnSpec, Termination,
41};
42use runner_manager_platform::runner_env::{self, RunnerEnv, RunnerPlatform};
43use runner_manager_platform::runner_root::{
44 self, RootOwner, RootPreflight, RunnerRootError, default_runner_root,
45};
46use secrecy::SecretString;
47
48use crate::dependency_caches::DependencyCaches;
49use crate::oci::OciProcesses;
50use crate::package::{
51 InstalledPackage, LastCheck, PackageCache, PackageContents, PackageError, RunnerVersion,
52};
53use crate::reconcile::{
54 AllocationGuard, CleanBackoff, EventSink, LaunchFailure, LaunchRequest, LifecycleEvent,
55 OutcomeKind, ReplacementIntent, ReservationWaits, RunnerLauncher, failure_reason_kind,
56};
57
58mod windows_hyperv;
59pub use windows_hyperv::{WindowsHyperVContainers, WindowsHyperVHostState};
60
61const IDENTITY_FILE: &str = ".runner-process.json";
62const FALLBACK_IDENTITY_FILE: &str = ".runner-process.recovery.json";
63const UNRESOLVED_PROCESS_FILE: &str = ".runner-process.unresolved";
64const RUNNER_ID_FILE: &str = ".github-runner-id";
65const TERMINATE_INTENT_FILE: &str = ".terminate-registration-timeout";
66const MAX_POST_SPAWN_STOP_ATTEMPTS: usize = 3;
67
68const SENSITIVE_SLOT_ENTRIES: &[&str] = &[
81 "bin",
83 "externals",
84 "run.sh",
85 "run.cmd",
86 "config.sh",
87 "config.cmd",
88 ".runner",
91 ".credentials",
92 ".credentials_rsaparams",
93 ".env",
94 ".path",
95 "_diag",
96 IDENTITY_FILE,
98 FALLBACK_IDENTITY_FILE,
99 UNRESOLVED_PROCESS_FILE,
100 RUNNER_ID_FILE,
101 TERMINATE_INTENT_FILE,
102];
103#[cfg(test)]
104const TEST_LISTENER_READY: &str = ".test-listener-ready";
105
106fn runner_listener_spec(
118 program: PathBuf,
119 runtime: &Path,
120 caches: Vec<(&'static str, std::ffi::OsString)>,
121 host_env: &RunnerEnv,
122) -> SpawnSpec {
123 let tmp = runtime.join("tmp");
124 let _ = std::fs::create_dir_all(&tmp);
125 let defaults = runner_env::platform_defaults(
126 RunnerPlatform::current(),
127 runtime,
128 &runner_env::Inherited::current(),
129 Path::exists,
130 );
131 for (name, dir) in &defaults {
135 if runner_env::DIRECTORY_VARIABLES.contains(name) {
136 let _ = std::fs::create_dir_all(dir);
137 }
138 }
139 let mut spec = SpawnSpec::new(program)
140 .arg("run")
141 .working_dir(runtime)
142 .expect_normal_scheduling();
143 for (name, value) in runner_env::runner_environment(defaults, caches, host_env) {
144 spec = spec.env(name, value);
145 }
146 spec.env("TMPDIR", &tmp).env("TEMP", &tmp).env("TMP", &tmp)
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub struct RetryPolicy {
152 pub max_attempts: u32,
153 pub initial: Duration,
154 pub maximum: Duration,
155}
156
157impl RetryPolicy {
158 #[must_use]
159 pub const fn bounded(max_attempts: u32, initial: Duration, maximum: Duration) -> Self {
160 Self {
161 max_attempts,
162 initial,
163 maximum,
164 }
165 }
166
167 pub(crate) fn delay(self, failure_index: u32) -> Duration {
168 let shift = failure_index.saturating_sub(1).min(31);
169 self.initial
170 .saturating_mul(1_u32 << shift)
171 .min(self.maximum)
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
178pub enum AttemptEvent {
179 State {
180 attempt: AttemptId,
181 state: AttemptState,
182 },
183 Retry {
184 attempt: AttemptId,
185 operation: &'static str,
186 delay: Duration,
187 },
188 Launched {
192 attempt: AttemptId,
193 started: bool,
194 timings: LaunchTimings,
195 },
196 Adopted {
197 attempt: AttemptId,
198 },
199 RemoteIdentityRecovered {
200 attempt: AttemptId,
201 runner_id: u64,
202 },
203 TerminateIntent {
204 attempt: AttemptId,
205 },
206 Terminated {
207 attempt: AttemptId,
208 },
209 Deregistered {
213 attempt: AttemptId,
214 runner_id: u64,
215 },
216 Concluded {
217 attempt: AttemptId,
218 outcome: OutcomeKind,
219 },
220 Cleaned {
221 attempt: AttemptId,
222 outcome: OutcomeKind,
223 },
224 QuarantinedOwnedResource {
225 attempt: AttemptId,
226 },
227}
228
229pub trait AttemptEventSink: fmt::Debug + Send + Sync {
230 fn emit(&self, event: AttemptEvent);
231}
232
233#[derive(Debug, Default)]
234pub struct AttemptEventLog(Mutex<Vec<AttemptEvent>>);
235
236impl AttemptEventLog {
237 #[must_use]
238 pub fn events(&self) -> Vec<AttemptEvent> {
239 self.0
240 .lock()
241 .map(|events| events.clone())
242 .unwrap_or_default()
243 }
244}
245
246impl AttemptEventSink for AttemptEventLog {
247 fn emit(&self, event: AttemptEvent) {
248 if let Ok(mut events) = self.0.lock() {
249 events.push(event);
250 }
251 }
252}
253
254#[derive(Debug, Clone, Copy, Default)]
255pub struct NoAttemptEvents;
256
257impl AttemptEventSink for NoAttemptEvents {
258 fn emit(&self, _event: AttemptEvent) {}
259}
260
261#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
263pub struct LaunchTimings {
264 pub order_wait: Duration,
266 pub lock_wait: Duration,
269 pub turn_wait: Duration,
271 pub materialize: Duration,
272 pub package: PackageTimings,
274 pub prepare: Duration,
277 pub register: Duration,
278 pub spawn: Duration,
279}
280
281impl LaunchTimings {
282 #[must_use]
283 pub fn total(&self) -> Duration {
284 self.order_wait
285 + self.lock_wait
286 + self.turn_wait
287 + self.materialize
288 + self.prepare
289 + self.register
290 + self.spawn
291 }
292}
293
294#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
296pub struct PackageTimings {
297 pub wait: Duration,
300 pub install: Duration,
302 pub verify: Duration,
304 pub copy: Duration,
306 pub downloaded: bool,
308}
309
310#[derive(Debug, Clone, Copy)]
311enum LaunchStep {
312 Turn,
313 Materialize,
314 Prepare,
315 Register,
316 Spawn,
317}
318
319#[derive(Debug)]
323struct LaunchStopwatch {
324 timings: LaunchTimings,
325 running: Option<(LaunchStep, std::time::Instant)>,
326}
327
328impl LaunchStopwatch {
329 fn new(waits: ReservationWaits) -> Self {
330 Self {
331 timings: LaunchTimings {
332 order_wait: waits.order,
333 lock_wait: waits.lock,
334 ..LaunchTimings::default()
335 },
336 running: None,
337 }
338 }
339
340 fn begin(&mut self, step: LaunchStep) {
341 self.finish();
342 self.running = Some((step, std::time::Instant::now()));
343 }
344
345 fn finish(&mut self) {
346 if let Some((step, since)) = self.running.take() {
347 let slot = match step {
348 LaunchStep::Turn => &mut self.timings.turn_wait,
349 LaunchStep::Materialize => &mut self.timings.materialize,
350 LaunchStep::Prepare => &mut self.timings.prepare,
351 LaunchStep::Register => &mut self.timings.register,
352 LaunchStep::Spawn => &mut self.timings.spawn,
353 };
354 *slot = since.elapsed();
355 }
356 }
357}
358
359const SLOW_LAUNCH: Duration = Duration::from_secs(60);
364
365#[derive(Debug, Clone, Copy, Default)]
372pub struct TracingAttemptEvents;
373
374impl AttemptEventSink for TracingAttemptEvents {
375 fn emit(&self, event: AttemptEvent) {
376 match event {
377 AttemptEvent::Retry {
378 attempt,
379 operation,
380 delay,
381 } => tracing::warn!(
382 event = "attempt_retry",
383 attempt_id = %attempt,
384 reason = operation,
385 retry_in_ms = millis(delay),
386 ),
387 AttemptEvent::Launched {
388 attempt,
389 started,
390 timings,
391 } => {
392 macro_rules! launched {
393 ($level:ident, $name:literal) => {
394 tracing::$level!(
395 event = $name,
396 attempt_id = %attempt,
397 duration_ms = millis(timings.total()),
398 order_wait_ms = millis(timings.order_wait),
399 lock_wait_ms = millis(timings.lock_wait),
400 turn_wait_ms = millis(timings.turn_wait),
401 materialize_ms = millis(timings.materialize),
402 package_wait_ms = millis(timings.package.wait),
403 package_install_ms = millis(timings.package.install),
404 package_verify_ms = millis(timings.package.verify),
405 package_copy_ms = millis(timings.package.copy),
406 package_downloaded = timings.package.downloaded,
407 prepare_ms = millis(timings.prepare),
408 register_ms = millis(timings.register),
409 spawn_ms = millis(timings.spawn),
410 )
411 };
412 }
413 if !started {
414 launched!(warn, "attempt_launch_failed");
415 } else if timings.total() >= SLOW_LAUNCH {
416 launched!(warn, "attempt_launch_slow");
417 } else {
418 launched!(info, "attempt_launched");
419 }
420 }
421 _ => {}
422 }
423 }
424}
425
426fn millis(duration: Duration) -> u64 {
427 u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
428}
429
430#[async_trait]
432pub trait DemandPersistence: fmt::Debug + Send + Sync {
433 async fn persists(&self, policy: PolicyId) -> bool;
434}
435
436#[derive(Debug, Clone, Copy, Default)]
437pub struct PersistentDemand;
438
439#[async_trait]
440impl DemandPersistence for PersistentDemand {
441 async fn persists(&self, _policy: PolicyId) -> bool {
442 true
443 }
444}
445
446#[async_trait]
447pub trait RetryDelay: fmt::Debug + Send + Sync {
448 async fn wait(&self, duration: Duration);
449}
450
451#[derive(Debug, Clone, Copy, Default)]
452pub struct TokioRetryDelay;
453
454#[async_trait]
455impl RetryDelay for TokioRetryDelay {
456 async fn wait(&self, duration: Duration) {
457 tokio::time::sleep(duration).await;
458 }
459}
460
461#[derive(Debug, Clone, PartialEq, Eq)]
462pub struct JitRequestFailure {
463 pub terminal: bool,
464 pub reason: FailureReason,
465 pub retry_after: Option<Duration>,
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
472pub struct LifecycleGithubObservation {
473 pub status: GithubRunnerObservation,
474 pub runner_id: Option<u64>,
475}
476
477impl LifecycleGithubObservation {
478 #[must_use]
479 pub const fn unreachable() -> Self {
480 Self {
481 status: GithubRunnerObservation::Unreachable,
482 runner_id: None,
483 }
484 }
485
486 #[must_use]
487 pub const fn not_registered() -> Self {
488 Self {
489 status: GithubRunnerObservation::NotRegistered,
490 runner_id: None,
491 }
492 }
493
494 #[must_use]
495 pub const fn registered(runner_id: u64, busy: bool) -> Self {
496 Self {
497 status: GithubRunnerObservation::Registered { busy },
498 runner_id: Some(runner_id),
499 }
500 }
501}
502
503#[async_trait]
506pub trait LifecycleGithub: fmt::Debug + Send + Sync {
507 async fn register(
508 &self,
509 target: &ScaleTarget,
510 request: &JitRunnerRequest,
511 cancel: &CancelToken,
512 ) -> Result<JitRegistration, JitRequestFailure>;
513
514 async fn observe(
515 &self,
516 target: &ScaleTarget,
517 attempt: AttemptId,
518 cancel: &CancelToken,
519 ) -> LifecycleGithubObservation;
520
521 async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool;
528}
529
530#[async_trait]
531impl<T> LifecycleGithub for T
532where
533 T: JitGateway + InventoryGateway + fmt::Debug + Send + Sync,
534{
535 async fn register(
536 &self,
537 target: &ScaleTarget,
538 request: &JitRunnerRequest,
539 cancel: &CancelToken,
540 ) -> Result<JitRegistration, JitRequestFailure> {
541 self.generate_jit_config(target, request, cancel)
542 .await
543 .map_err(|error| {
544 let reason = if matches!(&error, JitError::Forbidden { .. }) {
545 FailureReason::Other(
546 "GitHub refused JIT registration with 403; check the App's runner permission and runner-group access"
547 .into(),
548 )
549 } else {
550 FailureReason::JitRequestFailed
551 };
552 JitRequestFailure {
553 terminal: error.is_terminal(),
554 reason,
555 retry_after: error
556 .rate_limited()
557 .map(|limit| limit.delay_from(self.now())),
558 }
559 })
560 }
561
562 async fn observe(
563 &self,
564 target: &ScaleTarget,
565 attempt: AttemptId,
566 cancel: &CancelToken,
567 ) -> LifecycleGithubObservation {
568 let expected_name = runner_name(attempt);
569 match self.list_runners(target, cancel).await {
570 Ok(inventory) => inventory
571 .runners()
572 .iter()
573 .find(|runner| runner.name == expected_name)
574 .map_or(LifecycleGithubObservation::not_registered(), |runner| {
575 LifecycleGithubObservation::registered(runner.id, runner.busy)
576 }),
577 Err(_) => LifecycleGithubObservation::unreachable(),
578 }
579 }
580
581 async fn deregister(&self, target: &ScaleTarget, runner_id: u64, cancel: &CancelToken) -> bool {
582 self.remove_runner(target, runner_id, cancel).await.is_ok()
583 }
584}
585
586#[async_trait]
588pub trait RuntimePackages: fmt::Debug + Send + Sync {
589 async fn materialize(
592 &self,
593 attempt: &RunnerAttempt,
594 timings: &mut PackageTimings,
595 ) -> Result<RunnerVersion, FailureReason>;
596 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason>;
597 fn prune_obsolete_guarded(
598 &self,
599 authority: PruneAuthority<'_>,
600 attempts: &[RunnerAttempt],
601 ) -> Result<(), FailureReason>;
602}
603
604pub struct PruneAuthority<'a> {
609 _guard: &'a AllocationGuard,
610}
611
612impl fmt::Debug for PruneAuthority<'_> {
613 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
614 f.write_str("PruneAuthority")
615 }
616}
617
618impl<'a> PruneAuthority<'a> {
619 fn from_launch_request(guard: &'a AllocationGuard) -> Self {
620 Self { _guard: guard }
621 }
622
623 #[cfg(test)]
624 pub(crate) const fn for_test(guard: &'a AllocationGuard) -> Self {
625 Self { _guard: guard }
626 }
627}
628
629#[derive(Debug, Clone, Default)]
636pub struct SharedPackages {
637 trees: Arc<tokio::sync::RwLock<()>>,
640 trusted: Arc<TrustedTrees>,
641 last_check: LastCheck,
642 failed_install: Arc<Mutex<Option<(std::time::Instant, FailureReason)>>>,
645}
646
647impl SharedPackages {
648 fn discard(
652 &self,
653 root: &Path,
654 origin: &Path,
655 contents: &Arc<PackageContents>,
656 reason: &'static str,
657 ) {
658 if self.trusted.forget_if_holding(root, contents) {
659 tracing::warn!(
660 event = "package_verify_failed",
661 reason,
662 "a shared runner package does not match its verified archive; it is discarded \
663 and prepared again"
664 );
665 self.trusted.forget(origin);
666 }
667 }
668
669 fn note_install(&self, result: Result<(), &FailureReason>) {
673 *self
674 .failed_install
675 .lock()
676 .unwrap_or_else(std::sync::PoisonError::into_inner) = result
677 .err()
678 .filter(|reason| !package_failure_is_terminal(reason))
679 .map(|reason| (std::time::Instant::now(), reason.clone()));
680 }
681
682 fn install_failed_since(&self, queued: std::time::Instant) -> Option<FailureReason> {
685 self.failed_install
686 .lock()
687 .unwrap_or_else(std::sync::PoisonError::into_inner)
688 .as_ref()
689 .filter(|(at, _)| *at >= queued)
690 .map(|(_, reason)| reason.clone())
691 }
692
693 #[must_use]
697 pub fn last_check(&self) -> LastCheck {
698 self.last_check.clone()
699 }
700}
701
702#[cfg(test)]
703impl SharedPackages {
704 pub(crate) async fn copying(&self) -> tokio::sync::RwLockReadGuard<'_, ()> {
705 self.trees.read().await
706 }
707
708 pub(crate) fn blind_to_edits_of(&self, root: &Path) {
711 let contents = self.trusted.contents_of(root);
712 self.trusted.record(root, contents).expect("recorded");
713 }
714
715 pub(crate) fn vouches_for(&self, root: &Path) -> bool {
716 self.trusted.contents_of(root).is_some()
717 }
718}
719
720#[derive(Debug)]
723pub struct CachedRuntimePackages {
724 cache: Arc<PackageCache>,
725 shared: SharedPackages,
726}
727
728async fn timed<T>(slot: &mut Duration, work: impl std::future::Future<Output = T>) -> T {
730 let began = std::time::Instant::now();
731 let result = work.await;
732 *slot += began.elapsed();
733 result
734}
735
736impl CachedRuntimePackages {
737 async fn blocking<T: Send + 'static>(
740 &self,
741 root: &Path,
742 work: impl FnOnce(&TrustedTrees, &Path) -> T + Send + 'static,
743 ) -> Result<T, FailureReason> {
744 let trusted = Arc::clone(&self.shared.trusted);
745 let root = root.to_path_buf();
746 tokio::task::spawn_blocking(move || work(&trusted, &root))
747 .await
748 .map_err(|_| FailureReason::ProcessStartFailed)
749 }
750
751 #[must_use]
753 pub const fn new(cache: Arc<PackageCache>, shared: SharedPackages) -> Self {
754 Self { cache, shared }
755 }
756
757 async fn reject_copy(
762 &self,
763 attempt: &RunnerAttempt,
764 root: &Path,
765 origin: &Path,
766 contents: &Arc<PackageContents>,
767 timings: &mut PackageTimings,
768 ) -> Result<(), FailureReason> {
769 let (attempt_copy, checking, from) =
770 (attempt.clone(), Arc::clone(contents), origin.to_path_buf());
771 let differs = timed(
772 &mut timings.verify,
773 self.blocking(root, move |_, source| {
774 let _ = remove_materialized_package(&attempt_copy);
775 let shared = checking.verify(source, |_| false);
776 shared.and_then(|()| {
777 if from == source {
778 Ok(())
779 } else {
780 checking.verify(&from, |_| false)
781 }
782 })
783 }),
784 )
785 .await?;
786 if let Err(mismatch) = differs {
787 self.shared
788 .discard(root, origin, contents, mismatch.as_str());
789 }
790 Ok(())
791 }
792
793 async fn ready(
797 &self,
798 destination: &Path,
799 timings: &mut PackageTimings,
800 ) -> Result<Option<(InstalledPackage, PathBuf)>, FailureReason> {
801 let Some(installed) = self.cache.reusable().map_err(package_failure)? else {
802 return Ok(None);
803 };
804 let destination = destination.to_path_buf();
805 let verifying = self.blocking(installed.root(), move |trusted, source| {
806 if trusted.holds(source) {
807 copy_origin_ready(source, &destination, trusted)
808 } else {
809 None
810 }
811 });
812 let origin = timed(&mut timings.verify, verifying).await?;
813 Ok(origin.map(|origin| (installed, origin)))
814 }
815
816 async fn install_trusted(
819 &self,
820 destination: &Path,
821 timings: &mut PackageTimings,
822 ) -> Result<(InstalledPackage, PathBuf), FailureReason> {
823 let (mut installed, downloaded) = timed(
824 &mut timings.install,
825 self.cache.ensure_installed_reporting(),
826 )
827 .await
828 .map_err(package_failure)?;
829 timings.downloaded |= downloaded;
830 let fresh = downloaded
835 || !timed(
836 &mut timings.verify,
837 self.blocking(installed.root(), TrustedTrees::holds),
838 )
839 .await?;
840 if fresh && !downloaded {
841 if self.shared.trusted.contents_of(installed.root()).is_some() {
843 tracing::warn!(
844 event = "package_verify_failed",
845 reason = "changed",
846 "a shared runner package changed since it was prepared; it is discarded and \
847 prepared again"
848 );
849 }
850 installed = timed(&mut timings.install, self.cache.reinstall())
851 .await
852 .map_err(package_failure)?;
853 timings.downloaded = true;
854 }
855 let destination = destination.to_path_buf();
856 let contents = installed.contents();
857 let origin = timed(
858 &mut timings.verify,
859 self.blocking(installed.root(), move |trusted, source| {
860 if fresh {
861 trusted.record(source, contents)?;
862 }
863 Ok::<_, std::io::Error>(copy_origin(source, &destination, trusted))
864 }),
865 )
866 .await?
867 .map_err(|_| FailureReason::ProcessStartFailed)?;
868 Ok((installed, origin))
869 }
870}
871
872#[async_trait]
873impl RuntimePackages for CachedRuntimePackages {
874 async fn materialize(
875 &self,
876 attempt: &RunnerAttempt,
877 timings: &mut PackageTimings,
878 ) -> Result<RunnerVersion, FailureReason> {
879 let destination = attempt.runtime_path().to_path_buf();
880 let trees = &self.shared.trees;
881 let mut installs = 0;
887 let (hold, (installed, origin)) = loop {
888 let queued = std::time::Instant::now();
889 let hold = timed(&mut timings.wait, trees.read()).await;
890 if let Some(ready) = self.ready(&destination, timings).await? {
891 break (hold, ready);
892 }
893 drop(hold);
894 let exclusive = timed(&mut timings.wait, trees.write()).await;
900 if let Some(reason) = self.shared.install_failed_since(queued) {
901 return Err(reason);
902 }
903 let installed = self.install_trusted(&destination, timings).await;
904 self.shared.note_install(installed.as_ref().map(|_| ()));
905 let ready = installed?;
906 installs += 1;
907 if installs == 2 {
908 break (exclusive.downgrade(), ready);
909 }
910 };
911 let Some(contents) = self.shared.trusted.contents_of(installed.root()) else {
914 self.shared.trusted.forget(installed.root());
915 return Err(FailureReason::ProcessStartFailed);
916 };
917 let copied_from = origin.clone();
918 let copied = timed(
921 &mut timings.copy,
922 self.blocking(installed.root(), {
923 let destination = destination.clone();
924 move |trusted, source| {
925 copy_package_from(source, &origin, &destination, trusted)?;
926 if trusted.holds(source) {
929 Ok(())
930 } else {
931 Err(std::io::Error::other(
932 "the runner package changed while copied",
933 ))
934 }
935 }
936 }),
937 )
938 .await?;
939 if copied.is_err() {
940 drop(hold);
943 self.reject_copy(attempt, installed.root(), &copied_from, &contents, timings)
944 .await?;
945 return Err(FailureReason::ProcessStartFailed);
946 }
947 if let Err(error) = self.cache.lease(attempt, installed.version()) {
948 drop(hold);
952 self.reject_copy(attempt, installed.root(), &copied_from, &contents, timings)
953 .await?;
954 return Err(package_failure(error));
955 }
956 drop(hold);
959 let persistent = matches!(attempt.workspace(), AttemptWorkspace::PersistentSlot { .. });
963 let checking = Arc::clone(&contents);
964 let verified = timed(
965 &mut timings.verify,
966 self.blocking(installed.root(), move |_, _| {
967 checking.verify(&destination, |name| persistent && is_work_folder(name))
968 }),
969 )
970 .await?;
971 if let Err(mismatch) = verified {
972 let _ = self.cache.release(attempt.id);
973 tracing::warn!(
974 event = "package_copy_differs",
975 attempt_id = %attempt.id,
976 reason = mismatch.as_str(),
977 "a runner's package copy does not match the package's verified archive; it is \
978 being removed and will not be used"
979 );
980 self.reject_copy(attempt, installed.root(), &copied_from, &contents, timings)
981 .await?;
982 return Err(FailureReason::ProcessStartFailed);
983 }
984 Ok(installed.version().clone())
985 }
986
987 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
988 self.cache.release(attempt).map_err(package_failure)
989 }
990
991 fn prune_obsolete_guarded(
995 &self,
996 _authority: PruneAuthority<'_>,
997 attempts: &[RunnerAttempt],
998 ) -> Result<(), FailureReason> {
999 let Ok(_exclusive) = self.shared.trees.try_write() else {
1000 return Ok(());
1001 };
1002 let Some(current) = self.cache.newest_installed().map_err(package_failure)? else {
1003 return Ok(());
1004 };
1005 for installed in self.cache.installed().map_err(package_failure)? {
1006 if installed.version() != current.version() {
1007 match self.cache.prune(installed.version(), attempts) {
1008 Ok(()) => self.shared.trusted.forget(installed.root()),
1009 Err(PackageError::VersionInUse { .. }) => {}
1010 Err(error) => return Err(package_failure(error)),
1011 }
1012 }
1013 }
1014 Ok(())
1015 }
1016}
1017
1018fn package_failure(error: PackageError) -> FailureReason {
1019 error.failure_reason().unwrap_or(FailureReason::Other(
1020 "runner package cache operation failed".into(),
1021 ))
1022}
1023
1024fn package_failure_is_terminal(reason: &FailureReason) -> bool {
1025 matches!(
1026 reason,
1027 FailureReason::RunnerPackageUnverified | FailureReason::RunnerVersionRejected
1028 )
1029}
1030
1031const WORKSPACE_NAME_LEN: usize = 12;
1054
1055fn workspace_name(id: AttemptId) -> String {
1057 let full = id.to_string();
1058 full.chars()
1059 .filter(|c| *c != '-')
1060 .take(WORKSPACE_NAME_LEN)
1061 .collect()
1062}
1063
1064#[derive(Debug, Clone)]
1071struct Placement {
1072 runtime: PathBuf,
1073 workspace: AttemptWorkspace,
1074}
1075
1076fn root_failure(error: RunnerRootError) -> LifecycleError {
1083 tracing::warn!(
1100 error_kind = error.kind(),
1101 "the runner root refused this launch, so no attempt was created; the host will \
1102 retry every poll until the cause is resolved. Re-running `host set-runtime-root` \
1103 with the same path re-runs this check and prints the directory and the \
1104 remediation in full"
1105 );
1106 LifecycleError::Failed(FailureReason::Other(error.to_string()))
1107}
1108
1109fn lowest_free_slot(leases: &[RunnerAttempt], ceiling: NonZeroU16) -> Option<NonZeroU16> {
1118 let held: BTreeSet<u16> = leases
1119 .iter()
1120 .filter_map(|attempt| attempt.workspace().slot_number())
1121 .collect();
1122 (1..=ceiling.get())
1123 .find(|slot| !held.contains(slot))
1124 .and_then(NonZeroU16::new)
1125}
1126
1127fn create_or_validate_slot(slot: &Path) -> Result<(), LifecycleError> {
1135 match fs::symlink_metadata(slot) {
1136 Ok(metadata) if is_link_like(&metadata) => Err(slot_refusal(
1142 slot,
1143 "is a symbolic link, junction or other reparse point, which could place runner \
1144 files outside the configured root",
1145 )),
1146 Ok(metadata) if !metadata.is_dir() => Err(slot_refusal(slot, "is not a directory")),
1147 Ok(_) => Ok(()),
1148 Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(slot)
1149 .map_err(|source| slot_refusal(slot, format!("could not be created: {source}"))),
1150 Err(source) => Err(slot_refusal(
1151 slot,
1152 format!("could not be inspected: {source}"),
1153 )),
1154 }
1155}
1156
1157fn accept_reusable_slot(slot: &Path) -> Result<(), LifecycleError> {
1169 let unreadable =
1170 |source: std::io::Error| slot_refusal(slot, format!("could not be read: {source}"));
1171 let entries = fs::read_dir(slot).map_err(unreadable)?;
1172 let mut refused: Vec<String> = Vec::new();
1173 for entry in entries {
1174 let entry = entry.map_err(unreadable)?;
1175 let name = entry.file_name();
1176 let metadata = fs::symlink_metadata(entry.path()).map_err(|source| {
1177 slot_refusal(
1178 slot,
1179 format!("entry {name:?} could not be inspected: {source}"),
1180 )
1181 })?;
1182 if is_retainable_work_folder(&name, &metadata) {
1187 continue;
1188 }
1189 refused.push(name.to_string_lossy().into_owned());
1190 }
1191 if refused.is_empty() {
1192 return Ok(());
1193 }
1194 refused.sort();
1195 Err(slot_refusal(
1196 slot,
1197 format!(
1198 "holds {} that this attempt may not reuse: [{}]. A reusable slot is empty or holds \
1199 one real `{DEFAULT_WORK_FOLDER}` directory and nothing else; remove or move the \
1200 entries listed, or let cleanup and recovery resolve them",
1201 if refused.len() == 1 {
1202 "an entry"
1203 } else {
1204 "entries"
1205 },
1206 refused.join(", ")
1207 ),
1208 ))
1209}
1210
1211fn slot_refusal(slot: &Path, detail: impl fmt::Display) -> LifecycleError {
1212 LifecycleError::Failed(FailureReason::Other(format!(
1213 "the persistent slot {} {detail}",
1214 slot.display()
1215 )))
1216}
1217
1218fn is_work_folder(name: &OsStr) -> bool {
1228 if cfg!(windows) {
1229 name.eq_ignore_ascii_case(DEFAULT_WORK_FOLDER)
1230 } else {
1231 name == OsStr::new(DEFAULT_WORK_FOLDER)
1232 }
1233}
1234
1235fn is_link_like(metadata: &fs::Metadata) -> bool {
1244 if metadata.file_type().is_symlink() {
1245 return true;
1246 }
1247 #[cfg(windows)]
1248 {
1249 use std::os::windows::fs::MetadataExt;
1250
1251 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
1252 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
1253 }
1254 #[cfg(not(windows))]
1255 false
1256}
1257
1258fn is_retainable_work_folder(name: &OsStr, metadata: &fs::Metadata) -> bool {
1266 is_work_folder(name) && metadata.is_dir() && !is_link_like(metadata)
1267}
1268
1269fn remove_materialized_package(attempt: &RunnerAttempt) -> std::io::Result<()> {
1276 match attempt.workspace() {
1277 AttemptWorkspace::Ephemeral => remove_runtime_tree(attempt.runtime_path()),
1278 AttemptWorkspace::PersistentSlot { .. } => scrub_slot_entries(attempt.runtime_path())
1279 .map_err(|quarantine| std::io::Error::other(quarantine.to_string())),
1280 }
1281}
1282
1283fn remove_runtime_tree(path: &Path) -> std::io::Result<()> {
1293 #[cfg(windows)]
1294 {
1295 match remove_dir_all::remove_dir_all(path) {
1296 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
1297 unlink_reparse_points(path);
1298 remove_dir_all::remove_dir_all(path)
1299 }
1300 other => other,
1301 }
1302 }
1303 #[cfg(not(windows))]
1304 {
1305 fs::remove_dir_all(path)
1306 }
1307}
1308
1309#[cfg(windows)]
1329fn unlink_reparse_points(dir: &Path) {
1330 let Ok(entries) = fs::read_dir(dir) else {
1331 return;
1332 };
1333 for entry in entries.flatten() {
1334 let path = entry.path();
1335 let Ok(metadata) = entry.metadata() else {
1336 continue;
1337 };
1338 if is_link_like(&metadata) {
1339 let _ = fs::remove_dir(&path).or_else(|_| fs::remove_file(&path));
1340 } else if metadata.is_dir() {
1341 unlink_reparse_points(&path);
1342 }
1343 }
1344}
1345
1346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1361enum SlotRefusal {
1362 NotTheJournalledSlot,
1364 PolicyRootDisagrees,
1366 Containment,
1368 SlotNotADirectory,
1370 Enumeration,
1372 WorkNotADirectory,
1374 Deletion,
1376 Residue,
1378}
1379
1380impl SlotRefusal {
1381 const fn class(self) -> &'static str {
1383 match self {
1384 Self::NotTheJournalledSlot => "slot_path_is_not_the_journalled_slot",
1385 Self::PolicyRootDisagrees => "slot_root_disagrees_with_policy",
1386 Self::Containment => "slot_escapes_its_root",
1387 Self::SlotNotADirectory => "slot_is_not_a_directory",
1388 Self::Enumeration => "slot_could_not_be_enumerated",
1389 Self::WorkNotADirectory => "retained_work_is_not_a_directory",
1390 Self::Deletion => "slot_entry_could_not_be_removed",
1391 Self::Residue => "slot_still_holds_runner_state",
1392 }
1393 }
1394
1395 const fn remediation(self) -> &'static str {
1397 match self {
1398 Self::NotTheJournalledSlot | Self::PolicyRootDisagrees | Self::Containment => {
1399 "the attempt keeps its slot lease and nothing was removed; correct the \
1400 repository's persistent workspace path, or remove the slot directory by hand \
1401 once you have confirmed what is in it"
1402 }
1403 Self::SlotNotADirectory | Self::WorkNotADirectory => {
1404 "the attempt keeps its slot lease and nothing was removed; a job replaced the \
1405 slot or its `_work` with a link, so inspect it before deleting anything and \
1406 treat the retained workspace as untrusted"
1407 }
1408 Self::Enumeration | Self::Deletion | Self::Residue => {
1409 "the attempt keeps its slot lease and will be cleaned again on the next pass; \
1410 release whatever is holding the files open, or remove the slot's contents by \
1411 hand leaving only `_work`"
1412 }
1413 }
1414 }
1415}
1416
1417#[derive(Debug, Clone, PartialEq, Eq)]
1424struct SlotQuarantine {
1425 refusal: SlotRefusal,
1426 detail: String,
1427}
1428
1429impl SlotQuarantine {
1430 fn new(refusal: SlotRefusal, detail: impl Into<String>) -> Self {
1431 Self {
1432 refusal,
1433 detail: detail.into(),
1434 }
1435 }
1436}
1437
1438impl fmt::Display for SlotQuarantine {
1439 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1440 write!(f, "{}; {}", self.detail, self.refusal.remediation())
1441 }
1442}
1443
1444fn verify_journalled_slot(
1458 runtime: &Path,
1459 slot: NonZeroU16,
1460 configured: Option<&LocalAbsolutePath>,
1461) -> Result<(), SlotQuarantine> {
1462 let mislaid = || {
1463 SlotQuarantine::new(
1464 SlotRefusal::NotTheJournalledSlot,
1465 format!(
1466 "the journalled runtime {} is not the slot s{slot} this attempt was allocated as",
1467 runtime.display()
1468 ),
1469 )
1470 };
1471 let local = |path: &Path| {
1472 path.to_str()
1473 .and_then(|raw| LocalAbsolutePath::new(raw).ok())
1474 .ok_or_else(mislaid)
1475 };
1476
1477 let runtime_path = local(runtime)?;
1478 let root = local(runtime.parent().ok_or_else(mislaid)?)?;
1479 let name = AttemptWorkspace::persistent_slot(slot)
1486 .slot_directory_name()
1487 .expect("a persistent workspace names its slot directory");
1488 let derived = runner_root::derive_child(&root, &name).map_err(|_| mislaid())?;
1489 if derived != runtime_path {
1490 return Err(mislaid());
1491 }
1492 if let Some(configured) = configured
1493 && configured != &root
1494 {
1495 return Err(SlotQuarantine::new(
1496 SlotRefusal::PolicyRootDisagrees,
1497 format!(
1498 "the journalled slot {} is not under the repository's configured persistent root \
1499 {}",
1500 runtime.display(),
1501 configured.as_str()
1502 ),
1503 ));
1504 }
1505 runner_root::verify_containment(&root, &derived).map_err(|source| {
1508 SlotQuarantine::new(
1509 SlotRefusal::Containment,
1510 format!("the journalled slot is not inside the root it was allocated from: {source}"),
1511 )
1512 })
1513}
1514
1515fn slot_is_present(slot: &Path) -> Result<bool, SlotQuarantine> {
1526 match fs::symlink_metadata(slot) {
1527 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
1528 Err(source) => Err(SlotQuarantine::new(
1529 SlotRefusal::SlotNotADirectory,
1530 format!(
1531 "the slot {} could not be inspected: {:?}",
1532 slot.display(),
1533 source.kind()
1534 ),
1535 )),
1536 Ok(metadata) if !metadata.is_dir() || is_link_like(&metadata) => Err(SlotQuarantine::new(
1537 SlotRefusal::SlotNotADirectory,
1538 format!(
1539 "the slot {} is a link or a file rather than a real directory",
1540 slot.display()
1541 ),
1542 )),
1543 Ok(_) => Ok(true),
1544 }
1545}
1546
1547fn scrub_slot_entries(slot: &Path) -> Result<(), SlotQuarantine> {
1562 let unreadable = |source: std::io::Error| {
1563 SlotQuarantine::new(
1564 SlotRefusal::Enumeration,
1565 format!(
1566 "the entries of {} could not be listed: {:?}",
1567 slot.display(),
1568 source.kind()
1569 ),
1570 )
1571 };
1572 for entry in fs::read_dir(slot).map_err(unreadable)? {
1573 let name = entry.map_err(unreadable)?.file_name();
1574 let path = slot.join(&name);
1575 let Some(metadata) = listed_entry_metadata(&path).map_err(unreadable)? else {
1578 continue;
1579 };
1580 if is_work_folder(&name) {
1581 if is_retainable_work_folder(&name, &metadata) {
1582 continue;
1583 }
1584 return Err(SlotQuarantine::new(
1585 SlotRefusal::WorkNotADirectory,
1586 format!(
1587 "the retained `{DEFAULT_WORK_FOLDER}` in {} is a link or a file rather than a \
1588 real directory",
1589 slot.display()
1590 ),
1591 ));
1592 }
1593 remove_slot_entry(&path, &metadata).map_err(|source| {
1594 SlotQuarantine::new(
1595 SlotRefusal::Deletion,
1596 format!(
1597 "an entry of {} could not be removed: {:?}",
1598 slot.display(),
1599 source.kind()
1600 ),
1601 )
1602 })?;
1603 }
1604 Ok(())
1605}
1606
1607fn listed_entry_metadata(path: &Path) -> std::io::Result<Option<fs::Metadata>> {
1613 match fs::symlink_metadata(path) {
1614 Ok(metadata) => Ok(Some(metadata)),
1615 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
1616 Err(source) => Err(source),
1617 }
1618}
1619
1620fn remove_slot_entry(path: &Path, metadata: &fs::Metadata) -> std::io::Result<()> {
1625 let removed = if is_link_like(metadata) {
1626 fs::remove_file(path).or_else(|_| fs::remove_dir(path))
1629 } else if metadata.is_dir() {
1630 remove_runtime_tree(path)
1631 } else {
1632 fs::remove_file(path)
1633 };
1634 match removed {
1635 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1636 other => other,
1637 }
1638}
1639
1640fn verify_slot_scrubbed(slot: &Path) -> Result<(), SlotQuarantine> {
1657 let unreadable = |source: std::io::Error| {
1658 SlotQuarantine::new(
1659 SlotRefusal::Enumeration,
1660 format!(
1661 "the entries of {} could not be listed to verify the scrub: {:?}",
1662 slot.display(),
1663 source.kind()
1664 ),
1665 )
1666 };
1667 let mut residue = 0_usize;
1668 let mut named: Vec<String> = Vec::new();
1669 for entry in fs::read_dir(slot).map_err(unreadable)? {
1670 let name = entry.map_err(unreadable)?.file_name();
1671 let Some(metadata) = listed_entry_metadata(&slot.join(&name)).map_err(unreadable)? else {
1675 continue;
1676 };
1677 if is_retainable_work_folder(&name, &metadata) {
1678 continue;
1679 }
1680 residue = residue.saturating_add(1);
1681 if name
1682 .to_string_lossy()
1683 .starts_with(RestrictiveHandoff::NAME_PREFIX)
1684 {
1685 named.push("an encoded JIT handoff".to_owned());
1686 }
1687 }
1688 named.extend(
1689 SENSITIVE_SLOT_ENTRIES
1690 .iter()
1691 .filter(|entry| fs::symlink_metadata(slot.join(entry)).is_ok())
1692 .map(|entry| format!("`{entry}`")),
1693 );
1694 if residue == 0 && named.is_empty() {
1695 return Ok(());
1696 }
1697 named.sort_unstable();
1698 named.dedup();
1699 Err(SlotQuarantine::new(
1700 SlotRefusal::Residue,
1701 residue_detail(slot, residue, &named),
1702 ))
1703}
1704
1705fn residue_detail(slot: &Path, residue: usize, named: &[String]) -> String {
1715 if residue == 0 {
1716 format!(
1720 "the listing of {} reported nothing but `{DEFAULT_WORK_FOLDER}`, yet {} survived \
1721 cleanup",
1722 slot.display(),
1723 named.join(", ")
1724 )
1725 } else {
1726 format!(
1727 "{residue} entr{} other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}{}",
1728 if residue == 1 { "y" } else { "ies" },
1729 slot.display(),
1730 if named.is_empty() {
1731 String::new()
1732 } else {
1733 format!(", including {}", named.join(", "))
1734 }
1735 )
1736 }
1737}
1738
1739fn replacement_operation(outcome: &AttemptOutcome) -> Option<&'static str> {
1740 match outcome {
1741 AttemptOutcome::Failed {
1742 reason: FailureReason::JitExpired,
1743 } => Some("jit_expired_replacement"),
1744 AttemptOutcome::Failed {
1745 reason: FailureReason::ProcessExitedUnexpectedly,
1746 } => Some("exit_before_acceptance_replacement"),
1747 _ => None,
1748 }
1749}
1750
1751fn copy_origin(source: &Path, destination: &Path, seeds: &TrustedTrees) -> PathBuf {
1778 #[cfg(unix)]
1779 {
1780 package_seed(source, destination, seeds)
1781 .ok()
1782 .filter(|seed| refuse_work_folder(seed).is_ok())
1783 .unwrap_or_else(|| source.to_path_buf())
1784 }
1785 #[cfg(not(unix))]
1786 {
1787 let _ = (destination, seeds);
1788 source.to_path_buf()
1789 }
1790}
1791
1792fn copy_origin_ready(source: &Path, destination: &Path, seeds: &TrustedTrees) -> Option<PathBuf> {
1795 #[cfg(unix)]
1796 {
1797 match seed_location(source, destination) {
1798 Ok(None) => Some(source.to_path_buf()),
1799 Ok(Some(seeds_root)) => held_seed(source, &seeds_root, seeds)
1800 .ok()
1801 .flatten()
1802 .filter(|seed| refuse_work_folder(seed).is_ok()),
1803 Err(_) => None,
1804 }
1805 }
1806 #[cfg(not(unix))]
1807 {
1808 let _ = (destination, seeds);
1809 Some(source.to_path_buf())
1810 }
1811}
1812
1813fn copy_package_from(
1816 source: &Path,
1817 origin: &Path,
1818 destination: &Path,
1819 seeds: &TrustedTrees,
1820) -> std::io::Result<()> {
1821 refuse_work_folder(source)?;
1822
1823 #[cfg(unix)]
1824 {
1825 clone_tree(origin, destination)?;
1826 if origin != source && !seeds.holds(origin) {
1829 return Err(std::io::Error::other(
1830 "the package seed changed while cloned",
1831 ));
1832 }
1833 Ok(())
1834 }
1835 #[cfg(not(unix))]
1836 {
1837 let _ = (origin, seeds);
1838 copy_package_entries(source, destination, true)
1839 }
1840}
1841
1842fn refuse_work_folder(source: &Path) -> std::io::Result<()> {
1843 if source.join(DEFAULT_WORK_FOLDER).exists() {
1844 return Err(std::io::Error::new(
1845 std::io::ErrorKind::InvalidData,
1846 "a cached runner package contains a _work folder, which means it was used \
1847 to run a job before it was archived; the cache must only contain clean \
1848 extracts to prevent data leakage",
1849 ));
1850 }
1851 Ok(())
1852}
1853
1854#[cfg(unix)]
1856const PACKAGE_SEED_DIR: &str = ".runner-package";
1857
1858#[cfg(unix)]
1877fn package_seed(
1878 source: &Path,
1879 destination: &Path,
1880 seeds: &TrustedTrees,
1881) -> std::io::Result<PathBuf> {
1882 match seed_location(source, destination)? {
1883 Some(root) => refresh_package_seed(source, &root, seeds),
1884 None => Ok(source.to_path_buf()),
1885 }
1886}
1887
1888#[cfg(unix)]
1890fn seed_location(source: &Path, destination: &Path) -> std::io::Result<Option<PathBuf>> {
1891 use std::os::unix::fs::MetadataExt as _;
1892
1893 if !cfg!(target_os = "macos") {
1894 return Ok(None);
1895 }
1896 let root = destination
1897 .parent()
1898 .ok_or_else(|| std::io::Error::other("a runtime directory has no parent"))?;
1899 if fs::metadata(source)?.dev() == fs::metadata(root)?.dev() {
1900 return Ok(None);
1901 }
1902 Ok(Some(root.join(PACKAGE_SEED_DIR)))
1903}
1904
1905#[derive(Debug, Default)]
1922pub(crate) struct TrustedTrees {
1923 trees: Mutex<HashMap<PathBuf, TrustedTree>>,
1924}
1925
1926#[derive(Debug, Clone)]
1927struct TrustedTree {
1928 #[cfg(unix)]
1929 print: [u8; 32],
1930 contents: Option<Arc<PackageContents>>,
1931}
1932
1933impl TrustedTrees {
1934 fn trees(&self) -> std::sync::MutexGuard<'_, HashMap<PathBuf, TrustedTree>> {
1935 self.trees
1936 .lock()
1937 .unwrap_or_else(std::sync::PoisonError::into_inner)
1938 }
1939
1940 fn holds(&self, root: &Path) -> bool {
1943 let Some(tree) = self.trees().get(root).cloned() else {
1944 return false;
1945 };
1946 #[cfg(unix)]
1947 {
1948 tree_fingerprint(root).is_ok_and(|current| current == tree.print)
1949 }
1950 #[cfg(not(unix))]
1951 {
1952 let _ = tree;
1953 true
1954 }
1955 }
1956
1957 fn contents_of(&self, root: &Path) -> Option<Arc<PackageContents>> {
1959 self.trees()
1960 .get(root)
1961 .and_then(|tree| tree.contents.clone())
1962 }
1963
1964 fn record(&self, root: &Path, contents: Option<Arc<PackageContents>>) -> std::io::Result<()> {
1966 let tree = TrustedTree {
1967 #[cfg(unix)]
1968 print: tree_fingerprint(root)?,
1969 contents,
1970 };
1971 self.trees().insert(root.to_path_buf(), tree);
1972 Ok(())
1973 }
1974
1975 fn forget_if_holding(&self, root: &Path, contents: &Arc<PackageContents>) -> bool {
1978 let mut trees = self.trees();
1979 let holding = trees
1980 .get(root)
1981 .and_then(|tree| tree.contents.as_ref())
1982 .is_some_and(|current| Arc::ptr_eq(current, contents));
1983 if holding {
1984 trees.remove(root);
1985 }
1986 holding
1987 }
1988
1989 fn forget(&self, root: &Path) {
1991 self.trees().remove(root);
1992 }
1993}
1994
1995#[cfg(unix)]
1998fn tree_fingerprint(root: &Path) -> std::io::Result<[u8; 32]> {
1999 use sha2::{Digest as _, Sha256};
2000 use std::os::unix::fs::MetadataExt as _;
2001
2002 fn walk(root: &Path, path: &Path, hasher: &mut Sha256) -> std::io::Result<()> {
2003 let metadata = fs::symlink_metadata(path)?;
2004 let relative = path.strip_prefix(root).unwrap_or(path);
2005 hasher.update(relative.as_os_str().as_encoded_bytes());
2006 hasher.update([0]);
2007 for field in [
2008 metadata.ino(),
2009 metadata.size(),
2010 u64::from(metadata.mode()),
2011 u64::from(metadata.uid()),
2012 u64::from(metadata.gid()),
2013 ] {
2014 hasher.update(field.to_le_bytes());
2015 }
2016 hasher.update(metadata.ctime().to_le_bytes());
2017 hasher.update(metadata.ctime_nsec().to_le_bytes());
2018 if metadata.is_dir() {
2019 let mut children = fs::read_dir(path)?
2020 .map(|entry| entry.map(|entry| entry.path()))
2021 .collect::<std::io::Result<Vec<_>>>()?;
2022 children.sort();
2023 for child in children {
2024 walk(root, &child, hasher)?;
2025 }
2026 }
2027 Ok(())
2028 }
2029
2030 let mut hasher = Sha256::new();
2031 walk(root, root, &mut hasher)?;
2032 Ok(hasher.finalize().into())
2033}
2034
2035#[cfg(unix)]
2036fn seed_path(source: &Path, seeds: &Path) -> std::io::Result<PathBuf> {
2037 let version = source
2038 .file_name()
2039 .ok_or_else(|| std::io::Error::other("a cache entry has no name"))?;
2040 Ok(seeds.join(version))
2041}
2042
2043#[cfg(unix)]
2046fn held_seed(
2047 source: &Path,
2048 seeds: &Path,
2049 trusted: &TrustedTrees,
2050) -> std::io::Result<Option<PathBuf>> {
2051 use crate::package::MANIFEST_FILE;
2052
2053 let seed = seed_path(source, seeds)?;
2054 let manifest = fs::read(source.join(MANIFEST_FILE))?;
2055 let held = fs::read(seed.join(MANIFEST_FILE)).is_ok_and(|held| held == manifest)
2056 && trusted.holds(&seed);
2057 Ok(held.then_some(seed))
2058}
2059
2060#[cfg(unix)]
2062fn refresh_package_seed(
2063 source: &Path,
2064 seeds: &Path,
2065 trusted: &TrustedTrees,
2066) -> std::io::Result<PathBuf> {
2067 if let Some(seed) = held_seed(source, seeds, trusted)? {
2068 return Ok(seed);
2069 }
2070 let seed = seed_path(source, seeds)?;
2071
2072 match remove_runtime_tree(seeds) {
2074 Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error),
2075 _ => {}
2076 }
2077 fs::create_dir_all(seeds)?;
2078 let staging = seeds.join(format!(".staging-{}", uuid::Uuid::new_v4()));
2079 fs::create_dir(&staging)?;
2080 if let Err(error) = clone_tree(source, &staging).and_then(|()| fs::rename(&staging, &seed)) {
2082 let _ = remove_runtime_tree(&staging);
2083 return Err(error);
2084 }
2085 trusted.record(&seed, None)?;
2087 Ok(seed)
2088}
2089
2090#[cfg(unix)]
2095fn clone_tree(source: &Path, destination: &Path) -> std::io::Result<()> {
2096 #[cfg(target_os = "macos")]
2097 const CLONE: &[&str] = &["-c"];
2098 #[cfg(target_os = "linux")]
2099 const CLONE: &[&str] = &["--reflink=auto"];
2100 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
2101 const CLONE: &[&str] = &[];
2102
2103 let from = format!("{}/.", source.display());
2104 let cp = |extra: &[&str]| {
2105 std::process::Command::new("cp")
2106 .arg("-a")
2107 .args(extra)
2108 .arg(&from)
2109 .arg(destination)
2110 .status()
2111 };
2112 if cp(CLONE)?.success() || cp(&[])?.success() {
2113 Ok(())
2114 } else {
2115 Err(std::io::Error::other("cp failed"))
2116 }
2117}
2118
2119#[cfg(not(unix))]
2120fn copy_package_entries(source: &Path, destination: &Path, top_level: bool) -> std::io::Result<()> {
2121 fs::create_dir_all(destination)?;
2122 for entry in fs::read_dir(source)? {
2123 let entry = entry?;
2124 if top_level && is_work_folder(&entry.file_name()) {
2125 return Err(std::io::Error::new(
2126 std::io::ErrorKind::InvalidData,
2127 format!(
2128 "the runner package holds a top-level `{DEFAULT_WORK_FOLDER}`; copying \
2129 it would overwrite the job workspace a persistent slot retains"
2130 ),
2131 ));
2132 }
2133 let target = destination.join(entry.file_name());
2134 if entry.file_type()?.is_dir() {
2135 copy_package_entries(&entry.path(), &target, false)?;
2136 } else {
2137 fs::copy(entry.path(), target)?;
2138 }
2139 }
2140 Ok(())
2141}
2142
2143#[derive(Debug, Clone, PartialEq, Eq)]
2146pub struct ProcessStartFailure {
2147 pub reason: FailureReason,
2148 pub retryable: bool,
2151 pub live_pid: Option<u32>,
2154}
2155
2156impl ProcessStartFailure {
2157 pub(crate) fn before_spawn(reason: FailureReason) -> Self {
2158 Self {
2159 reason,
2160 retryable: true,
2161 live_pid: None,
2162 }
2163 }
2164
2165 fn after_spawn_stopped() -> Self {
2166 Self {
2167 reason: FailureReason::ProcessStartFailed,
2168 retryable: false,
2169 live_pid: None,
2170 }
2171 }
2172
2173 fn after_spawn_live(pid: u32) -> Self {
2174 Self::after_spawn_live_with_reason(pid, FailureReason::ProcessStartFailed)
2175 }
2176
2177 fn after_spawn_live_with_reason(pid: u32, reason: FailureReason) -> Self {
2178 Self {
2179 reason,
2180 retryable: false,
2181 live_pid: Some(pid),
2182 }
2183 }
2184}
2185
2186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2189pub enum ProviderCapability {
2190 Ready,
2191 Unsupported,
2192 NotInstalled,
2193 PermissionDenied,
2194 ImageUnavailableOrIncompatible,
2195 DiskQuotaUnavailable,
2196 Degraded,
2197}
2198
2199impl ProviderCapability {
2200 #[must_use]
2201 pub const fn display_name(self) -> &'static str {
2202 match self {
2203 Self::Ready => "ready",
2204 Self::Unsupported => "unsupported",
2205 Self::NotInstalled => "not installed",
2206 Self::PermissionDenied => "permission denied",
2207 Self::ImageUnavailableOrIncompatible => "image unavailable or incompatible",
2208 Self::DiskQuotaUnavailable => "disk quota unavailable",
2209 Self::Degraded => "degraded",
2210 }
2211 }
2212
2213 pub(crate) fn refusal(self) -> FailureReason {
2214 FailureReason::IsolationProvider(match self {
2215 Self::Ready | Self::Degraded => IsolationProviderFailure::Degraded,
2216 Self::Unsupported => IsolationProviderFailure::Unsupported,
2217 Self::NotInstalled => IsolationProviderFailure::NotInstalled,
2218 Self::PermissionDenied => IsolationProviderFailure::PermissionDenied,
2219 Self::ImageUnavailableOrIncompatible => {
2220 IsolationProviderFailure::ImageUnavailableOrIncompatible
2221 }
2222 Self::DiskQuotaUnavailable => IsolationProviderFailure::DiskQuotaUnavailable,
2223 })
2224 }
2225}
2226
2227fn safe_isolation_reason(
2230 reason: FailureReason,
2231 fallback: IsolationProviderFailure,
2232) -> FailureReason {
2233 match reason {
2234 FailureReason::IsolationProvider(_) => reason,
2235 _ => FailureReason::IsolationProvider(fallback),
2236 }
2237}
2238
2239#[derive(Debug, Clone, PartialEq, Eq)]
2240pub struct PreparedEnvironment {
2241 attempt: AttemptId,
2242 identity: Option<EnvironmentIdentity>,
2243 cache_target: Option<runner_manager_domain::model::ScaleTarget>,
2246}
2247
2248impl PreparedEnvironment {
2249 #[must_use]
2251 pub fn isolated(attempt: AttemptId, identity: EnvironmentIdentity) -> Self {
2252 Self {
2253 attempt,
2254 identity: Some(identity),
2255 cache_target: None,
2256 }
2257 }
2258
2259 #[must_use]
2260 pub fn identity(&self) -> Option<&EnvironmentIdentity> {
2261 self.identity.as_ref()
2262 }
2263}
2264
2265#[derive(Debug, Clone, PartialEq, Eq)]
2266pub struct ResolvedEnvironment {
2267 pub provider_kind: Backend,
2268 pub image: ImageReference,
2269}
2270
2271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2273pub enum ProviderDiagnostic {
2274 CapabilityUnavailable,
2275 ImageRejected,
2276 PrepareFailed,
2277 StartFailed,
2278 OwnershipMismatch,
2279 CleanupDeferred,
2280}
2281
2282#[derive(Debug, Clone, PartialEq, Eq)]
2283pub enum EnvironmentIdentity {
2284 NativeProcess(u32),
2285 Isolated {
2286 host: HostId,
2287 attempt: AttemptId,
2288 provider_kind: Backend,
2289 environment_id: String,
2290 resolved_image: ImageReference,
2291 generation: String,
2292 },
2293}
2294
2295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2296pub enum EnvironmentState {
2297 Starting,
2298 Running,
2299 Exited,
2300 Missing,
2301}
2302
2303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2304pub enum ProviderStop {
2305 Stopped,
2306 StillRunning,
2307}
2308
2309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2310pub enum ProviderDestroy {
2311 Destroyed,
2312 Deferred(&'static str),
2313}
2314
2315fn identity_matches_intent(
2316 host: HostId,
2317 attempt: &RunnerAttempt,
2318 identity: &EnvironmentIdentity,
2319) -> bool {
2320 identity_same_allocation(host, attempt, identity)
2321 && match (attempt.execution(), identity) {
2322 (
2323 AttemptExecution::Isolated { environment_id, .. },
2324 EnvironmentIdentity::Isolated {
2325 environment_id: id, ..
2326 },
2327 ) => environment_id
2328 .as_ref()
2329 .is_none_or(|journal_id| journal_id == id),
2330 _ => false,
2331 }
2332}
2333
2334fn identity_same_allocation(
2335 host: HostId,
2336 attempt: &RunnerAttempt,
2337 identity: &EnvironmentIdentity,
2338) -> bool {
2339 match (attempt.execution(), identity) {
2340 (
2341 AttemptExecution::Isolated {
2342 provider_kind,
2343 environment_id: _,
2344 resolved_image,
2345 generation,
2346 },
2347 EnvironmentIdentity::Isolated {
2348 host: owner,
2349 attempt: owner_attempt,
2350 provider_kind: kind,
2351 environment_id: _,
2352 resolved_image: image,
2353 generation: resource_generation,
2354 },
2355 ) => {
2356 *owner == host
2357 && *owner_attempt == attempt.id
2358 && provider_kind == kind
2359 && resolved_image == image
2360 && generation == resource_generation
2361 }
2362 _ => false,
2363 }
2364}
2365
2366pub struct OneTimeJitHandoff<'a> {
2369 config: &'a EncodedJitConfig,
2370}
2371
2372impl fmt::Debug for OneTimeJitHandoff<'_> {
2373 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2374 f.write_str("OneTimeJitHandoff([redacted])")
2375 }
2376}
2377
2378impl<'a> OneTimeJitHandoff<'a> {
2379 pub(crate) fn new(config: &'a EncodedJitConfig) -> Self {
2380 Self { config }
2381 }
2382
2383 pub fn consume(self) -> &'a EncodedJitConfig {
2385 self.config
2386 }
2387}
2388
2389pub trait ExecutionProvider: fmt::Debug + Send + Sync {
2394 fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
2395 if policy.execution_policy().is_native() {
2396 ProviderCapability::Ready
2397 } else {
2398 ProviderCapability::Unsupported
2399 }
2400 }
2401
2402 fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
2404 if policy.execution_policy().is_native() {
2405 Ok(None)
2406 } else {
2407 Err(FailureReason::Other(
2408 "execution provider unavailable".into(),
2409 ))
2410 }
2411 }
2412
2413 fn prepare(
2414 &self,
2415 attempt: &RunnerAttempt,
2416 policy: &ScalePolicy,
2417 ) -> Result<PreparedEnvironment, FailureReason> {
2418 if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
2419 return Err(FailureReason::Other(
2420 "execution provider unavailable".into(),
2421 ));
2422 }
2423 Ok(PreparedEnvironment {
2424 attempt: attempt.id,
2425 identity: None,
2426 cache_target: None,
2427 })
2428 }
2429
2430 fn start(
2431 &self,
2432 prepared: PreparedEnvironment,
2433 attempt: &RunnerAttempt,
2434 handoff: OneTimeJitHandoff<'_>,
2435 ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2436 if prepared.attempt != attempt.id || !attempt.execution().is_native() {
2437 return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2438 "execution provider identity mismatch".into(),
2439 )));
2440 }
2441 self.spawn(attempt, handoff.consume())
2442 .map(EnvironmentIdentity::NativeProcess)
2443 }
2444
2445 fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2446 if !attempt.execution().is_native() {
2447 return Err(FailureReason::Other(
2448 "execution provider unavailable".into(),
2449 ));
2450 }
2451 self.is_alive(attempt).map(|alive| {
2452 if alive {
2453 EnvironmentState::Running
2454 } else {
2455 EnvironmentState::Missing
2456 }
2457 })
2458 }
2459
2460 fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2461 if !attempt.execution().is_native() {
2462 return Err(FailureReason::Other(
2463 "execution provider unavailable".into(),
2464 ));
2465 }
2466 self.terminate(attempt).map(|()| ProviderStop::Stopped)
2467 }
2468
2469 fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2470 if !attempt.execution().is_native() {
2471 return Err(FailureReason::Other(
2472 "execution provider unavailable".into(),
2473 ));
2474 }
2475 Ok(ProviderDestroy::Destroyed)
2480 }
2481
2482 fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2483 self.inspect(attempt)
2484 }
2485
2486 fn enumerate_owned(&self, _host_id: HostId) -> Vec<EnvironmentIdentity> {
2487 Vec::new() }
2489
2490 fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2491 Vec::new() }
2493
2494 fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2496 Ok(attempt.execution().is_native())
2497 }
2498
2499 fn spawn(
2500 &self,
2501 attempt: &RunnerAttempt,
2502 config: &EncodedJitConfig,
2503 ) -> Result<u32, ProcessStartFailure>;
2504 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason>;
2505 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason>;
2508 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool;
2511 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2512 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool;
2513 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason>;
2514}
2515
2516pub use ExecutionProvider as ProcessSupervisor;
2518
2519#[derive(Debug, Default)]
2522pub struct NativeProcesses {
2523 children: Mutex<BTreeMap<AttemptId, ChildProcess>>,
2524 successful_exits: Mutex<BTreeMap<AttemptId, bool>>,
2525 runner_env_file: Option<PathBuf>,
2528 dependency_caches: Option<DependencyCaches>,
2530 docker_cleanup: Option<crate::docker_cleanup::DockerCleanup>,
2531 #[cfg(test)]
2532 post_spawn_faults: Mutex<VecDeque<PostSpawnBoundary>>,
2533 #[cfg(test)]
2534 post_spawn_reaps: std::sync::atomic::AtomicUsize,
2535 #[cfg(test)]
2536 post_spawn_stop_failures: std::sync::atomic::AtomicUsize,
2537 #[cfg(test)]
2538 use_long_lived_test_listener: std::sync::atomic::AtomicBool,
2539}
2540
2541#[derive(Debug)]
2545pub struct PlatformExecutionProvider {
2546 native: NativeProcesses,
2547 oci: OciProcesses,
2548 windows_hyper_v: WindowsHyperVContainers,
2549}
2550
2551impl PlatformExecutionProvider {
2552 #[must_use]
2553 pub fn new(host_id: HostId) -> Self {
2554 Self {
2555 native: NativeProcesses::new(),
2556 oci: OciProcesses::new(host_id),
2557 windows_hyper_v: WindowsHyperVContainers::new(host_id),
2558 }
2559 }
2560
2561 #[must_use]
2564 pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2565 self.native = self.native.with_runner_env_file(path);
2566 self
2567 }
2568
2569 #[must_use]
2572 pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2573 self.native = self.native.with_dependency_caches(caches);
2574 self
2575 }
2576
2577 #[must_use]
2578 pub fn with_docker_cleanup(mut self, cleanup: crate::docker_cleanup::DockerCleanup) -> Self {
2579 self.native.docker_cleanup = Some(cleanup);
2580 self
2581 }
2582
2583 fn isolated_provider(&self, backend: Backend) -> Option<&dyn ExecutionProvider> {
2584 match backend {
2585 Backend::Auto => {
2586 #[cfg(target_os = "windows")]
2587 {
2588 Some(&self.windows_hyper_v)
2589 }
2590 #[cfg(not(target_os = "windows"))]
2591 {
2592 Some(&self.oci)
2593 }
2594 }
2595 Backend::Oci => Some(&self.oci),
2596 Backend::WindowsHyperVContainer => Some(&self.windows_hyper_v),
2597 Backend::VirtualMachine => None,
2598 }
2599 }
2600
2601 fn provider_for_attempt(&self, attempt: &RunnerAttempt) -> Option<&dyn ExecutionProvider> {
2602 match attempt.execution() {
2603 AttemptExecution::Native { .. } => Some(&self.native),
2604 AttemptExecution::Isolated { provider_kind, .. } => {
2605 self.isolated_provider(*provider_kind)
2606 }
2607 }
2608 }
2609
2610 fn required_provider_for_attempt(
2611 &self,
2612 attempt: &RunnerAttempt,
2613 ) -> Result<&dyn ExecutionProvider, FailureReason> {
2614 self.provider_for_attempt(attempt)
2615 .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))
2616 }
2617}
2618
2619impl ExecutionProvider for PlatformExecutionProvider {
2620 fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
2621 match policy.execution_policy() {
2622 runner_manager_domain::execution::ExecutionPolicy::Native => self.native.probe(policy),
2623 runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2624 .isolated_provider(*backend)
2625 .map_or(ProviderCapability::Unsupported, |provider| {
2626 provider.probe(policy)
2627 }),
2628 }
2629 }
2630
2631 fn resolve(&self, policy: &ScalePolicy) -> Result<Option<ResolvedEnvironment>, FailureReason> {
2632 match policy.execution_policy() {
2633 runner_manager_domain::execution::ExecutionPolicy::Native => {
2634 self.native.resolve(policy)
2635 }
2636 runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2637 .isolated_provider(*backend)
2638 .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2639 .resolve(policy),
2640 }
2641 }
2642
2643 fn prepare(
2644 &self,
2645 attempt: &RunnerAttempt,
2646 policy: &ScalePolicy,
2647 ) -> Result<PreparedEnvironment, FailureReason> {
2648 match policy.execution_policy() {
2649 runner_manager_domain::execution::ExecutionPolicy::Native => {
2650 self.native.prepare(attempt, policy)
2651 }
2652 runner_manager_domain::execution::ExecutionPolicy::Isolated { backend, .. } => self
2653 .isolated_provider(*backend)
2654 .ok_or_else(|| FailureReason::Other("execution provider unavailable".into()))?
2655 .prepare(attempt, policy),
2656 }
2657 }
2658
2659 fn start(
2660 &self,
2661 prepared: PreparedEnvironment,
2662 attempt: &RunnerAttempt,
2663 handoff: OneTimeJitHandoff<'_>,
2664 ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
2665 self.provider_for_attempt(attempt)
2666 .ok_or_else(|| {
2667 ProcessStartFailure::before_spawn(FailureReason::Other(
2668 "execution provider unavailable".into(),
2669 ))
2670 })?
2671 .start(prepared, attempt, handoff)
2672 }
2673
2674 fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2675 self.required_provider_for_attempt(attempt)?
2676 .inspect(attempt)
2677 }
2678
2679 fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
2680 self.required_provider_for_attempt(attempt)?.stop(attempt)
2681 }
2682
2683 fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
2684 self.required_provider_for_attempt(attempt)?
2685 .destroy(attempt)
2686 }
2687
2688 fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
2689 self.required_provider_for_attempt(attempt)?
2690 .recover(attempt)
2691 }
2692
2693 fn enumerate_owned(&self, host_id: HostId) -> Vec<EnvironmentIdentity> {
2694 let mut owned = self.oci.enumerate_owned(host_id);
2695 owned.extend(self.windows_hyper_v.enumerate_owned(host_id));
2696 owned
2697 }
2698
2699 fn diagnostics(&self, attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
2700 self.provider_for_attempt(attempt)
2701 .map_or_else(Vec::new, |provider| provider.diagnostics(attempt))
2702 }
2703
2704 fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2705 self.required_provider_for_attempt(attempt)?.owns(attempt)
2706 }
2707
2708 fn spawn(
2709 &self,
2710 attempt: &RunnerAttempt,
2711 config: &EncodedJitConfig,
2712 ) -> Result<u32, ProcessStartFailure> {
2713 if !attempt.execution().is_native() {
2714 return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
2715 "native process launch refused for an isolated allocation".into(),
2716 )));
2717 }
2718 self.native.spawn(attempt, config)
2719 }
2720
2721 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
2722 self.required_provider_for_attempt(attempt)?
2723 .is_alive(attempt)
2724 }
2725
2726 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
2727 self.required_provider_for_attempt(attempt)?
2728 .recovered_pid(attempt)
2729 }
2730
2731 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
2732 self.provider_for_attempt(attempt)
2733 .is_some_and(|provider| provider.completed_successfully(attempt))
2734 }
2735
2736 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2737 self.required_provider_for_attempt(attempt)?
2738 .record_terminate_intent(attempt)
2739 }
2740
2741 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
2742 self.provider_for_attempt(attempt)
2743 .is_some_and(|provider| provider.has_terminate_intent(attempt))
2744 }
2745
2746 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
2747 self.required_provider_for_attempt(attempt)?
2748 .terminate(attempt)
2749 }
2750}
2751
2752#[cfg(test)]
2753#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2754enum PostSpawnBoundary {
2755 HandoffDelete,
2756 IdentitySerialize,
2757 IdentityWrite,
2758 ChildMapInsert,
2759}
2760
2761impl NativeProcesses {
2762 #[must_use]
2763 pub fn new() -> Self {
2764 Self::default()
2765 }
2766
2767 #[must_use]
2769 pub fn with_runner_env_file(mut self, path: PathBuf) -> Self {
2770 self.runner_env_file = Some(path);
2771 self
2772 }
2773
2774 #[must_use]
2776 pub fn with_dependency_caches(mut self, caches: DependencyCaches) -> Self {
2777 self.dependency_caches = Some(caches);
2778 self
2779 }
2780
2781 fn cache_env(
2783 &self,
2784 attempt: &RunnerAttempt,
2785 target: Option<&runner_manager_domain::model::ScaleTarget>,
2786 host_env: &RunnerEnv,
2787 ) -> Vec<(&'static str, std::ffi::OsString)> {
2788 match (&self.dependency_caches, target) {
2789 (Some(caches), Some(target)) => {
2790 caches.for_launch(target, attempt.runtime_path(), host_env)
2791 }
2792 _ => Vec::new(),
2793 }
2794 }
2795
2796 fn spawn_listener(
2799 &self,
2800 attempt: &RunnerAttempt,
2801 config: &EncodedJitConfig,
2802 cache_target: Option<&runner_manager_domain::model::ScaleTarget>,
2803 ) -> Result<u32, ProcessStartFailure> {
2804 let handoff = RestrictiveHandoff::create(
2805 attempt.runtime_path(),
2806 SecretString::from(config.expose().to_owned()),
2807 )
2808 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2809 #[cfg(windows)]
2810 let program = attempt
2811 .runtime_path()
2812 .join("bin")
2813 .join("Runner.Listener.exe");
2814 #[cfg(not(windows))]
2815 let program = attempt.runtime_path().join("bin").join("Runner.Listener");
2816 if !program.is_file() {
2819 return Err(ProcessStartFailure::before_spawn(
2820 FailureReason::ProcessStartFailed,
2821 ));
2822 }
2823 let host_env = self.host_env().map_err(ProcessStartFailure::before_spawn)?;
2824 let caches = self.cache_env(attempt, cache_target, &host_env);
2825 #[cfg(test)]
2826 let spec = if self
2827 .use_long_lived_test_listener
2828 .load(std::sync::atomic::Ordering::SeqCst)
2829 {
2830 SpawnSpec::new(program)
2831 .args([
2832 "--ignored",
2833 "--exact",
2834 "lifecycle::tests::long_lived_native_listener_helper",
2835 "--nocapture",
2836 ])
2837 .env(
2838 "RUNNER_MANAGER_TEST_LISTENER_READY",
2839 attempt.runtime_path().join(TEST_LISTENER_READY),
2840 )
2841 .working_dir(attempt.runtime_path())
2842 } else {
2843 runner_listener_spec(program, attempt.runtime_path(), caches, &host_env)
2844 };
2845 #[cfg(not(test))]
2846 let spec = runner_listener_spec(program, attempt.runtime_path(), caches, &host_env);
2847 let (spec, docker_proxy) = if let Some(cleanup) = &self.docker_cleanup {
2848 let repository = cache_target.map(ToString::to_string).unwrap_or_default();
2849 match cleanup.launch_environment(attempt.id, &repository, &host_env) {
2850 Ok(Some(endpoint)) => (
2851 spec.env("DOCKER_HOST", endpoint),
2852 Some(cleanup.root.join(attempt.id.to_string())),
2853 ),
2854 Ok(None) => (spec, None),
2855 Err(error) => {
2856 tracing::warn!(attempt_id = %attempt.id, event = "docker_accounting_unavailable", reason = ?error.kind(), "native Docker accounting could not start");
2857 if crate::docker_cleanup::Config::load(&cleanup.config).map_or(true, |config| {
2858 config.mode == crate::docker_cleanup::Mode::Apply
2859 }) {
2860 return Err(ProcessStartFailure::before_spawn(
2861 FailureReason::ProcessStartFailed,
2862 ));
2863 }
2864 (spec, None)
2865 }
2866 }
2867 } else {
2868 (spec, None)
2869 };
2870 let child = spec
2871 .spawn_runner_with_handoff(&handoff)
2872 .map_err(|_| ProcessStartFailure::before_spawn(FailureReason::ProcessStartFailed))?;
2873 #[cfg(test)]
2875 if self.faults_at(PostSpawnBoundary::HandoffDelete) {
2876 drop(handoff);
2877 return Err(self.abort_spawned_child(child, attempt, false));
2878 }
2879 if handoff.delete().is_err() {
2880 return Err(self.abort_spawned_child(child, attempt, false));
2881 }
2882 #[cfg(test)]
2883 if self.faults_at(PostSpawnBoundary::IdentitySerialize) {
2884 return Err(self.abort_spawned_child(child, attempt, false));
2885 }
2886 let identity = match serde_json::to_vec(child.identity()) {
2887 Ok(identity) => identity,
2888 Err(_) => {
2889 return Err(self.abort_spawned_child(child, attempt, false));
2890 }
2891 };
2892 if self.persist_identity(attempt, &identity).is_err() {
2893 return Err(self.abort_spawned_child(child, attempt, true));
2894 }
2895 if let Some(directory) = docker_proxy
2896 && crate::docker_cleanup::write_json(&directory.join("runner.json"), child.identity())
2897 .is_err()
2898 {
2899 return Err(self.abort_spawned_child(child, attempt, true));
2900 }
2901 let pid = child.pid();
2902 #[cfg(test)]
2903 if self.faults_at(PostSpawnBoundary::ChildMapInsert) {
2904 return Err(self.abort_spawned_child(child, attempt, true));
2905 }
2906 let mut children = self
2907 .children
2908 .lock()
2909 .unwrap_or_else(std::sync::PoisonError::into_inner);
2910 children.insert(attempt.id, child);
2911 Ok(pid)
2912 }
2913
2914 fn host_env(&self) -> Result<RunnerEnv, FailureReason> {
2922 let Some(path) = &self.runner_env_file else {
2923 return Ok(RunnerEnv::default());
2924 };
2925 RunnerEnv::load(path).map_err(|error| {
2926 tracing::warn!(
2927 reason = "runner_env_file_invalid",
2928 "runner.env could not be applied, so no runner starts until it is fixed: {error}"
2929 );
2930 FailureReason::Other(format!(
2931 "runner.env cannot be applied ({error}); fix it with `runner-manager host env`"
2932 ))
2933 })
2934 }
2935
2936 #[cfg(test)]
2937 fn fail_post_spawn_at(&self, boundary: PostSpawnBoundary) {
2938 self.post_spawn_faults.lock().unwrap().push_back(boundary);
2939 }
2940
2941 #[cfg(test)]
2942 fn faults_at(&self, boundary: PostSpawnBoundary) -> bool {
2943 let mut faults = self.post_spawn_faults.lock().unwrap();
2944 if faults.front() == Some(&boundary) {
2945 faults.pop_front();
2946 true
2947 } else {
2948 false
2949 }
2950 }
2951
2952 #[cfg(test)]
2953 fn fail_post_spawn_stops(&self, count: usize) {
2954 self.post_spawn_stop_failures
2955 .fetch_add(count, std::sync::atomic::Ordering::SeqCst);
2956 }
2957
2958 #[cfg(test)]
2959 fn fail_next_post_spawn_stop(&self) {
2960 self.fail_post_spawn_stops(1);
2961 }
2962
2963 #[cfg(test)]
2964 fn use_long_lived_test_listener(&self) {
2965 self.use_long_lived_test_listener
2966 .store(true, std::sync::atomic::Ordering::SeqCst);
2967 }
2968
2969 fn stop_spawned_child(&self, child: &mut ChildProcess) -> Result<(), FailureReason> {
2970 #[cfg(test)]
2971 if self
2972 .post_spawn_stop_failures
2973 .fetch_update(
2974 std::sync::atomic::Ordering::SeqCst,
2975 std::sync::atomic::Ordering::SeqCst,
2976 |left| if left > 0 { Some(left - 1) } else { None },
2977 )
2978 .is_ok()
2979 {
2980 return Err(FailureReason::Other("injected runner stop failure".into()));
2981 }
2982 child
2983 .stop(Duration::from_secs(1))
2984 .map(|_| ())
2985 .map_err(|_| FailureReason::Other("spawned runner process could not be stopped".into()))
2986 }
2987
2988 fn abort_spawned_child(
2989 &self,
2990 mut child: ChildProcess,
2991 attempt: &RunnerAttempt,
2992 remove_identity: bool,
2993 ) -> ProcessStartFailure {
2994 let mut reaped = self.stop_spawned_child(&mut child).is_ok();
2995 if reaped {
2996 if remove_identity {
2997 Self::remove_identity_files(attempt);
2998 }
2999 } else {
3000 let identity_durable =
3003 serde_json::to_vec(child.identity())
3004 .ok()
3005 .is_some_and(|identity| {
3006 self.persist_identity(attempt, &identity).is_ok()
3007 || self.persist_fallback_identity(attempt, &identity).is_ok()
3008 });
3009 if !identity_durable {
3010 for _ in 1..MAX_POST_SPAWN_STOP_ATTEMPTS {
3015 if self.stop_spawned_child(&mut child).is_ok() {
3016 reaped = true;
3017 break;
3018 }
3019 }
3020 if !reaped {
3021 let pid = child.pid();
3026 let marker = write_durable_file(
3027 &Self::unresolved_process_path(attempt),
3028 pid.to_string().as_bytes(),
3029 );
3030 self.children
3031 .lock()
3032 .unwrap_or_else(std::sync::PoisonError::into_inner)
3033 .insert(attempt.id, child);
3034 let reason = if marker.is_ok() {
3035 FailureReason::Other(
3036 "spawn cleanup exhausted its bounded stop attempts; the live process remains under durable unresolved supervision"
3037 .into(),
3038 )
3039 } else {
3040 FailureReason::Other(
3041 "spawn cleanup exhausted its bounded stop attempts and the unresolved-process marker could not be journalled"
3042 .into(),
3043 )
3044 };
3045 return ProcessStartFailure::after_spawn_live_with_reason(pid, reason);
3046 }
3047 } else {
3048 let pid = child.pid();
3049 self.children
3050 .lock()
3051 .unwrap_or_else(std::sync::PoisonError::into_inner)
3052 .insert(attempt.id, child);
3053 return ProcessStartFailure::after_spawn_live(pid);
3054 }
3055 }
3056 #[cfg(test)]
3057 if reaped {
3058 self.post_spawn_reaps
3059 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3060 }
3061 #[cfg(not(test))]
3062 let _ = reaped;
3063 ProcessStartFailure::after_spawn_stopped()
3064 }
3065
3066 fn identity_path(attempt: &RunnerAttempt) -> PathBuf {
3067 attempt.runtime_path().join(IDENTITY_FILE)
3068 }
3069
3070 fn fallback_identity_path(attempt: &RunnerAttempt) -> PathBuf {
3071 attempt.runtime_path().join(FALLBACK_IDENTITY_FILE)
3072 }
3073
3074 fn unresolved_process_path(attempt: &RunnerAttempt) -> PathBuf {
3075 attempt.runtime_path().join(UNRESOLVED_PROCESS_FILE)
3076 }
3077
3078 fn remove_identity_files(attempt: &RunnerAttempt) {
3079 let _ = fs::remove_file(Self::identity_path(attempt));
3080 let _ = fs::remove_file(Self::fallback_identity_path(attempt));
3081 let _ = fs::remove_file(Self::unresolved_process_path(attempt));
3082 }
3083
3084 fn persist_identity(&self, attempt: &RunnerAttempt, bytes: &[u8]) -> std::io::Result<()> {
3085 self.persist_identity_at(&Self::identity_path(attempt), bytes)
3086 }
3087
3088 fn persist_fallback_identity(
3089 &self,
3090 attempt: &RunnerAttempt,
3091 bytes: &[u8],
3092 ) -> std::io::Result<()> {
3093 self.persist_identity_at(&Self::fallback_identity_path(attempt), bytes)
3094 }
3095
3096 fn persist_identity_at(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
3097 #[cfg(test)]
3098 if self.faults_at(PostSpawnBoundary::IdentityWrite) {
3099 return Err(std::io::Error::other("injected identity write failure"));
3100 }
3101 write_durable_file(path, bytes)
3102 }
3103
3104 fn intent_path(attempt: &RunnerAttempt) -> PathBuf {
3105 attempt.runtime_path().join(TERMINATE_INTENT_FILE)
3106 }
3107
3108 fn read_identity(attempt: &RunnerAttempt) -> Result<Option<ProcessIdentity>, FailureReason> {
3109 match Self::read_identity_at(&Self::identity_path(attempt))? {
3110 Some(identity) => Ok(Some(identity)),
3111 None => Self::read_identity_at(&Self::fallback_identity_path(attempt)),
3112 }
3113 }
3114
3115 fn read_identity_at(path: &Path) -> Result<Option<ProcessIdentity>, FailureReason> {
3116 match fs::read(path) {
3117 Ok(bytes) => serde_json::from_slice(&bytes)
3118 .map(Some)
3119 .map_err(|_| FailureReason::Other("process identity journal is unreadable".into())),
3120 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
3121 Err(_) => Err(FailureReason::Other(
3122 "process identity journal could not be read".into(),
3123 )),
3124 }
3125 }
3126}
3127
3128impl ProcessSupervisor for NativeProcesses {
3129 fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
3130 if !attempt.execution().is_native() {
3131 return Err(FailureReason::Other(
3132 "execution provider unavailable".into(),
3133 ));
3134 }
3135 if let Some(caches) = &self.dependency_caches {
3138 caches.capture_git(attempt.runtime_path());
3139 }
3140 Ok(ProviderDestroy::Destroyed)
3141 }
3142
3143 fn prepare(
3144 &self,
3145 attempt: &RunnerAttempt,
3146 policy: &ScalePolicy,
3147 ) -> Result<PreparedEnvironment, FailureReason> {
3148 if !attempt.execution().is_native() || !policy.execution_policy().is_native() {
3149 return Err(FailureReason::Other(
3150 "execution provider unavailable".into(),
3151 ));
3152 }
3153 self.host_env()?;
3154 let cache_target = match &self.dependency_caches {
3157 Some(caches) => {
3158 caches.config()?;
3159 Some(policy.target.clone())
3160 }
3161 None => None,
3162 };
3163 Ok(PreparedEnvironment {
3164 attempt: attempt.id,
3165 identity: None,
3166 cache_target,
3167 })
3168 }
3169
3170 fn start(
3171 &self,
3172 prepared: PreparedEnvironment,
3173 attempt: &RunnerAttempt,
3174 handoff: OneTimeJitHandoff<'_>,
3175 ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
3176 if prepared.attempt != attempt.id || !attempt.execution().is_native() {
3177 return Err(ProcessStartFailure::before_spawn(FailureReason::Other(
3178 "execution provider identity mismatch".into(),
3179 )));
3180 }
3181 self.spawn_listener(attempt, handoff.consume(), prepared.cache_target.as_ref())
3182 .map(EnvironmentIdentity::NativeProcess)
3183 }
3184
3185 fn spawn(
3186 &self,
3187 attempt: &RunnerAttempt,
3188 config: &EncodedJitConfig,
3189 ) -> Result<u32, ProcessStartFailure> {
3190 self.spawn_listener(attempt, config, None)
3191 }
3192
3193 fn is_alive(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
3194 let mut children = self
3195 .children
3196 .lock()
3197 .unwrap_or_else(std::sync::PoisonError::into_inner);
3198 if let Some(child) = children.get_mut(&attempt.id) {
3199 return match child
3200 .try_exit_status()
3201 .map_err(|_| FailureReason::Other("runner process could not be observed".into()))?
3202 {
3203 None => Ok(true),
3204 Some(status) => {
3205 if let Ok(mut exits) = self.successful_exits.lock() {
3206 exits.insert(attempt.id, status.success());
3207 }
3208 Ok(false)
3209 }
3210 };
3211 }
3212 let Some(identity) = Self::read_identity(attempt)? else {
3213 if attempt.process_id().is_some() || Self::unresolved_process_path(attempt).is_file() {
3214 return Err(FailureReason::Other(
3215 "runner process identity is missing; refusing recovery until the process is resolved"
3216 .into(),
3217 ));
3218 }
3219 return Ok(false);
3220 };
3221 match identity.recheck() {
3222 Ok(Adoption::Live) => Ok(true),
3223 Ok(Adoption::Gone | Adoption::PidRecycled { .. }) => Ok(false),
3224 Err(_) => Ok(false),
3225 }
3226 }
3227
3228 fn recovered_pid(&self, attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
3229 Ok(Self::read_identity(attempt)?.map(|identity| identity.pid()))
3230 }
3231
3232 fn completed_successfully(&self, attempt: &RunnerAttempt) -> bool {
3233 self.successful_exits
3234 .lock()
3235 .ok()
3236 .and_then(|exits| exits.get(&attempt.id).copied())
3237 .unwrap_or(false)
3238 }
3239
3240 fn record_terminate_intent(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3241 let path = Self::intent_path(attempt);
3242 write_durable_file(&path, b"registration-timeout\n")
3243 .map_err(|_| FailureReason::Other("terminate intent could not be journalled".into()))
3244 }
3245
3246 fn has_terminate_intent(&self, attempt: &RunnerAttempt) -> bool {
3247 Self::intent_path(attempt).is_file()
3248 }
3249
3250 fn terminate(&self, attempt: &RunnerAttempt) -> Result<(), FailureReason> {
3251 let mut children = self
3252 .children
3253 .lock()
3254 .unwrap_or_else(std::sync::PoisonError::into_inner);
3255 if let Some(child) = children.get_mut(&attempt.id) {
3256 child
3257 .stop(Duration::from_secs(10))
3258 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?;
3259 return Ok(());
3260 }
3261 let Some(identity) = Self::read_identity(attempt)? else {
3262 return Ok(());
3263 };
3264 match identity
3265 .terminate(Duration::from_secs(10))
3266 .map_err(|_| FailureReason::Other("runner process could not be stopped".into()))?
3267 {
3268 Termination::Terminated | Termination::AlreadyGone => Ok(()),
3269 Termination::RefusedPidRecycled { .. } => Err(FailureReason::Other(
3270 "runner PID was recycled; refusing to signal it".into(),
3271 )),
3272 }
3273 }
3274}
3275
3276pub struct LifecyclePorts {
3277 pub store: Arc<dyn Store>,
3278 pub github: Arc<dyn LifecycleGithub>,
3279 pub packages: Arc<dyn RuntimePackages>,
3280 pub processes: Arc<dyn ExecutionProvider>,
3281 pub clock: Arc<dyn Clock>,
3282 pub demand: Arc<dyn DemandPersistence>,
3283 pub delay: Arc<dyn RetryDelay>,
3284 pub events: Arc<dyn AttemptEventSink>,
3285 pub reconcile_events: Arc<dyn EventSink>,
3286}
3287
3288impl fmt::Debug for LifecyclePorts {
3289 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3290 f.debug_struct("LifecyclePorts")
3291 .field("store", &self.store)
3292 .field("github", &self.github)
3293 .field("packages", &self.packages)
3294 .field("processes", &self.processes)
3295 .finish_non_exhaustive()
3296 }
3297}
3298
3299#[derive(Debug, thiserror::Error)]
3300pub enum LifecycleError {
3301 #[error("attempt journal operation failed")]
3302 Journal,
3303 #[error("attempt {0} is not in the journal")]
3304 Missing(AttemptId),
3305 #[error("attempt lifecycle transition was refused")]
3306 Transition,
3307 #[error("startup recovery has not completed")]
3308 RecoveryIncomplete,
3309 #[error("the persistent slot was not cleaned: {detail}")]
3319 SlotQuarantined {
3320 class: &'static str,
3322 detail: String,
3323 },
3324 #[error("{}", FailureReason::WorkspaceCleanupDeferred)]
3328 WorkspaceCleanupDeferred,
3329 #[error("isolated environment cleanup is deferred")]
3330 EnvironmentCleanupDeferred,
3331 #[error("runner lifecycle failed: {0}")]
3332 Failed(FailureReason),
3333}
3334
3335impl LifecycleError {
3336 fn reason(&self) -> FailureReason {
3337 match self {
3338 Self::Failed(reason) => reason.clone(),
3339 Self::RecoveryIncomplete => FailureReason::Other("startup recovery incomplete".into()),
3340 Self::Journal => FailureReason::Other("attempt journal operation failed".into()),
3341 Self::Missing(_) => FailureReason::Other("attempt disappeared from the journal".into()),
3342 Self::Transition => FailureReason::Other("attempt transition was refused".into()),
3343 Self::SlotQuarantined { .. } => FailureReason::Other(self.to_string()),
3346 Self::WorkspaceCleanupDeferred => FailureReason::WorkspaceCleanupDeferred,
3347 Self::EnvironmentCleanupDeferred => FailureReason::Other(self.to_string()),
3348 }
3349 }
3350}
3351
3352#[derive(Debug)]
3354pub struct LifecycleLauncher {
3355 host_id: HostId,
3356 app_paths: runner_manager_platform::paths::AppPaths,
3357 diagnostics_root: PathBuf,
3358 runner_group_id: u64,
3359 timeouts: RecoveryTimeouts,
3360 retry: RetryPolicy,
3361 cancel: CancelToken,
3362 ports: LifecyclePorts,
3363 recovery_complete: Mutex<bool>,
3364 versions: Mutex<BTreeMap<AttemptId, RunnerVersion>>,
3365 pending_replacements: Mutex<BTreeMap<AttemptId, ReplacementIntent>>,
3366 cleanup_retries: Mutex<CleanBackoff>,
3370 turns: Arc<tokio::sync::Semaphore>,
3371 bringing_up: Mutex<BTreeSet<AttemptId>>,
3374}
3375
3376struct BroughtUpOnDrop<'a> {
3379 attempts: &'a Mutex<BTreeSet<AttemptId>>,
3380 id: AttemptId,
3381}
3382
3383impl Drop for BroughtUpOnDrop<'_> {
3384 fn drop(&mut self) {
3385 self.attempts
3386 .lock()
3387 .unwrap_or_else(std::sync::PoisonError::into_inner)
3388 .remove(&self.id);
3389 }
3390}
3391
3392#[must_use]
3397pub fn preparation_turns() -> Arc<tokio::sync::Semaphore> {
3398 let cores = std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
3399 Arc::new(tokio::sync::Semaphore::new((cores / 4).max(2)))
3400}
3401
3402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3403enum ReconcileProgress {
3404 Reconciled,
3405 Deferred,
3406 Replacement {
3407 attempt: AttemptId,
3408 operation: &'static str,
3409 },
3410}
3411
3412impl LifecycleLauncher {
3413 #[must_use]
3414 pub fn new(
3415 host_id: HostId,
3416 app_paths: runner_manager_platform::paths::AppPaths,
3417 diagnostics_root: impl Into<PathBuf>,
3418 runner_group_id: u64,
3419 timeouts: RecoveryTimeouts,
3420 retry: RetryPolicy,
3421 ports: LifecyclePorts,
3422 ) -> Self {
3423 Self {
3424 host_id,
3425 app_paths,
3426 diagnostics_root: diagnostics_root.into(),
3427 runner_group_id,
3428 timeouts,
3429 retry,
3430 cancel: CancelToken::new(),
3431 ports,
3432 recovery_complete: Mutex::new(false),
3433 versions: Mutex::new(BTreeMap::new()),
3434 pending_replacements: Mutex::new(BTreeMap::new()),
3435 cleanup_retries: Mutex::new(CleanBackoff::default()),
3436 turns: preparation_turns(),
3437 bringing_up: Mutex::default(),
3438 }
3439 }
3440
3441 #[must_use]
3444 pub fn with_preparation_turns(mut self, turns: Arc<tokio::sync::Semaphore>) -> Self {
3445 self.turns = turns;
3446 self
3447 }
3448
3449 pub async fn recover_startup(
3453 &self,
3454 policies: &[ScalePolicy],
3455 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
3456 *self
3457 .recovery_complete
3458 .lock()
3459 .map_err(|_| LifecycleError::Journal)? = false;
3460 let by_id: BTreeMap<_, _> = policies.iter().map(|policy| (policy.id, policy)).collect();
3461 let attempts = self
3462 .ports
3463 .store
3464 .attempts()
3465 .map_err(|_| LifecycleError::Journal)?;
3466 let owned = self.ports.processes.enumerate_owned(self.host_id);
3467 for identity in &owned {
3468 if let EnvironmentIdentity::Isolated { attempt: id, .. } = identity
3469 && !attempts.iter().any(|attempt| {
3470 attempt.state() != AttemptState::Cleaned
3471 && identity_matches_intent(self.host_id, attempt, identity)
3472 })
3473 {
3474 self.ports
3476 .events
3477 .emit(AttemptEvent::QuarantinedOwnedResource { attempt: *id });
3478 tracing::warn!(host_id = %self.host_id, attempt_id = %id, reason = "owned_isolated_resource_without_matching_journal", "provider resource quarantined for operator review");
3479 }
3480 }
3481 let mut unresolved = false;
3482 for mut attempt in attempts {
3483 let Some(policy) = by_id.get(&attempt.policy_id) else {
3484 if attempt.counts_against_capacity() {
3485 unresolved = true;
3486 }
3487 continue;
3488 };
3489 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3490 let allocation_resources = owned
3491 .iter()
3492 .filter(|identity| identity_same_allocation(self.host_id, &attempt, identity))
3493 .collect::<Vec<_>>();
3494 if allocation_resources.len() > 1 {
3495 self.ports
3496 .events
3497 .emit(AttemptEvent::QuarantinedOwnedResource {
3498 attempt: attempt.id,
3499 });
3500 tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_isolated_resource_ownership", "provider resources quarantined for operator review");
3501 }
3502 if attempt.state() != AttemptState::Cleaned
3503 && matches!(
3504 attempt.execution(),
3505 AttemptExecution::Isolated {
3506 environment_id: None,
3507 ..
3508 }
3509 )
3510 && allocation_resources.len() == 1
3511 && let Some(EnvironmentIdentity::Isolated { environment_id, .. }) =
3512 allocation_resources.first().copied()
3513 {
3514 attempt
3515 .prepared_environment(environment_id.clone())
3516 .map_err(|_| LifecycleError::Transition)?;
3517 if attempt.state() == AttemptState::Preparing {
3518 attempt
3519 .mark_prepared(self.ports.clock.now())
3520 .map_err(|_| LifecycleError::Transition)?;
3521 }
3522 self.record(&attempt)?;
3523 }
3524 match self.reconcile_one(policy, attempt).await? {
3525 ReconcileProgress::Deferred => {
3526 if policy.execution_policy().is_native() {
3527 unresolved = true;
3528 }
3529 }
3530 ReconcileProgress::Replacement { attempt, operation } => {
3531 self.pending_replacements
3532 .lock()
3533 .map_err(|_| LifecycleError::Journal)?
3534 .insert(
3535 attempt,
3536 ReplacementIntent {
3537 policy: policy.id,
3538 previous_attempt: attempt,
3539 operation,
3540 },
3541 );
3542 }
3543 ReconcileProgress::Reconciled => {}
3544 }
3545 }
3546 if unresolved {
3547 return Err(LifecycleError::RecoveryIncomplete);
3548 }
3549 *self
3550 .recovery_complete
3551 .lock()
3552 .map_err(|_| LifecycleError::Journal)? = true;
3553 Ok(self
3554 .pending_replacements
3555 .lock()
3556 .map_err(|_| LifecycleError::Journal)?
3557 .values()
3558 .copied()
3559 .collect())
3560 }
3561
3562 pub async fn supervise(
3564 &self,
3565 policy: &ScalePolicy,
3566 ) -> Result<Vec<ReplacementIntent>, LifecycleError> {
3567 let mut replacements = Vec::new();
3568 self.pending_replacements
3569 .lock()
3570 .map_err(|_| LifecycleError::Journal)?
3571 .retain(|_, intent| {
3572 if intent.policy == policy.id {
3573 replacements.push(*intent);
3574 false
3575 } else {
3576 true
3577 }
3578 });
3579 let attempts = {
3584 let bringing_up = self
3585 .bringing_up
3586 .lock()
3587 .map_err(|_| LifecycleError::Journal)?;
3588 let mut attempts = self
3589 .ports
3590 .store
3591 .attempts_for_policy(policy.id)
3592 .map_err(|_| LifecycleError::Journal)?;
3593 attempts.retain(|attempt| !bringing_up.contains(&attempt.id));
3594 attempts
3595 };
3596 for attempt in attempts {
3597 authorize(self.host_id, policy, &attempt).map_err(|_| LifecycleError::Journal)?;
3598 if let ReconcileProgress::Replacement { attempt, operation } =
3599 self.reconcile_one(policy, attempt).await?
3600 {
3601 replacements.push(ReplacementIntent {
3602 policy: policy.id,
3603 previous_attempt: attempt,
3604 operation,
3605 });
3606 }
3607 }
3608 Ok(replacements)
3609 }
3610
3611 async fn reconcile_one(
3612 &self,
3613 policy: &ScalePolicy,
3614 mut attempt: RunnerAttempt,
3615 ) -> Result<ReconcileProgress, LifecycleError> {
3616 if !attempt.execution().is_native() {
3617 return self.reconcile_isolated_one(policy, attempt).await;
3618 }
3619 if attempt.state() == AttemptState::Cleaned {
3620 if matches!(attempt.workspace(), AttemptWorkspace::Ephemeral)
3629 && attempt.runtime_path().exists()
3630 {
3631 let now = self.ports.clock.now();
3639 if !self.cleanup_retries().is_due(attempt.id, now) {
3640 return Ok(ReconcileProgress::Reconciled);
3641 }
3642 match self.scrub_workspace(&attempt) {
3643 Ok(()) => self.cleanup_retries().succeeded(attempt.id),
3644 Err(LifecycleError::WorkspaceCleanupDeferred) => {
3645 self.cleanup_retries().failed(attempt.id, now);
3646 self.ports
3647 .reconcile_events
3648 .emit(LifecycleEvent::AttemptCleanFailed {
3649 policy: attempt.policy_id,
3650 attempt: attempt.id,
3651 reason: "late_ephemeral_workspace_could_not_be_removed",
3652 });
3653 }
3654 Err(error) => return Err(error),
3655 }
3656 }
3657 return Ok(ReconcileProgress::Reconciled);
3658 }
3659 if attempt.is_terminal() {
3660 self.clean_or_quarantine(&mut attempt)?;
3661 return Ok(ReconcileProgress::Reconciled);
3662 }
3663 let process_alive = self
3664 .ports
3665 .processes
3666 .inspect(&attempt)
3667 .map_err(LifecycleError::Failed)?
3668 == EnvironmentState::Running;
3669 let github = self
3670 .ports
3671 .github
3672 .observe(&policy.target, attempt.id, &self.cancel)
3673 .await;
3674
3675 if let Some(runner_id) = github.runner_id
3680 && read_runner_id(attempt.runtime_path()).is_none()
3681 {
3682 write_runner_id(attempt.runtime_path(), runner_id)?;
3683 self.ports
3684 .events
3685 .emit(AttemptEvent::RemoteIdentityRecovered {
3686 attempt: attempt.id,
3687 runner_id,
3688 });
3689 }
3690
3691 if attempt.state() == AttemptState::JitReceived
3696 && process_alive
3697 && let Some(pid) = self
3698 .ports
3699 .processes
3700 .recovered_pid(&attempt)
3701 .map_err(LifecycleError::Failed)?
3702 {
3703 attempt
3704 .started(pid, self.ports.clock.now())
3705 .map_err(|_| LifecycleError::Transition)?;
3706 self.record(&attempt)?;
3707 }
3708
3709 if !process_alive
3722 && github.status != GithubRunnerObservation::Unreachable
3723 && matches!(
3724 attempt.state(),
3725 AttemptState::Starting | AttemptState::Idle | AttemptState::Busy
3726 )
3727 && self.ports.processes.completed_successfully(&attempt)
3728 {
3729 let state = attempt.state();
3730 let outcome = match (state, JobEvidence::of(attempt.runtime_path())) {
3731 (AttemptState::Starting | AttemptState::Idle, JobEvidence::RanAJob) => {
3734 known_runner_id(&attempt, github.runner_id)
3735 .map(|runner_id| (Some(runner_id), AttemptOutcome::CompletedJob))
3736 }
3737 (AttemptState::Busy, JobEvidence::RanAJob) => {
3738 Some((None, AttemptOutcome::CompletedJob))
3739 }
3740 (AttemptState::Busy, JobEvidence::NoJob) => {
3741 Some((None, AttemptOutcome::ExitedIdleWithoutWork))
3742 }
3743 (AttemptState::Busy, JobEvidence::Unknown)
3744 if github.status == GithubRunnerObservation::NotRegistered =>
3745 {
3746 Some((None, AttemptOutcome::CompletedJob))
3747 }
3748 _ => None,
3749 };
3750 if let Some((walk_to_busy, outcome)) = outcome {
3751 if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3758 self.deregister_runner(policy, &attempt).await;
3759 }
3760 let now = self.ports.clock.now();
3761 match (walk_to_busy, &outcome) {
3762 (Some(runner_id), _) => {
3763 self.transition(&mut attempt, |a| a.assigned_job(runner_id, now))?;
3764 }
3765 (None, AttemptOutcome::ExitedIdleWithoutWork) => {
3766 self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
3767 }
3768 (None, _) => {}
3769 }
3770 self.conclude(&mut attempt, outcome)?;
3771 self.clean_or_quarantine(&mut attempt)?;
3772 return Ok(ReconcileProgress::Reconciled);
3773 }
3774 }
3775
3776 if process_alive
3781 && attempt.state() == AttemptState::Busy
3782 && github.status == (GithubRunnerObservation::Registered { busy: false })
3783 && JobEvidence::of(attempt.runtime_path()) == JobEvidence::NoJob
3784 {
3785 let now = self.ports.clock.now();
3786 self.transition(&mut attempt, |a| a.assignment_not_taken(now))?;
3787 return Ok(ReconcileProgress::Reconciled);
3788 }
3789
3790 if self.ports.processes.has_terminate_intent(&attempt) && !process_alive {
3793 self.deregister_runner(policy, &attempt).await;
3794 self.conclude(
3795 &mut attempt,
3796 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout),
3797 )?;
3798 self.clean_or_quarantine(&mut attempt)?;
3799 return Ok(ReconcileProgress::Replacement {
3800 attempt: attempt.id,
3801 operation: "registration_timeout_replacement",
3802 });
3803 }
3804
3805 if matches!(
3811 attempt.state(),
3812 AttemptState::Allocated | AttemptState::JitReceived
3813 ) && !process_alive
3814 && matches!(github.status, GithubRunnerObservation::Registered { .. })
3815 {
3816 if attempt.state() == AttemptState::Allocated {
3817 attempt
3818 .jit_received(self.ports.clock.now())
3819 .map_err(|_| LifecycleError::Transition)?;
3820 self.record(&attempt)?;
3821 }
3822 self.deregister_runner(policy, &attempt).await;
3823 self.conclude(
3824 &mut attempt,
3825 AttemptOutcome::failed(FailureReason::JitExpired),
3826 )?;
3827 self.clean_or_quarantine(&mut attempt)?;
3828 return Ok(ReconcileProgress::Replacement {
3829 attempt: attempt.id,
3830 operation: "jit_expired_replacement",
3831 });
3832 }
3833
3834 match recovery_decision(
3835 &attempt,
3836 RecoveryObservation {
3837 process_alive,
3838 github: github.status,
3839 },
3840 self.timeouts,
3841 self.ports.clock.as_ref(),
3842 ) {
3843 RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
3844 RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
3845 RecoveryDecision::Adopt => {
3846 self.ports.events.emit(AttemptEvent::Adopted {
3847 attempt: attempt.id,
3848 });
3849 Ok(ReconcileProgress::Reconciled)
3850 }
3851 RecoveryDecision::Clean => {
3852 self.clean_or_quarantine(&mut attempt)?;
3853 Ok(ReconcileProgress::Reconciled)
3854 }
3855 RecoveryDecision::Observe(state) => {
3856 let runner_id = known_runner_id(&attempt, github.runner_id)
3857 .ok_or(LifecycleError::Transition)?;
3858 match state {
3859 AttemptState::JitReceived => attempt
3860 .jit_received(self.ports.clock.now())
3861 .map_err(|_| LifecycleError::Transition)?,
3862 AttemptState::Starting => {
3863 let pid = attempt.process_id().ok_or(LifecycleError::Transition)?;
3864 attempt
3865 .started(pid, self.ports.clock.now())
3866 .map_err(|_| LifecycleError::Transition)?;
3867 }
3868 AttemptState::Idle => attempt
3869 .registered_idle(runner_id, self.ports.clock.now())
3870 .map_err(|_| LifecycleError::Transition)?,
3871 AttemptState::Busy => attempt
3872 .assigned_job(runner_id, self.ports.clock.now())
3873 .map_err(|_| LifecycleError::Transition)?,
3874 _ => return Err(LifecycleError::Transition),
3875 }
3876 self.record(&attempt)?;
3877 Ok(ReconcileProgress::Reconciled)
3878 }
3879 RecoveryDecision::Conclude(outcome) => {
3880 let replacement = replacement_operation(&outcome);
3881 if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
3886 self.deregister_runner(policy, &attempt).await;
3887 }
3888 self.conclude(&mut attempt, outcome)?;
3889 self.clean_or_quarantine(&mut attempt)?;
3890 Ok(
3891 replacement.map_or(ReconcileProgress::Reconciled, |operation| {
3892 ReconcileProgress::Replacement {
3893 attempt: attempt.id,
3894 operation,
3895 }
3896 }),
3897 )
3898 }
3899 RecoveryDecision::Terminate(payload) => {
3900 let idle_exit = payload.is_idle_exit();
3910 self.ports
3911 .processes
3912 .record_terminate_intent(&attempt)
3913 .map_err(LifecycleError::Failed)?;
3914 self.ports.events.emit(AttemptEvent::TerminateIntent {
3915 attempt: attempt.id,
3916 });
3917 if self
3918 .ports
3919 .processes
3920 .stop(&attempt)
3921 .map_err(LifecycleError::Failed)?
3922 == ProviderStop::StillRunning
3923 {
3924 return Ok(ReconcileProgress::Deferred);
3925 }
3926 if self
3927 .ports
3928 .processes
3929 .inspect(&attempt)
3930 .map_err(LifecycleError::Failed)?
3931 == EnvironmentState::Running
3932 {
3933 return Ok(ReconcileProgress::Deferred);
3934 }
3935 self.ports.events.emit(AttemptEvent::Terminated {
3936 attempt: attempt.id,
3937 });
3938 let outcome = if idle_exit {
3944 AttemptOutcome::ExitedIdleWithoutWork
3945 } else {
3946 AttemptOutcome::failed(FailureReason::TerminatedAfterRegistrationTimeout)
3947 };
3948 self.deregister_runner(policy, &attempt).await;
3949 self.conclude(&mut attempt, outcome)?;
3950 self.clean_or_quarantine(&mut attempt)?;
3951 if idle_exit {
3955 Ok(ReconcileProgress::Reconciled)
3956 } else {
3957 Ok(ReconcileProgress::Replacement {
3958 attempt: attempt.id,
3959 operation: "registration_timeout_replacement",
3960 })
3961 }
3962 }
3963 }
3964 }
3965
3966 async fn reconcile_isolated_one(
3967 &self,
3968 policy: &ScalePolicy,
3969 mut attempt: RunnerAttempt,
3970 ) -> Result<ReconcileProgress, LifecycleError> {
3971 if attempt.state() == AttemptState::Cleaned {
3972 return Ok(ReconcileProgress::Reconciled);
3973 }
3974 if attempt.is_terminal() {
3975 self.clean_or_quarantine(&mut attempt)?;
3976 return Ok(ReconcileProgress::Reconciled);
3977 }
3978 if matches!(
3979 attempt.state(),
3980 AttemptState::Allocated | AttemptState::Preparing | AttemptState::Prepared
3981 ) {
3982 self.conclude(
3985 &mut attempt,
3986 AttemptOutcome::failed(FailureReason::Other(
3987 "isolated preparation interrupted".into(),
3988 )),
3989 )?;
3990 self.clean_or_quarantine(&mut attempt)?;
3991 return Ok(ReconcileProgress::Reconciled);
3992 }
3993 let Ok(provider_state) = self.ports.processes.recover(&attempt) else {
3994 return Ok(ReconcileProgress::Deferred);
3995 };
3996 if provider_state != EnvironmentState::Missing {
3997 let Ok(owned) = self.ports.processes.owns(&attempt) else {
3998 return Ok(ReconcileProgress::Deferred);
3999 };
4000 if !owned {
4001 self.ports
4002 .events
4003 .emit(AttemptEvent::QuarantinedOwnedResource {
4004 attempt: attempt.id,
4005 });
4006 tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
4007 return Ok(ReconcileProgress::Deferred);
4008 }
4009 }
4010 let live = matches!(
4011 provider_state,
4012 EnvironmentState::Starting | EnvironmentState::Running
4013 );
4014 if attempt.state() == AttemptState::JitReceived && live {
4015 attempt
4016 .started_isolated(self.ports.clock.now())
4017 .map_err(|_| LifecycleError::Transition)?;
4018 self.record(&attempt)?;
4019 }
4020 let github = self
4021 .ports
4022 .github
4023 .observe(&policy.target, attempt.id, &self.cancel)
4024 .await;
4025 if let Some(runner_id) = github.runner_id
4026 && read_runner_id(attempt.runtime_path()).is_none()
4027 {
4028 write_runner_id(attempt.runtime_path(), runner_id)?;
4029 }
4030 if attempt.state() == AttemptState::Busy
4034 && !live
4035 && github.status == GithubRunnerObservation::NotRegistered
4036 && self.ports.processes.completed_successfully(&attempt)
4037 {
4038 self.conclude(&mut attempt, AttemptOutcome::CompletedJob)?;
4039 self.clean_or_quarantine(&mut attempt)?;
4040 return Ok(ReconcileProgress::Reconciled);
4041 }
4042 match recovery_decision(
4043 &attempt,
4044 RecoveryObservation {
4045 process_alive: live,
4046 github: github.status,
4047 },
4048 self.timeouts,
4049 self.ports.clock.as_ref(),
4050 ) {
4051 RecoveryDecision::Nothing | RecoveryDecision::Wait => Ok(ReconcileProgress::Reconciled),
4052 RecoveryDecision::Defer => Ok(ReconcileProgress::Deferred),
4053 RecoveryDecision::Adopt => {
4054 self.ports.events.emit(AttemptEvent::Adopted {
4055 attempt: attempt.id,
4056 });
4057 Ok(ReconcileProgress::Reconciled)
4058 }
4059 RecoveryDecision::Clean => {
4060 self.clean_or_quarantine(&mut attempt)?;
4061 Ok(ReconcileProgress::Reconciled)
4062 }
4063 RecoveryDecision::Observe(state) => {
4064 let runner_id = github
4065 .runner_id
4066 .or_else(|| read_runner_id(attempt.runtime_path()))
4067 .ok_or(LifecycleError::Transition)?;
4068 match state {
4069 AttemptState::Idle => attempt
4070 .registered_idle(runner_id, self.ports.clock.now())
4071 .map_err(|_| LifecycleError::Transition)?,
4072 AttemptState::Busy => attempt
4073 .assigned_job(runner_id, self.ports.clock.now())
4074 .map_err(|_| LifecycleError::Transition)?,
4075 _ => return Err(LifecycleError::Transition),
4076 }
4077 self.record(&attempt)?;
4078 Ok(ReconcileProgress::Reconciled)
4079 }
4080 RecoveryDecision::Conclude(outcome) | RecoveryDecision::Terminate(outcome) => {
4081 let replacement = replacement_operation(&outcome);
4082 if matches!(github.status, GithubRunnerObservation::Registered { .. }) {
4083 self.deregister_runner(policy, &attempt).await;
4084 }
4085 self.conclude(&mut attempt, outcome)?;
4086 self.clean_or_quarantine(&mut attempt)?;
4087 Ok(
4088 replacement.map_or(ReconcileProgress::Reconciled, |operation| {
4089 ReconcileProgress::Replacement {
4090 attempt: attempt.id,
4091 operation,
4092 }
4093 }),
4094 )
4095 }
4096 }
4097 }
4098
4099 fn transition(
4101 &self,
4102 attempt: &mut RunnerAttempt,
4103 edge: impl FnOnce(
4104 &mut RunnerAttempt,
4105 ) -> Result<(), runner_manager_domain::attempt::AttemptError>,
4106 ) -> Result<(), LifecycleError> {
4107 edge(attempt).map_err(|_| LifecycleError::Transition)?;
4108 self.record(attempt)
4109 }
4110
4111 fn record(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4112 self.ports
4113 .store
4114 .record_attempt(attempt)
4115 .map_err(|_| LifecycleError::Journal)?;
4116 self.ports.events.emit(AttemptEvent::State {
4117 attempt: attempt.id,
4118 state: attempt.state(),
4119 });
4120 Ok(())
4121 }
4122
4123 async fn deregister_runner(&self, policy: &ScalePolicy, attempt: &RunnerAttempt) {
4144 let Some(runner_id) = attempt
4145 .github_runner_id()
4146 .or_else(|| read_runner_id(attempt.runtime_path()))
4147 else {
4148 return;
4149 };
4150 if self
4151 .ports
4152 .github
4153 .deregister(&policy.target, runner_id, &self.cancel)
4154 .await
4155 {
4156 self.ports.events.emit(AttemptEvent::Deregistered {
4157 attempt: attempt.id,
4158 runner_id,
4159 });
4160 } else {
4161 tracing::warn!(
4162 attempt = %attempt.id,
4163 runner_id,
4164 "the runner registration could not be removed from GitHub; it will show in the \
4165 target's runner settings until GitHub retires it or a later pass removes it"
4166 );
4167 }
4168 }
4169
4170 fn conclude(
4171 &self,
4172 attempt: &mut RunnerAttempt,
4173 outcome: AttemptOutcome,
4174 ) -> Result<(), LifecycleError> {
4175 attempt
4176 .conclude(outcome.clone(), self.ports.clock.now())
4177 .map_err(|_| LifecycleError::Transition)?;
4178 self.record(attempt)?;
4179 self.ports.events.emit(AttemptEvent::Concluded {
4180 attempt: attempt.id,
4181 outcome: OutcomeKind::of(&outcome),
4182 });
4183 Ok(())
4184 }
4185
4186 fn clean_or_quarantine(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
4206 let now = self.ports.clock.now();
4207 if !self.cleanup_retries().is_due(attempt.id, now) {
4208 return Ok(());
4209 }
4210 let reason = match self.clean_attempt(attempt) {
4211 Err(LifecycleError::SlotQuarantined { class, .. }) => class,
4212 Err(LifecycleError::WorkspaceCleanupDeferred) => {
4213 failure_reason_kind(&FailureReason::WorkspaceCleanupDeferred)
4214 }
4215 Err(LifecycleError::EnvironmentCleanupDeferred) => {
4216 "isolated_environment_cleanup_deferred"
4217 }
4218 other => {
4219 if other.is_ok() {
4220 self.cleanup_retries().succeeded(attempt.id);
4221 }
4222 return other;
4223 }
4224 };
4225 self.cleanup_retries().failed(attempt.id, now);
4226 self.ports
4227 .reconcile_events
4228 .emit(LifecycleEvent::AttemptCleanFailed {
4229 policy: attempt.policy_id,
4230 attempt: attempt.id,
4231 reason,
4232 });
4233 Ok(())
4234 }
4235
4236 fn cleanup_retries(&self) -> std::sync::MutexGuard<'_, CleanBackoff> {
4237 self.cleanup_retries
4238 .lock()
4239 .unwrap_or_else(std::sync::PoisonError::into_inner)
4240 }
4241
4242 fn clean_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
4243 if !attempt.execution().is_native() {
4244 return self.clean_isolated_attempt(attempt);
4245 }
4246 match self
4247 .ports
4248 .processes
4249 .destroy(attempt)
4250 .map_err(LifecycleError::Failed)?
4251 {
4252 ProviderDestroy::Destroyed => {}
4253 ProviderDestroy::Deferred(reason) => {
4254 return Err(LifecycleError::Failed(FailureReason::Other(reason.into())));
4255 }
4256 }
4257 let outcome = attempt
4258 .outcome()
4259 .cloned()
4260 .ok_or(LifecycleError::Transition)?;
4261 self.preserve_diagnostics(attempt, &outcome)?;
4262 self.scrub_workspace(attempt)?;
4263 self.ports
4264 .packages
4265 .release(attempt.id)
4266 .map_err(LifecycleError::Failed)?;
4267 attempt
4268 .clean(self.ports.clock.now())
4269 .map_err(|_| LifecycleError::Transition)?;
4270 self.record(attempt)?;
4271 let kind = OutcomeKind::of(&outcome);
4272 self.ports.events.emit(AttemptEvent::Cleaned {
4273 attempt: attempt.id,
4274 outcome: kind,
4275 });
4276 self.ports
4277 .reconcile_events
4278 .emit(LifecycleEvent::AttemptCleaned {
4279 policy: attempt.policy_id,
4280 attempt: attempt.id,
4281 outcome: kind,
4282 });
4283 Ok(())
4284 }
4285
4286 fn clean_isolated_attempt(&self, attempt: &mut RunnerAttempt) -> Result<(), LifecycleError> {
4287 if attempt.state() != AttemptState::Destroying {
4288 attempt
4289 .begin_destroy(self.ports.clock.now())
4290 .map_err(|_| LifecycleError::Transition)?;
4291 self.record(attempt)?;
4292 }
4293 let provider = self.ports.processes.as_ref();
4294 let allocation_resources = provider.enumerate_owned(self.host_id);
4295 let matching_allocation = allocation_resources
4296 .iter()
4297 .filter(|identity| identity_same_allocation(self.host_id, attempt, identity))
4298 .collect::<Vec<_>>();
4299 if matching_allocation.len() > 1
4300 || matching_allocation
4301 .iter()
4302 .any(|identity| !identity_matches_intent(self.host_id, attempt, identity))
4303 {
4304 self.ports
4305 .events
4306 .emit(AttemptEvent::QuarantinedOwnedResource {
4307 attempt: attempt.id,
4308 });
4309 tracing::warn!(attempt_id = %attempt.id, reason = "duplicate_or_mismatched_isolated_ownership", "provider resources quarantined for operator review");
4310 return Err(self.defer_environment_cleanup(attempt)?);
4311 }
4312 let has_identity = matches!(
4313 attempt.execution(),
4314 AttemptExecution::Isolated {
4315 environment_id: Some(_),
4316 ..
4317 }
4318 );
4319 if !has_identity
4320 && allocation_resources
4321 .iter()
4322 .any(|identity| identity_matches_intent(self.host_id, attempt, identity))
4323 {
4324 return Err(self.defer_environment_cleanup(attempt)?);
4325 }
4326 let current = if has_identity {
4327 provider.inspect(attempt)
4328 } else {
4329 provider.recover(attempt)
4332 };
4333 let current = match current {
4334 Ok(state) => state,
4335 Err(_) => return Err(self.defer_environment_cleanup(attempt)?),
4336 };
4337 if !has_identity && current != EnvironmentState::Missing {
4338 return Err(self.defer_environment_cleanup(attempt)?);
4339 }
4340 if current != EnvironmentState::Missing {
4341 if !matches!(provider.owns(attempt), Ok(true)) {
4342 self.ports
4343 .events
4344 .emit(AttemptEvent::QuarantinedOwnedResource {
4345 attempt: attempt.id,
4346 });
4347 tracing::warn!(attempt_id = %attempt.id, reason = "isolated_resource_ownership_mismatch", "provider resource quarantined for operator review");
4348 return Err(self.defer_environment_cleanup(attempt)?);
4349 }
4350 if matches!(
4351 current,
4352 EnvironmentState::Starting | EnvironmentState::Running
4353 ) && !matches!(provider.stop(attempt), Ok(ProviderStop::Stopped))
4354 {
4355 return Err(self.defer_environment_cleanup(attempt)?);
4356 }
4357 if !matches!(provider.destroy(attempt), Ok(ProviderDestroy::Destroyed)) {
4358 return Err(self.defer_environment_cleanup(attempt)?);
4359 }
4360 if !matches!(provider.inspect(attempt), Ok(EnvironmentState::Missing)) {
4361 return Err(self.defer_environment_cleanup(attempt)?);
4362 }
4363 }
4364 if provider
4365 .enumerate_owned(self.host_id)
4366 .iter()
4367 .any(|identity| identity_same_allocation(self.host_id, attempt, identity))
4368 {
4369 return Err(self.defer_environment_cleanup(attempt)?);
4370 }
4371 let outcome = attempt
4372 .outcome()
4373 .cloned()
4374 .ok_or(LifecycleError::Transition)?;
4375 for diagnostic in provider.diagnostics(attempt).into_iter().take(32) {
4378 tracing::debug!(attempt_id = %attempt.id, provider_diagnostic = ?diagnostic, "provider lifecycle diagnostic");
4379 }
4380 self.preserve_diagnostics(attempt, &outcome)?;
4381 self.scrub_workspace(attempt)?;
4382 self.ports
4383 .packages
4384 .release(attempt.id)
4385 .map_err(LifecycleError::Failed)?;
4386 attempt
4387 .clean_isolated(self.ports.clock.now())
4388 .map_err(|_| LifecycleError::Transition)?;
4389 self.record(attempt)?;
4390 self.ports.events.emit(AttemptEvent::Cleaned {
4391 attempt: attempt.id,
4392 outcome: OutcomeKind::of(&outcome),
4393 });
4394 self.ports
4395 .reconcile_events
4396 .emit(LifecycleEvent::AttemptCleaned {
4397 policy: attempt.policy_id,
4398 attempt: attempt.id,
4399 outcome: OutcomeKind::of(&outcome),
4400 });
4401 Ok(())
4402 }
4403
4404 fn defer_environment_cleanup(
4405 &self,
4406 attempt: &mut RunnerAttempt,
4407 ) -> Result<LifecycleError, LifecycleError> {
4408 attempt
4409 .defer_cleanup(self.ports.clock.now())
4410 .map_err(|_| LifecycleError::Transition)?;
4411 self.record(attempt)?;
4412 Ok(LifecycleError::EnvironmentCleanupDeferred)
4413 }
4414
4415 fn scrub_workspace(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4425 #[cfg(test)]
4426 {
4427 if matches!(
4431 std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref(),
4432 Ok("skip_workspace_cleanup" | "reuse_job_workspace")
4433 ) {
4434 return Ok(());
4435 }
4436 }
4437 match attempt.workspace() {
4438 AttemptWorkspace::Ephemeral => match remove_runtime_tree(attempt.runtime_path()) {
4439 Ok(()) => Ok(()),
4440 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
4441 Err(_) => Err(LifecycleError::WorkspaceCleanupDeferred),
4442 },
4443 AttemptWorkspace::PersistentSlot { slot } => self.scrub_persistent_slot(attempt, slot),
4444 }
4445 }
4446
4447 fn scrub_persistent_slot(
4460 &self,
4461 attempt: &RunnerAttempt,
4462 slot: NonZeroU16,
4463 ) -> Result<(), LifecycleError> {
4464 let configured = self
4469 .ports
4470 .store
4471 .policy(attempt.policy_id)
4472 .map_err(|_| LifecycleError::Journal)?
4473 .and_then(|policy| match policy.workspace_policy() {
4474 WorkspacePolicy::Persistent { root } => Some(root.clone()),
4475 WorkspacePolicy::Ephemeral => None,
4476 });
4477 let runtime = attempt.runtime_path();
4478 self.quarantine_on_refusal(
4479 attempt,
4480 verify_journalled_slot(runtime, slot, configured.as_ref())
4481 .and_then(|()| slot_is_present(runtime))
4482 .and_then(|present| {
4483 if present {
4484 scrub_slot_entries(runtime).and_then(|()| verify_slot_scrubbed(runtime))
4485 } else {
4486 Ok(())
4487 }
4488 }),
4489 )
4490 }
4491
4492 fn quarantine_on_refusal(
4501 &self,
4502 attempt: &RunnerAttempt,
4503 outcome: Result<(), SlotQuarantine>,
4504 ) -> Result<(), LifecycleError> {
4505 let Err(quarantine) = outcome else {
4506 return Ok(());
4507 };
4508 let detail = quarantine.to_string();
4509 tracing::warn!(
4510 attempt = %attempt.id,
4511 policy = %attempt.policy_id,
4512 slot = attempt.workspace().slot_number(),
4513 refusal = quarantine.refusal.class(),
4514 "{detail}"
4515 );
4516 Err(LifecycleError::SlotQuarantined {
4517 class: quarantine.refusal.class(),
4518 detail,
4519 })
4520 }
4521
4522 fn preserve_diagnostics(
4523 &self,
4524 attempt: &RunnerAttempt,
4525 outcome: &AttemptOutcome,
4526 ) -> Result<(), LifecycleError> {
4527 fs::create_dir_all(&self.diagnostics_root).map_err(|_| {
4528 LifecycleError::Failed(FailureReason::Other(
4529 "diagnostics directory could not be created".into(),
4530 ))
4531 })?;
4532 let reason_kind = match outcome {
4535 AttemptOutcome::Failed { reason } => failure_reason_kind(reason),
4536 _ => "none",
4537 };
4538 let diagnostic = format!(
4539 "attempt_id={}\npolicy_id={}\noutcome={}\nreason={}\n",
4540 attempt.id,
4541 attempt.policy_id,
4542 OutcomeKind::of(outcome).as_str(),
4543 reason_kind,
4544 );
4545 fs::write(
4546 self.diagnostics_root.join(format!("{}.log", attempt.id)),
4547 diagnostic,
4548 )
4549 .map_err(|_| {
4550 LifecycleError::Failed(FailureReason::Other(
4551 "redacted diagnostics could not be preserved".into(),
4552 ))
4553 })
4554 }
4555
4556 async fn materialize_with_retry(
4557 &self,
4558 policy: &ScalePolicy,
4559 attempt: &RunnerAttempt,
4560 timings: &mut PackageTimings,
4561 ) -> Result<RunnerVersion, FailureReason> {
4562 let mut issued = 0_u32;
4563 loop {
4564 issued = issued.saturating_add(1);
4565 match self.ports.packages.materialize(attempt, timings).await {
4566 Ok(version) => return Ok(version),
4567 Err(reason)
4568 if package_failure_is_terminal(&reason)
4569 || issued >= self.retry.max_attempts.max(1) =>
4570 {
4571 return Err(reason);
4572 }
4573 Err(reason) => {
4574 if !self.ports.demand.persists(policy.id).await {
4575 return Err(reason);
4576 }
4577 let delay = self.retry.delay(issued);
4578 self.ports.events.emit(AttemptEvent::Retry {
4579 attempt: attempt.id,
4580 operation: "package_materialization",
4581 delay,
4582 });
4583 self.ports.delay.wait(delay).await;
4584 if !self.ports.demand.persists(policy.id).await {
4585 return Err(reason);
4586 }
4587 }
4588 }
4589 }
4590 }
4591
4592 async fn register_with_retry(
4593 &self,
4594 policy: &ScalePolicy,
4595 attempt: AttemptId,
4596 request: &JitRunnerRequest,
4597 ) -> Result<JitRegistration, LifecycleError> {
4598 let mut issued = 0_u32;
4599 loop {
4600 issued = issued.saturating_add(1);
4601 match self
4602 .ports
4603 .github
4604 .register(&policy.target, request, &self.cancel)
4605 .await
4606 {
4607 Ok(registration) => return Ok(registration),
4608 Err(error) if error.terminal => {
4609 return Err(LifecycleError::Failed(error.reason));
4610 }
4611 Err(error) => {
4612 if issued >= self.retry.max_attempts.max(1)
4613 || !self.ports.demand.persists(policy.id).await
4614 {
4615 return Err(LifecycleError::Failed(error.reason));
4616 }
4617 let delay = error
4618 .retry_after
4619 .unwrap_or_else(|| self.retry.delay(issued));
4620 self.ports.events.emit(AttemptEvent::Retry {
4621 attempt,
4622 operation: "jit_request",
4623 delay,
4624 });
4625 self.ports.delay.wait(delay).await;
4626 if !self.ports.demand.persists(policy.id).await {
4627 return Err(LifecycleError::Failed(error.reason));
4628 }
4629 }
4630 }
4631 }
4632 }
4633
4634 fn allocate_workspace(
4644 &self,
4645 policy: &ScalePolicy,
4646 id: AttemptId,
4647 ) -> Result<Placement, LifecycleError> {
4648 let placement = match policy.workspace_policy() {
4649 WorkspacePolicy::Persistent { root } => self.allocate_persistent_slot(policy, root),
4654 WorkspacePolicy::Ephemeral => self.allocate_disposable(policy, id),
4655 };
4656 if placement.is_ok() {
4659 self.root_accepted(policy.id);
4660 }
4661 placement
4662 }
4663
4664 fn configured_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4671 let host = self
4672 .ports
4673 .store
4674 .host(self.host_id)
4675 .map_err(|_| LifecycleError::Journal)?
4676 .ok_or_else(|| LifecycleError::Failed(FailureReason::Other("host not found".into())))?;
4677 Ok(host.runner_root_override.clone())
4678 }
4679
4680 fn resolvable_host_root(&self) -> Result<Option<LocalAbsolutePath>, LifecycleError> {
4684 Ok(self
4685 .configured_host_root()?
4686 .or_else(|| default_runner_root(&self.app_paths).ok()))
4687 }
4688
4689 fn effective_host_root(
4694 &self,
4695 policy: &ScalePolicy,
4696 ) -> Result<LocalAbsolutePath, LifecycleError> {
4697 match self.configured_host_root()? {
4698 Some(configured) => Ok(configured),
4699 None => default_runner_root(&self.app_paths).map_err(|error| {
4700 self.root_refused(policy.id, "the platform default runner root", error)
4703 }),
4704 }
4705 }
4706
4707 fn root_refused(&self, policy: PolicyId, root: &str, error: RunnerRootError) -> LifecycleError {
4720 let _ = runner_manager_platform::service::record_runner_root_refusal(
4721 &self.app_paths,
4722 &policy.to_string(),
4723 self.ports.clock.now(),
4724 error.kind(),
4725 root,
4726 &error.to_string(),
4727 );
4728 root_failure(error)
4729 }
4730
4731 fn root_accepted(&self, policy: PolicyId) {
4738 let _ = runner_manager_platform::service::clear_runner_root_refusal(
4739 &self.app_paths,
4740 &policy.to_string(),
4741 );
4742 }
4743
4744 fn allocate_disposable(
4747 &self,
4748 policy: &ScalePolicy,
4749 id: AttemptId,
4750 ) -> Result<Placement, LifecycleError> {
4751 let effective_root = self.effective_host_root(policy)?;
4752 RootPreflight::new(&self.app_paths)
4753 .check(&RootOwner::Host, &effective_root)
4754 .map_err(|error| self.root_refused(policy.id, effective_root.as_str(), error))?;
4755 let runtime = effective_root.as_path().join({
4756 #[cfg(test)]
4757 {
4758 if std::env::var("RUNNER_MANAGER_TEST_MUTANT").as_deref()
4759 == Ok("reuse_job_workspace")
4760 {
4761 "mutant-shared-workspace".to_owned()
4762 } else {
4763 workspace_name(id)
4764 }
4765 }
4766 #[cfg(not(test))]
4767 {
4768 workspace_name(id)
4769 }
4770 });
4771 fs::create_dir_all(&runtime)
4772 .map_err(|_| LifecycleError::Failed(FailureReason::ProcessStartFailed))?;
4773 Ok(Placement {
4774 runtime,
4775 workspace: AttemptWorkspace::Ephemeral,
4776 })
4777 }
4778
4779 fn allocate_persistent_slot(
4791 &self,
4792 policy: &ScalePolicy,
4793 root: &LocalAbsolutePath,
4794 ) -> Result<Placement, LifecycleError> {
4795 let ceiling = policy.max_capacity().ok_or_else(|| {
4798 LifecycleError::Failed(FailureReason::Other(
4799 "a persistent workspace needs the policy's max_capacity to bound its slots"
4800 .to_string(),
4801 ))
4802 })?;
4803 let leases = self
4804 .ports
4805 .store
4806 .slot_leases_for_policy(policy.id)
4807 .map_err(|_| LifecycleError::Journal)?;
4808 let slot = lowest_free_slot(&leases, ceiling).ok_or_else(|| {
4809 LifecycleError::Failed(FailureReason::Other(format!(
4810 "every persistent slot s1 to s{ceiling} for {} is leased by an attempt that has \
4811 not been cleaned, so no slot is free; raise the repository's max capacity, or \
4812 finish cleaning a concluded attempt",
4813 policy.target
4814 )))
4815 })?;
4816 let workspace = AttemptWorkspace::persistent_slot(slot);
4817 let name = workspace
4818 .slot_directory_name()
4819 .expect("a persistent allocation names its slot directory");
4820
4821 let host_root = self.resolvable_host_root()?;
4833 let mut preflight = RootPreflight::new(&self.app_paths);
4834 if let Some(host_root) = host_root {
4835 preflight = preflight.against(RootOwner::Host, host_root);
4836 }
4837 let checked = preflight
4838 .check(&RootOwner::Repository(policy.target.to_string()), root)
4839 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4840 if let Some(leaf) = checked.leaf_to_create() {
4841 fs::create_dir(leaf).map_err(|source| {
4842 LifecycleError::Failed(FailureReason::Other(format!(
4843 "the persistent workspace root {} could not be created: {source}",
4844 leaf.display()
4845 )))
4846 })?;
4847 }
4848
4849 let slot_path = runner_root::derive_child(root, &name)
4853 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4854 create_or_validate_slot(slot_path.as_path())?;
4855 runner_root::verify_containment(root, &slot_path)
4856 .map_err(|error| self.root_refused(policy.id, root.as_str(), error))?;
4857 accept_reusable_slot(slot_path.as_path())?;
4858 Ok(Placement {
4859 runtime: slot_path.as_path().to_path_buf(),
4860 workspace,
4861 })
4862 }
4863
4864 fn record_allocation(&self, attempt: &RunnerAttempt) -> Result<(), LifecycleError> {
4876 match self.ports.store.record_attempt(attempt) {
4877 Ok(()) => {
4878 self.ports.events.emit(AttemptEvent::State {
4879 attempt: attempt.id,
4880 state: attempt.state(),
4881 });
4882 Ok(())
4883 }
4884 Err(error @ StoreError::SlotAlreadyLeased { .. }) => Err(LifecycleError::Failed(
4885 FailureReason::Other(error.to_string()),
4886 )),
4887 Err(_) => Err(LifecycleError::Journal),
4888 }
4889 }
4890
4891 fn reserve_attempt(
4894 &self,
4895 policy: &ScalePolicy,
4896 allocation_guard: &AllocationGuard,
4897 ) -> Result<RunnerAttempt, LifecycleError> {
4898 if let Err(error) = self.prune_under_allocation_lock(allocation_guard) {
4901 tracing::warn!(
4902 event = "package_prune_failed",
4903 reason = failure_reason_kind(&error.reason()),
4904 );
4905 }
4906 self.allocate_attempt(policy)
4907 }
4908
4909 async fn finish_attempt(
4910 &self,
4911 policy: &ScalePolicy,
4912 attempt: RunnerAttempt,
4913 waits: ReservationWaits,
4914 ) -> Result<RunnerAttempt, LifecycleError> {
4915 let id = attempt.id;
4916 let _bringing_up = BroughtUpOnDrop {
4917 attempts: &self.bringing_up,
4918 id,
4919 };
4920 let mut stopwatch = LaunchStopwatch::new(waits);
4921 let result = self.launch_steps(policy, attempt, &mut stopwatch).await;
4922 stopwatch.finish();
4923 self.ports.events.emit(AttemptEvent::Launched {
4924 attempt: id,
4925 started: result.is_ok(),
4926 timings: stopwatch.timings,
4927 });
4928 result
4929 }
4930
4931 fn allocate_attempt(&self, policy: &ScalePolicy) -> Result<RunnerAttempt, LifecycleError> {
4934 if !*self
4935 .recovery_complete
4936 .lock()
4937 .map_err(|_| LifecycleError::Journal)?
4938 {
4939 return Err(LifecycleError::RecoveryIncomplete);
4940 }
4941 let capability = self.ports.processes.probe(policy);
4942 if capability != ProviderCapability::Ready {
4943 return Err(LifecycleError::Failed(capability.refusal()));
4944 }
4945 let resolved = self.ports.processes.resolve(policy).map_err(|reason| {
4946 LifecycleError::Failed(if policy.execution_policy().is_native() {
4947 reason
4948 } else {
4949 safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
4950 })
4951 })?;
4952 if policy.execution_policy().is_native() != resolved.is_none() {
4953 return Err(LifecycleError::Failed(FailureReason::Other(
4954 "provider resolution mismatch".into(),
4955 )));
4956 }
4957 if let Some(resolution) = &resolved {
4958 if resolution.provider_kind == Backend::Auto {
4959 return Err(LifecycleError::Failed(FailureReason::Other(
4960 "provider resolution mismatch".into(),
4961 )));
4962 }
4963 AttemptExecution::Isolated {
4964 provider_kind: resolution.provider_kind,
4965 environment_id: None,
4966 resolved_image: resolution.image.clone(),
4967 generation: "generation-check".into(),
4968 }
4969 .validate()
4970 .map_err(|_| {
4971 LifecycleError::Failed(FailureReason::Other(
4972 "resolved image is incompatible".into(),
4973 ))
4974 })?;
4975 }
4976 policy
4977 .routing_labels()
4978 .ok_or(LifecycleError::Failed(FailureReason::JitRequestFailed))?;
4979 let id = AttemptId::new_random();
4980 let placement = self.allocate_workspace(policy, id)?;
4981 let mut attempt = RunnerAttempt::allocate_in(
4982 id,
4983 policy.id,
4984 placement.runtime,
4985 placement.workspace,
4986 self.ports.clock.now(),
4987 );
4988 if let Some(resolution) = &resolved {
4989 attempt
4990 .allocate_execution(AttemptExecution::Isolated {
4991 provider_kind: resolution.provider_kind,
4992 environment_id: None,
4993 resolved_image: resolution.image.clone(),
4994 generation: uuid::Uuid::new_v4().to_string(),
4995 })
4996 .map_err(|_| LifecycleError::Transition)?;
4997 }
4998 self.bringing_up
5003 .lock()
5004 .map_err(|_| LifecycleError::Journal)?
5005 .insert(id);
5006 if let Err(error) = self.record_allocation(&attempt) {
5007 self.bringing_up
5008 .lock()
5009 .map_err(|_| LifecycleError::Journal)?
5010 .remove(&id);
5011 return Err(error);
5012 }
5013 Ok(attempt)
5014 }
5015
5016 async fn launch_steps(
5017 &self,
5018 policy: &ScalePolicy,
5019 mut attempt: RunnerAttempt,
5020 stopwatch: &mut LaunchStopwatch,
5021 ) -> Result<RunnerAttempt, LifecycleError> {
5022 let id = attempt.id;
5023 let isolated = !attempt.execution().is_native();
5024 let Some(labels) = policy.routing_labels() else {
5025 return self.fail_launch(&mut attempt, FailureReason::JitRequestFailed);
5026 };
5027 stopwatch.begin(LaunchStep::Turn);
5028 let turn = self.turns.acquire().await.ok();
5030 stopwatch.begin(LaunchStep::Materialize);
5031 let package_timings = &mut stopwatch.timings.package;
5032 let version = match self
5033 .materialize_with_retry(policy, &attempt, package_timings)
5034 .await
5035 {
5036 Ok(version) => version,
5037 Err(reason) => return self.fail_launch(&mut attempt, reason),
5038 };
5039 self.versions
5040 .lock()
5041 .map_err(|_| LifecycleError::Journal)?
5042 .insert(id, version);
5043
5044 if isolated {
5045 attempt
5046 .begin_prepare(self.ports.clock.now())
5047 .map_err(|_| LifecycleError::Transition)?;
5048 self.record(&attempt)?;
5049 }
5050 stopwatch.begin(LaunchStep::Prepare);
5051 let preparing = {
5054 let (processes, attempt, policy) = (
5055 Arc::clone(&self.ports.processes),
5056 attempt.clone(),
5057 policy.clone(),
5058 );
5059 tokio::task::spawn_blocking(move || processes.prepare(&attempt, &policy)).await
5060 };
5061 drop(turn);
5062 let prepared = match preparing.unwrap_or(Err(FailureReason::ProcessStartFailed)) {
5063 Ok(prepared) => prepared,
5064 Err(reason) => {
5065 let safe_reason = if isolated {
5066 safe_isolation_reason(reason, IsolationProviderFailure::RuntimeOperationFailed)
5067 } else {
5068 reason
5069 };
5070 return self.fail_launch(&mut attempt, safe_reason);
5071 }
5072 };
5073 if isolated {
5074 let Some(identity) = prepared.identity() else {
5075 return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
5076 IsolationProviderFailure::OwnershipMismatch,
5077 )));
5078 };
5079 if !identity_matches_intent(self.host_id, &attempt, identity) {
5080 return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
5081 IsolationProviderFailure::OwnershipMismatch,
5082 )));
5083 }
5084 let EnvironmentIdentity::Isolated { environment_id, .. } = identity else {
5085 unreachable!()
5086 };
5087 attempt
5088 .prepared_environment(environment_id.clone())
5089 .map_err(|_| LifecycleError::Transition)?;
5090 attempt
5091 .mark_prepared(self.ports.clock.now())
5092 .map_err(|_| LifecycleError::Transition)?;
5093 self.record(&attempt)?;
5094 }
5095
5096 stopwatch.begin(LaunchStep::Register);
5097 let jit_request =
5098 JitRunnerRequest::for_policy(runner_name(id), self.runner_group_id, labels);
5099 let registration = match self.register_with_retry(policy, id, &jit_request).await {
5100 Ok(registration) => registration,
5101 Err(error) => return self.fail_launch(&mut attempt, error.reason()),
5102 };
5103 stopwatch.begin(LaunchStep::Spawn);
5104 let runner_id = registration.runner().id;
5105 write_runner_id(attempt.runtime_path(), runner_id)?;
5106 attempt
5107 .jit_received(self.ports.clock.now())
5108 .map_err(|_| LifecycleError::Transition)?;
5109 self.record(&attempt)?;
5110 let config = registration.into_config();
5111 if isolated {
5112 let started = self
5113 .ports
5114 .processes
5115 .start(prepared, &attempt, OneTimeJitHandoff::new(&config))
5116 .map_err(|failure| {
5117 LifecycleError::Failed(safe_isolation_reason(
5118 failure.reason,
5119 IsolationProviderFailure::RuntimeOperationFailed,
5120 ))
5121 })?;
5122 if !identity_matches_intent(self.host_id, &attempt, &started) {
5123 return Err(LifecycleError::Failed(FailureReason::IsolationProvider(
5124 IsolationProviderFailure::OwnershipMismatch,
5125 )));
5126 }
5127 attempt
5128 .started_isolated(self.ports.clock.now())
5129 .map_err(|_| LifecycleError::Transition)?;
5130 self.record(&attempt)?;
5131 return Ok(attempt);
5132 }
5133 let mut issued = 0_u32;
5134 let pid = loop {
5135 issued = issued.saturating_add(1);
5136 match self.ports.processes.start(
5137 prepared.clone(),
5138 &attempt,
5139 OneTimeJitHandoff::new(&config),
5140 ) {
5141 Ok(EnvironmentIdentity::NativeProcess(pid)) => break pid,
5142 Ok(EnvironmentIdentity::Isolated { .. }) => {
5143 return Err(LifecycleError::Failed(FailureReason::Other(
5147 "unexpected isolated environment identity".into(),
5148 )));
5149 }
5150 Err(error) => {
5151 if let Some(pid) = error.live_pid {
5152 attempt
5153 .started(pid, self.ports.clock.now())
5154 .map_err(|_| LifecycleError::Transition)?;
5155 self.record(&attempt)?;
5156 return Err(LifecycleError::Failed(error.reason));
5157 }
5158 if !error.retryable
5159 || issued >= self.retry.max_attempts.max(1)
5160 || !self.ports.demand.persists(policy.id).await
5161 {
5162 return self.fail_launch(&mut attempt, error.reason);
5163 }
5164 let delay = self.retry.delay(issued);
5165 self.ports.events.emit(AttemptEvent::Retry {
5166 attempt: attempt.id,
5167 operation: "process_start",
5168 delay,
5169 });
5170 self.ports.delay.wait(delay).await;
5171 if !self.ports.demand.persists(policy.id).await {
5172 return self.fail_launch(&mut attempt, error.reason);
5173 }
5174 }
5175 }
5176 };
5177 attempt
5178 .started(pid, self.ports.clock.now())
5179 .map_err(|_| LifecycleError::Transition)?;
5180 self.record(&attempt)?;
5181 Ok(attempt)
5182 }
5183
5184 fn fail_launch<T>(
5185 &self,
5186 attempt: &mut RunnerAttempt,
5187 reason: FailureReason,
5188 ) -> Result<T, LifecycleError> {
5189 self.conclude(attempt, AttemptOutcome::failed(reason.clone()))?;
5190 Err(LifecycleError::Failed(reason))
5191 }
5192
5193 fn prune_under_allocation_lock(&self, guard: &AllocationGuard) -> Result<(), LifecycleError> {
5196 let attempts = self
5197 .ports
5198 .store
5199 .attempts()
5200 .map_err(|_| LifecycleError::Journal)?;
5201 self.ports
5202 .packages
5203 .prune_obsolete_guarded(PruneAuthority::from_launch_request(guard), &attempts)
5204 .map_err(LifecycleError::Failed)?;
5205 #[cfg(target_os = "macos")]
5206 self.prune_abandoned_seeds(&attempts);
5207 Ok(())
5208 }
5209
5210 #[cfg(target_os = "macos")]
5221 fn prune_abandoned_seeds(&self, attempts: &[RunnerAttempt]) {
5222 let Ok(policies) = self.ports.store.policies() else {
5223 return;
5224 };
5225 let Ok(Some(host_root)) = self.resolvable_host_root() else {
5226 return;
5227 };
5228 let in_use: BTreeSet<PathBuf> = policies
5229 .iter()
5230 .filter_map(|policy| match policy.workspace_policy() {
5231 WorkspacePolicy::Persistent { root } => Some(canonical_or_raw(root.as_path())),
5232 WorkspacePolicy::Ephemeral => None,
5233 })
5234 .chain(std::iter::once(canonical_or_raw(host_root.as_path())))
5235 .collect();
5236 let used: BTreeSet<&Path> = attempts
5238 .iter()
5239 .filter_map(|attempt| attempt.runtime_path().parent())
5240 .collect();
5241 for root in used.into_iter().map(canonical_or_raw) {
5242 if !in_use.contains(&root) {
5243 let _ = remove_runtime_tree(&root.join(PACKAGE_SEED_DIR));
5244 }
5245 }
5246 }
5247}
5248
5249#[cfg(target_os = "macos")]
5250fn canonical_or_raw(path: &Path) -> PathBuf {
5251 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
5252}
5253
5254#[async_trait]
5255impl RunnerLauncher for LifecycleLauncher {
5256 async fn host_capacity(
5257 &self,
5258 host: &runner_manager_domain::model::Host,
5259 ) -> Result<std::num::NonZeroU16, crate::reconcile::HostCapacityError> {
5260 self.ports
5261 .store
5262 .host(host.id)
5263 .map_err(|_| crate::reconcile::HostCapacityError::JournalUnreadable)?
5264 .map(|current| current.host_capacity)
5265 .ok_or(crate::reconcile::HostCapacityError::MissingHost)
5266 }
5267
5268 async fn supervise(
5269 &self,
5270 policy: &ScalePolicy,
5271 ) -> Result<Vec<ReplacementIntent>, LaunchFailure> {
5272 LifecycleLauncher::supervise(self, policy)
5273 .await
5274 .map_err(|error| LaunchFailure::new(error.reason()))
5275 }
5276
5277 async fn attempts(&self) -> Result<Vec<RunnerAttempt>, LaunchFailure> {
5278 self.ports.store.attempts().map_err(|_| {
5279 LaunchFailure::new(FailureReason::Other(
5280 "attempt journal could not be read".into(),
5281 ))
5282 })
5283 }
5284
5285 async fn launch(&self, request: LaunchRequest<'_>) -> Result<RunnerAttempt, LaunchFailure> {
5286 self.reserve_attempt(request.policy, request.allocation_guard)
5287 .map_err(|error| LaunchFailure::new(error.reason()))
5288 }
5289
5290 async fn finish_launch(
5291 &self,
5292 policy: &ScalePolicy,
5293 attempt: RunnerAttempt,
5294 waits: ReservationWaits,
5295 ) -> Result<RunnerAttempt, LaunchFailure> {
5296 self.finish_attempt(policy, attempt, waits)
5297 .await
5298 .map_err(|error| LaunchFailure::new(error.reason()))
5299 }
5300
5301 async fn clean(&self, id: AttemptId) -> Result<(), LaunchFailure> {
5302 let mut attempt = self
5303 .ports
5304 .store
5305 .attempt(id)
5306 .map_err(|_| {
5307 LaunchFailure::new(FailureReason::Other(
5308 "attempt journal could not be read".into(),
5309 ))
5310 })?
5311 .ok_or_else(|| {
5312 LaunchFailure::new(FailureReason::Other(
5313 "attempt disappeared from the journal".into(),
5314 ))
5315 })?;
5316 self.clean_attempt(&mut attempt)
5317 .map_err(|error| LaunchFailure::new(error.reason()))?;
5318 self.cleanup_retries().succeeded(id);
5319 Ok(())
5320 }
5321}
5322
5323#[must_use]
5326pub fn runner_name(attempt: AttemptId) -> String {
5327 format!("runner-manager-{attempt}")
5328}
5329
5330const EVIDENCE_DEADLINE: Duration = Duration::from_secs(2);
5332
5333#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5343enum JobEvidence {
5344 RanAJob,
5345 NoJob,
5346 Unknown,
5347}
5348
5349impl JobEvidence {
5350 fn of(runtime: &Path) -> Self {
5351 let diag = runtime.join("_diag");
5352 if runner_manager_platform::host_fitness::directory_responds(&diag, EVIDENCE_DEADLINE)
5356 != runner_manager_platform::host_fitness::Responsiveness::Responds
5357 {
5358 return Self::Unknown;
5359 }
5360 let Ok(entries) = fs::read_dir(diag) else {
5361 return Self::Unknown;
5362 };
5363 let mut listener = false;
5364 for entry in entries.flatten() {
5365 let name = entry.file_name();
5366 let name = name.to_string_lossy();
5367 if !name.ends_with(".log") {
5368 continue;
5369 }
5370 if name.starts_with("Worker_") {
5371 return Self::RanAJob;
5372 }
5373 listener |= name.starts_with("Runner_");
5374 }
5375 if listener { Self::NoJob } else { Self::Unknown }
5376 }
5377}
5378
5379fn known_runner_id(attempt: &RunnerAttempt, observed: Option<u64>) -> Option<u64> {
5382 attempt
5383 .github_runner_id()
5384 .or(observed)
5385 .or_else(|| read_runner_id(attempt.runtime_path()))
5386}
5387
5388fn read_runner_id(runtime: &Path) -> Option<u64> {
5389 fs::read_to_string(runtime.join(RUNNER_ID_FILE))
5390 .ok()?
5391 .trim()
5392 .parse()
5393 .ok()
5394}
5395
5396fn write_runner_id(runtime: &Path, runner_id: u64) -> Result<(), LifecycleError> {
5397 let target = runtime.join(RUNNER_ID_FILE);
5398 if let Some(existing) = read_runner_id(runtime) {
5399 return (existing == runner_id)
5400 .then_some(())
5401 .ok_or(LifecycleError::Journal);
5402 }
5403 let temporary = runtime.join(format!("{RUNNER_ID_FILE}.{}.tmp", uuid::Uuid::new_v4()));
5404 write_durable_file(&temporary, runner_id.to_string().as_bytes())
5405 .map_err(|_| LifecycleError::Journal)?;
5406 match fs::rename(&temporary, &target) {
5407 Ok(()) => sync_directory(runtime).map_err(|_| LifecycleError::Journal),
5408 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
5409 let _ = fs::remove_file(&temporary);
5410 (read_runner_id(runtime) == Some(runner_id))
5411 .then_some(())
5412 .ok_or(LifecycleError::Journal)
5413 }
5414 Err(_) => {
5415 let _ = fs::remove_file(&temporary);
5416 Err(LifecycleError::Journal)
5417 }
5418 }
5419}
5420
5421fn write_durable_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
5422 let mut file = fs::OpenOptions::new()
5423 .create(true)
5424 .truncate(true)
5425 .write(true)
5426 .open(path)?;
5427 file.write_all(bytes)?;
5428 file.sync_all()?;
5429 let parent = path.parent().ok_or_else(|| {
5430 std::io::Error::new(
5431 std::io::ErrorKind::InvalidInput,
5432 "file has no parent directory",
5433 )
5434 })?;
5435 sync_directory(parent)
5436}
5437
5438#[cfg(unix)]
5439fn sync_directory(path: &Path) -> std::io::Result<()> {
5440 fs::File::open(path)?.sync_all()
5441}
5442
5443#[cfg(windows)]
5444fn sync_directory(path: &Path) -> std::io::Result<()> {
5445 use std::os::windows::fs::OpenOptionsExt;
5446
5447 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
5448 const FILE_SHARE_ALL: u32 = 0x0000_0007;
5449 const GENERIC_WRITE: u32 = 0x4000_0000;
5450 fs::OpenOptions::new()
5451 .access_mode(GENERIC_WRITE)
5452 .share_mode(FILE_SHARE_ALL)
5453 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
5454 .open(path)?
5455 .sync_all()
5456}
5457
5458#[cfg(test)]
5459mod tests {
5460 use super::*;
5461 use std::collections::BTreeSet;
5462 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
5463
5464 use crate::reconcile::{AllocationLock, InProcessAllocationLock};
5465 use runner_manager_domain::model::{Elapsed, TargetScope};
5466 use runner_manager_domain::store::SqliteStore;
5467 use runner_manager_github::jit::JitRunner;
5468 use runner_manager_testkit::clock::FakeClock;
5469 use runner_manager_testkit::fixtures;
5470
5471 #[test]
5472 fn production_provider_set_routes_every_implemented_isolated_backend() {
5473 let providers = PlatformExecutionProvider::new(HostId::from_u128(1));
5474 assert!(providers.isolated_provider(Backend::Auto).is_some());
5475 assert!(providers.isolated_provider(Backend::Oci).is_some());
5476 assert!(
5477 providers
5478 .isolated_provider(Backend::WindowsHyperVContainer)
5479 .is_some()
5480 );
5481 assert!(
5482 providers
5483 .isolated_provider(Backend::VirtualMachine)
5484 .is_none()
5485 );
5486 }
5487
5488 type CapturedFields = Vec<(String, String)>;
5490
5491 #[derive(Clone, Default)]
5499 struct CapturedEvents(std::sync::Arc<std::sync::Mutex<Vec<CapturedFields>>>);
5500
5501 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CapturedEvents {
5502 fn on_event(
5503 &self,
5504 event: &tracing::Event<'_>,
5505 _context: tracing_subscriber::layer::Context<'_, S>,
5506 ) {
5507 struct Collect(Vec<(String, String)>);
5508 impl tracing::field::Visit for Collect {
5509 fn record_debug(
5510 &mut self,
5511 field: &tracing::field::Field,
5512 value: &dyn std::fmt::Debug,
5513 ) {
5514 self.0.push((
5517 field.name().to_owned(),
5518 format!("{value:?}").trim_matches('"').to_owned(),
5519 ));
5520 }
5521 }
5522 let mut collected = Collect(Vec::new());
5523 event.record(&mut collected);
5524 self.0
5525 .lock()
5526 .expect("the capture mutex is not poisoned")
5527 .push(collected.0);
5528 }
5529 }
5530
5531 #[test]
5549 fn a_launch_the_runner_root_refused_names_the_cause_in_the_log_that_ships() {
5550 use runner_manager_platform::logging;
5551 use tracing_subscriber::layer::SubscriberExt as _;
5552
5553 let captured = CapturedEvents::default();
5554 let error = RunnerRootError::DeniedByPrivacyPolicy {
5555 requested: PathBuf::from("/Volumes/NVME/runners"),
5556 refused: PathBuf::from("/Volumes/NVME"),
5557 remediation: RootOwner::Host.remediation(),
5558 };
5559 let kind = error.kind();
5560
5561 let failure = tracing::subscriber::with_default(
5562 tracing_subscriber::registry().with(captured.clone()),
5563 || root_failure(error),
5564 );
5565
5566 assert!(
5571 matches!(
5572 &failure,
5573 LifecycleError::Failed(FailureReason::Other(detail))
5574 if detail.contains("/Volumes/NVME/runners")
5575 && detail.contains("Full Disk Access")
5576 ),
5577 "the reason must still carry the detail: {failure:?}"
5578 );
5579
5580 let events = captured
5581 .0
5582 .lock()
5583 .expect("the capture mutex is not poisoned")
5584 .clone();
5585 let event = events
5586 .iter()
5587 .find(|fields| fields.iter().any(|(_, value)| value.contains(kind)))
5588 .unwrap_or_else(|| panic!("the refusal did not name its cause: {events:?}"));
5589
5590 for (name, value) in event {
5595 assert!(
5596 logging::is_field_allowed(name),
5597 "`{name}` is not allow-listed, so it ships as `{}`: {event:?}",
5598 logging::REDACTION
5599 );
5600 assert_eq!(
5601 &logging::redact(value),
5602 value,
5603 "`{name}` does not survive value-shape scrubbing: {event:?}"
5604 );
5605 }
5606 }
5607
5608 fn nz(slot: u16) -> NonZeroU16 {
5610 NonZeroU16::new(slot).expect("a positive slot")
5611 }
5612
5613 const JIT: &str = "eyJzZWNyZXQiOiJnaHBfRE9fTk9UX0xFQUsifQ==";
5614
5615 #[derive(Debug, Default)]
5616 struct FakeGithubLifecycle {
5617 registration_failures: Mutex<VecDeque<bool>>,
5618 observations: Mutex<VecDeque<LifecycleGithubObservation>>,
5619 registrations: AtomicUsize,
5620 remaining_runners: AtomicUsize,
5621 deregistrations: Mutex<Vec<u64>>,
5625 deregistration_fails: AtomicBool,
5628 journal: Mutex<Option<Arc<SqliteStore>>>,
5633 registration_facts: Mutex<Vec<RegistrationFact>>,
5635 }
5636
5637 #[derive(Debug, Clone)]
5644 struct RegistrationFact {
5645 leased_slots: Vec<u16>,
5647 work_folder: String,
5649 runner_name: String,
5652 journal_states: Vec<AttemptState>,
5653 }
5654
5655 impl FakeGithubLifecycle {
5656 fn fail(mut self, terminal: bool) -> Self {
5657 self.registration_failures
5658 .get_mut()
5659 .expect("unpoisoned")
5660 .push_back(terminal);
5661 self
5662 }
5663
5664 fn watch_journal(&self, store: Arc<SqliteStore>) {
5665 *self.journal.lock().unwrap() = Some(store);
5666 }
5667
5668 fn registration_facts(&self) -> Vec<RegistrationFact> {
5669 self.registration_facts.lock().unwrap().clone()
5670 }
5671
5672 fn observe(&self, observation: GithubRunnerObservation) {
5673 let observation = match observation {
5674 GithubRunnerObservation::Unreachable => LifecycleGithubObservation::unreachable(),
5675 GithubRunnerObservation::NotRegistered => {
5676 LifecycleGithubObservation::not_registered()
5677 }
5678 GithubRunnerObservation::Registered { busy } => {
5679 LifecycleGithubObservation::registered(73, busy)
5680 }
5681 };
5682 self.observations.lock().unwrap().push_back(observation);
5683 }
5684 }
5685
5686 #[async_trait]
5687 impl LifecycleGithub for FakeGithubLifecycle {
5688 async fn register(
5689 &self,
5690 _target: &ScaleTarget,
5691 request: &JitRunnerRequest,
5692 _cancel: &CancelToken,
5693 ) -> Result<JitRegistration, JitRequestFailure> {
5694 self.registrations.fetch_add(1, Ordering::SeqCst);
5695 if let Some(store) = self.journal.lock().unwrap().as_ref() {
5696 let attempts = store.attempts().expect("the journal is readable");
5697 let slots = attempts
5698 .iter()
5699 .filter_map(|attempt| attempt.workspace().slot_number())
5700 .collect();
5701 self.registration_facts
5702 .lock()
5703 .unwrap()
5704 .push(RegistrationFact {
5705 leased_slots: slots,
5706 work_folder: request.work_folder().to_string(),
5707 runner_name: request.name().to_string(),
5708 journal_states: attempts.iter().map(RunnerAttempt::state).collect(),
5709 });
5710 }
5711 if let Some(terminal) = self.registration_failures.lock().unwrap().pop_front() {
5712 return Err(JitRequestFailure {
5713 terminal,
5714 reason: if terminal {
5715 FailureReason::Other("GitHub refused JIT registration with 403".into())
5716 } else {
5717 FailureReason::JitRequestFailed
5718 },
5719 retry_after: None,
5720 });
5721 }
5722 self.remaining_runners.store(1, Ordering::SeqCst);
5723 Ok(JitRegistration::new(
5724 EncodedJitConfig::new(JIT),
5725 JitRunner {
5726 id: 73,
5727 name: request.name().to_string(),
5728 os: "windows".into(),
5729 status: "offline".into(),
5730 busy: false,
5731 runner_group_id: Some(1),
5732 labels: request.labels().to_vec(),
5733 },
5734 ))
5735 }
5736
5737 async fn observe(
5738 &self,
5739 _target: &ScaleTarget,
5740 _attempt: AttemptId,
5741 _cancel: &CancelToken,
5742 ) -> LifecycleGithubObservation {
5743 let observation = self
5744 .observations
5745 .lock()
5746 .unwrap()
5747 .pop_front()
5748 .unwrap_or(LifecycleGithubObservation::not_registered());
5749 if observation.status == GithubRunnerObservation::NotRegistered {
5750 self.remaining_runners.store(0, Ordering::SeqCst);
5751 }
5752 observation
5753 }
5754
5755 async fn deregister(
5756 &self,
5757 _target: &ScaleTarget,
5758 runner_id: u64,
5759 _cancel: &CancelToken,
5760 ) -> bool {
5761 self.deregistrations.lock().unwrap().push(runner_id);
5762 if self.deregistration_fails.load(Ordering::SeqCst) {
5763 return false;
5764 }
5765 self.remaining_runners.store(0, Ordering::SeqCst);
5766 true
5767 }
5768 }
5769
5770 #[derive(Debug)]
5771 struct FakePackages {
5772 version: RunnerVersion,
5773 leases: Mutex<BTreeSet<AttemptId>>,
5774 materializations: AtomicUsize,
5775 materializing: AtomicUsize,
5776 peak_materializing: AtomicUsize,
5777 materialization_failures: AtomicUsize,
5778 release_failures: AtomicUsize,
5779 releases: AtomicUsize,
5780 prunes: AtomicUsize,
5781 }
5782
5783 impl Default for FakePackages {
5784 fn default() -> Self {
5785 Self {
5786 version: RunnerVersion::parse("2.330.0").unwrap(),
5787 leases: Mutex::new(BTreeSet::new()),
5788 materializations: AtomicUsize::new(0),
5789 materializing: AtomicUsize::new(0),
5790 peak_materializing: AtomicUsize::new(0),
5791 materialization_failures: AtomicUsize::new(0),
5792 release_failures: AtomicUsize::new(0),
5793 releases: AtomicUsize::new(0),
5794 prunes: AtomicUsize::new(0),
5795 }
5796 }
5797 }
5798
5799 impl FakePackages {
5800 fn fail_materializations(&self, count: usize) {
5801 self.materialization_failures.store(count, Ordering::SeqCst);
5802 }
5803
5804 fn fail_releases(&self, count: usize) {
5805 self.release_failures.store(count, Ordering::SeqCst);
5806 }
5807 }
5808
5809 #[async_trait]
5810 impl RuntimePackages for FakePackages {
5811 async fn materialize(
5812 &self,
5813 attempt: &RunnerAttempt,
5814 _timings: &mut PackageTimings,
5815 ) -> Result<RunnerVersion, FailureReason> {
5816 self.materializations.fetch_add(1, Ordering::SeqCst);
5817 let now = self.materializing.fetch_add(1, Ordering::SeqCst) + 1;
5818 self.peak_materializing.fetch_max(now, Ordering::SeqCst);
5819 for _ in 0..5 {
5820 tokio::task::yield_now().await;
5821 }
5822 self.materializing.fetch_sub(1, Ordering::SeqCst);
5823 if self
5824 .materialization_failures
5825 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5826 if left > 0 { Some(left - 1) } else { None }
5827 })
5828 .is_ok()
5829 {
5830 return Err(FailureReason::Other(
5831 "runner package materialization failed transiently".into(),
5832 ));
5833 }
5834 fs::create_dir_all(attempt.runtime_path()).unwrap();
5835 fs::write(attempt.runtime_path().join("runner-package"), b"verified").unwrap();
5836 self.leases.lock().unwrap().insert(attempt.id);
5837 Ok(self.version.clone())
5838 }
5839
5840 fn release(&self, attempt: AttemptId) -> Result<(), FailureReason> {
5841 if self
5842 .release_failures
5843 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5844 if left > 0 { Some(left - 1) } else { None }
5845 })
5846 .is_ok()
5847 {
5848 return Err(FailureReason::Other(
5849 "runner package lease could not be released".into(),
5850 ));
5851 }
5852 self.leases.lock().unwrap().remove(&attempt);
5853 self.releases.fetch_add(1, Ordering::SeqCst);
5854 Ok(())
5855 }
5856
5857 fn prune_obsolete_guarded(
5858 &self,
5859 _authority: PruneAuthority<'_>,
5860 _attempts: &[RunnerAttempt],
5861 ) -> Result<(), FailureReason> {
5862 self.prunes.fetch_add(1, Ordering::SeqCst);
5863 Ok(())
5864 }
5865 }
5866
5867 #[derive(Debug, Default)]
5868 struct FakeProcesses {
5869 alive: AtomicBool,
5870 completed_successfully: AtomicBool,
5871 spawns: AtomicUsize,
5872 spawn_failures: AtomicUsize,
5873 live_spawn_failure: AtomicBool,
5874 terminations: AtomicUsize,
5875 intent: AtomicBool,
5876 intent_failure: AtomicBool,
5877 actions: Mutex<Vec<&'static str>>,
5878 saw_secret: AtomicBool,
5879 }
5880
5881 impl FakeProcesses {
5882 fn fail_spawns(&self, count: usize) {
5883 self.spawn_failures.store(count, Ordering::SeqCst);
5884 }
5885
5886 fn fail_spawn_with_live_child(&self) {
5887 self.live_spawn_failure.store(true, Ordering::SeqCst);
5888 }
5889
5890 fn set_alive(&self, alive: bool) {
5891 self.alive.store(alive, Ordering::SeqCst);
5892 }
5893
5894 fn finish_successfully(&self) {
5895 self.completed_successfully.store(true, Ordering::SeqCst);
5896 self.alive.store(false, Ordering::SeqCst);
5897 }
5898
5899 fn fail_intent(&self) {
5900 self.intent_failure.store(true, Ordering::SeqCst);
5901 }
5902 }
5903
5904 impl ProcessSupervisor for FakeProcesses {
5905 fn spawn(
5906 &self,
5907 attempt: &RunnerAttempt,
5908 config: &EncodedJitConfig,
5909 ) -> Result<u32, ProcessStartFailure> {
5910 self.spawns.fetch_add(1, Ordering::SeqCst);
5911 let handoff = RestrictiveHandoff::create(
5914 attempt.runtime_path(),
5915 SecretString::from(config.expose().to_owned()),
5916 )
5917 .unwrap();
5918 self.saw_secret
5919 .store(config.expose() == JIT, Ordering::SeqCst);
5920 let handoff_path = handoff.path().to_path_buf();
5921 let failing = self
5922 .spawn_failures
5923 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |left| {
5924 if left > 0 { Some(left - 1) } else { None }
5925 })
5926 .is_ok();
5927 drop(handoff);
5928 assert!(!handoff_path.exists(), "handoff must be absent on return");
5929 if self.live_spawn_failure.swap(false, Ordering::SeqCst) {
5930 self.alive.store(true, Ordering::SeqCst);
5931 return Err(ProcessStartFailure::after_spawn_live(4242));
5932 }
5933 if failing {
5934 return Err(ProcessStartFailure::before_spawn(
5935 FailureReason::ProcessStartFailed,
5936 ));
5937 }
5938 self.alive.store(true, Ordering::SeqCst);
5939 Ok(4242)
5940 }
5941
5942 fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
5943 self.actions.lock().unwrap().push("observe_process");
5944 Ok(self.alive.load(Ordering::SeqCst))
5945 }
5946
5947 fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
5948 Ok(self.alive.load(Ordering::SeqCst).then_some(4242))
5949 }
5950
5951 fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
5952 self.completed_successfully.load(Ordering::SeqCst)
5953 }
5954
5955 fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5956 self.actions.lock().unwrap().push("terminate_intent");
5957 if self.intent_failure.load(Ordering::SeqCst) {
5958 return Err(FailureReason::Other(
5959 "terminate intent directory sync failed".into(),
5960 ));
5961 }
5962 self.intent.store(true, Ordering::SeqCst);
5963 Ok(())
5964 }
5965
5966 fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
5967 self.intent.load(Ordering::SeqCst)
5968 }
5969
5970 fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
5971 assert!(
5972 self.intent.load(Ordering::SeqCst),
5973 "the durable intent must exist before signalling"
5974 );
5975 self.actions.lock().unwrap().push("terminate");
5976 self.terminations.fetch_add(1, Ordering::SeqCst);
5977 self.alive.store(false, Ordering::SeqCst);
5978 Ok(())
5979 }
5980 }
5981
5982 #[derive(Debug, Default)]
5983 struct FakeIsolatedProvider {
5984 resources: Mutex<BTreeMap<AttemptId, (EnvironmentIdentity, EnvironmentState)>>,
5985 extra_resources: Mutex<Vec<EnvironmentIdentity>>,
5986 unsupported_host: AtomicBool,
5987 permission_denied: AtomicBool,
5988 incompatible_image: AtomicBool,
5989 prepare_failure: AtomicBool,
5990 recovery_unavailable: AtomicBool,
5991 destroy_deferred: AtomicBool,
5992 handoffs: AtomicUsize,
5993 completed_successfully: AtomicBool,
5994 native_calls: AtomicUsize,
5995 actions: Mutex<Vec<&'static str>>,
5996 }
5997
5998 impl FakeIsolatedProvider {
5999 fn identity(attempt: &RunnerAttempt, host: HostId) -> EnvironmentIdentity {
6000 let AttemptExecution::Isolated {
6001 provider_kind,
6002 resolved_image,
6003 generation,
6004 ..
6005 } = attempt.execution()
6006 else {
6007 panic!("isolated intent required")
6008 };
6009 EnvironmentIdentity::Isolated {
6010 host,
6011 attempt: attempt.id,
6012 provider_kind: *provider_kind,
6013 environment_id: format!("environment-{}", attempt.id),
6014 resolved_image: resolved_image.clone(),
6015 generation: generation.clone(),
6016 }
6017 }
6018
6019 fn resource_count(&self) -> usize {
6020 self.resources.lock().unwrap().len()
6021 }
6022 }
6023
6024 impl ExecutionProvider for FakeIsolatedProvider {
6025 fn probe(&self, policy: &ScalePolicy) -> ProviderCapability {
6026 if self.unsupported_host.load(Ordering::SeqCst) {
6027 ProviderCapability::Unsupported
6028 } else if self.permission_denied.load(Ordering::SeqCst) {
6029 ProviderCapability::PermissionDenied
6030 } else if policy.execution_policy().is_native() {
6031 ProviderCapability::Unsupported
6032 } else {
6033 ProviderCapability::Ready
6034 }
6035 }
6036
6037 fn resolve(
6038 &self,
6039 policy: &ScalePolicy,
6040 ) -> Result<Option<ResolvedEnvironment>, FailureReason> {
6041 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
6042 policy.execution_policy()
6043 else {
6044 return Err(FailureReason::Other("isolated policy required".into()));
6045 };
6046 Ok(Some(ResolvedEnvironment {
6047 provider_kind: Backend::Oci,
6048 image: if self.incompatible_image.load(Ordering::SeqCst) {
6049 ImageReference::new("vm-version:wrong-provider")
6050 .expect("valid but incompatible")
6051 } else {
6052 image.clone()
6053 },
6054 }))
6055 }
6056
6057 fn prepare(
6058 &self,
6059 attempt: &RunnerAttempt,
6060 policy: &ScalePolicy,
6061 ) -> Result<PreparedEnvironment, FailureReason> {
6062 assert_eq!(
6063 attempt.state(),
6064 AttemptState::Preparing,
6065 "intent must be durable before provider effect"
6066 );
6067 self.actions.lock().unwrap().push("prepare");
6068 let identity = Self::identity(attempt, policy.host_id);
6069 self.resources
6070 .lock()
6071 .unwrap()
6072 .insert(attempt.id, (identity.clone(), EnvironmentState::Starting));
6073 if self.prepare_failure.swap(false, Ordering::SeqCst) {
6074 return Err(FailureReason::Other("provider-secret-needle".into()));
6075 }
6076 Ok(PreparedEnvironment::isolated(attempt.id, identity))
6077 }
6078
6079 fn start(
6080 &self,
6081 prepared: PreparedEnvironment,
6082 attempt: &RunnerAttempt,
6083 handoff: OneTimeJitHandoff<'_>,
6084 ) -> Result<EnvironmentIdentity, ProcessStartFailure> {
6085 assert_eq!(prepared.attempt, attempt.id);
6086 assert_eq!(attempt.state(), AttemptState::JitReceived);
6087 assert_eq!(handoff.consume().expose(), JIT);
6088 self.handoffs.fetch_add(1, Ordering::SeqCst);
6089 self.actions.lock().unwrap().push("start");
6090 let mut resources = self.resources.lock().unwrap();
6091 let (identity, state) = resources.get_mut(&attempt.id).expect("prepared resource");
6092 *state = EnvironmentState::Running;
6093 Ok(identity.clone())
6094 }
6095
6096 fn inspect(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
6097 self.actions.lock().unwrap().push("inspect");
6098 Ok(self
6099 .resources
6100 .lock()
6101 .unwrap()
6102 .get(&attempt.id)
6103 .map_or(EnvironmentState::Missing, |(_, state)| *state))
6104 }
6105
6106 fn recover(&self, attempt: &RunnerAttempt) -> Result<EnvironmentState, FailureReason> {
6107 if self.recovery_unavailable.load(Ordering::SeqCst) {
6108 return Err(FailureReason::Other(
6109 "provider discovery unavailable".into(),
6110 ));
6111 }
6112 self.inspect(attempt)
6113 }
6114
6115 fn owns(&self, attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
6116 Ok(self
6117 .resources
6118 .lock()
6119 .unwrap()
6120 .get(&attempt.id)
6121 .is_some_and(|(identity, _)| {
6122 identity_matches_intent(attempt_host(identity), attempt, identity)
6123 }))
6124 }
6125
6126 fn stop(&self, attempt: &RunnerAttempt) -> Result<ProviderStop, FailureReason> {
6127 assert!(self.owns(attempt)?);
6128 self.actions.lock().unwrap().push("stop");
6129 if let Some((_, state)) = self.resources.lock().unwrap().get_mut(&attempt.id) {
6130 *state = EnvironmentState::Exited;
6131 }
6132 Ok(ProviderStop::Stopped)
6133 }
6134
6135 fn destroy(&self, attempt: &RunnerAttempt) -> Result<ProviderDestroy, FailureReason> {
6136 assert!(self.owns(attempt)?);
6137 self.actions.lock().unwrap().push("destroy");
6138 if self.destroy_deferred.load(Ordering::SeqCst) {
6139 return Ok(ProviderDestroy::Deferred("provider busy"));
6140 }
6141 self.resources.lock().unwrap().remove(&attempt.id);
6142 Ok(ProviderDestroy::Destroyed)
6143 }
6144
6145 fn enumerate_owned(&self, host: HostId) -> Vec<EnvironmentIdentity> {
6146 let mut owned = self
6147 .resources
6148 .lock()
6149 .unwrap()
6150 .values()
6151 .filter(|(identity, _)| attempt_host(identity) == host)
6152 .map(|(identity, _)| identity.clone())
6153 .collect::<Vec<_>>();
6154 owned.extend(
6155 self.extra_resources
6156 .lock()
6157 .unwrap()
6158 .iter()
6159 .filter(|identity| attempt_host(identity) == host)
6160 .cloned(),
6161 );
6162 owned
6163 }
6164
6165 fn diagnostics(&self, _attempt: &RunnerAttempt) -> Vec<ProviderDiagnostic> {
6166 vec![ProviderDiagnostic::CleanupDeferred; 40]
6167 }
6168
6169 fn spawn(
6170 &self,
6171 _attempt: &RunnerAttempt,
6172 _config: &EncodedJitConfig,
6173 ) -> Result<u32, ProcessStartFailure> {
6174 self.native_calls.fetch_add(1, Ordering::SeqCst);
6175 panic!("isolated provider must never spawn natively")
6176 }
6177 fn is_alive(&self, _attempt: &RunnerAttempt) -> Result<bool, FailureReason> {
6178 panic!("native fallback")
6179 }
6180 fn recovered_pid(&self, _attempt: &RunnerAttempt) -> Result<Option<u32>, FailureReason> {
6181 panic!("native fallback")
6182 }
6183 fn completed_successfully(&self, _attempt: &RunnerAttempt) -> bool {
6184 self.completed_successfully.load(Ordering::SeqCst)
6185 }
6186 fn record_terminate_intent(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
6187 panic!("native fallback")
6188 }
6189 fn has_terminate_intent(&self, _attempt: &RunnerAttempt) -> bool {
6190 false
6191 }
6192 fn terminate(&self, _attempt: &RunnerAttempt) -> Result<(), FailureReason> {
6193 panic!("native fallback")
6194 }
6195 }
6196
6197 fn attempt_host(identity: &EnvironmentIdentity) -> HostId {
6198 let EnvironmentIdentity::Isolated { host, .. } = identity else {
6199 panic!("isolated identity")
6200 };
6201 *host
6202 }
6203
6204 #[derive(Debug, Default)]
6205 struct FakeDemand {
6206 answers: Mutex<VecDeque<bool>>,
6207 }
6208
6209 impl FakeDemand {
6210 fn answering(answers: impl IntoIterator<Item = bool>) -> Self {
6211 Self {
6212 answers: Mutex::new(answers.into_iter().collect()),
6213 }
6214 }
6215 }
6216
6217 #[async_trait]
6218 impl DemandPersistence for FakeDemand {
6219 async fn persists(&self, _policy: PolicyId) -> bool {
6220 self.answers.lock().unwrap().pop_front().unwrap_or(true)
6221 }
6222 }
6223
6224 #[derive(Debug, Default)]
6225 struct FakeDelay(Mutex<Vec<Duration>>);
6226
6227 #[async_trait]
6228 impl RetryDelay for FakeDelay {
6229 async fn wait(&self, duration: Duration) {
6230 self.0.lock().unwrap().push(duration);
6231 }
6232 }
6233
6234 #[tokio::test]
6235 async fn live_capacity_reads_the_updated_sqlite_host_and_rejects_a_missing_host() {
6236 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
6237 let path = harness._root.path().join("live-capacity.sqlite3");
6238 harness.store = Arc::new(SqliteStore::open(&path).unwrap());
6239 harness.store.put_host(&harness.host).unwrap();
6240 harness.launcher = harness.restart();
6241 assert_eq!(
6242 harness.launcher.host_capacity(&harness.host).await.unwrap(),
6243 harness.host.host_capacity
6244 );
6245 let writer = SqliteStore::open(&path).unwrap();
6248 let mut updated = harness.host.clone();
6249 updated.host_capacity = std::num::NonZeroU16::new(12).unwrap();
6250 writer.put_host(&updated).unwrap();
6251 assert_eq!(
6252 harness
6253 .launcher
6254 .host_capacity(&harness.host)
6255 .await
6256 .unwrap()
6257 .get(),
6258 12
6259 );
6260
6261 updated.host_capacity = std::num::NonZeroU16::new(2).unwrap();
6262 writer.put_host(&updated).unwrap();
6263 assert_eq!(
6264 harness
6265 .launcher
6266 .host_capacity(&harness.host)
6267 .await
6268 .unwrap()
6269 .get(),
6270 2
6271 );
6272
6273 let mut missing = harness.host.clone();
6274 missing.id = HostId::from_u128(999);
6275 assert_eq!(
6276 harness.launcher.host_capacity(&missing).await,
6277 Err(crate::reconcile::HostCapacityError::MissingHost)
6278 );
6279 }
6280
6281 struct Harness {
6282 _root: tempfile::TempDir,
6283 app_paths: runner_manager_platform::paths::AppPaths,
6284 launcher: LifecycleLauncher,
6285 demand: Arc<dyn DemandPersistence>,
6286 store: Arc<SqliteStore>,
6287 github: Arc<FakeGithubLifecycle>,
6288 packages: Arc<FakePackages>,
6289 processes: Arc<FakeProcesses>,
6290 clock: Arc<FakeClock>,
6291 events: Arc<AttemptEventLog>,
6292 reconcile_events: Arc<crate::reconcile::EventLog>,
6293 delay: Arc<FakeDelay>,
6294 host: runner_manager_domain::model::Host,
6295 policy: ScalePolicy,
6296 allocation_lock: InProcessAllocationLock,
6297 workspace_root: Option<LocalAbsolutePath>,
6299 }
6300
6301 impl Harness {
6302 fn new(github: FakeGithubLifecycle, demand: Arc<dyn DemandPersistence>) -> Self {
6303 let root = tempfile::tempdir().unwrap();
6304 let paths = runner_manager_platform::paths::AppPaths::rooted_at(root.path());
6305 paths.create_all().unwrap();
6306 let policy = fixtures::policy()
6307 .repository("octo/repo")
6308 .autoscale("home", 2)
6309 .active()
6310 .build();
6311 let host_root = root.path().join("host-root");
6317 fs::create_dir_all(&host_root).unwrap();
6318 let mut host = fixtures::host().build();
6319 host.runner_root_override = Some(
6320 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
6321 .expect("a local absolute host root"),
6322 );
6323 let store = Arc::new(SqliteStore::open_in_memory().unwrap());
6324 store.put_host(&host).unwrap();
6325 let github = Arc::new(github);
6326 let packages = Arc::new(FakePackages::default());
6327 let processes = Arc::new(FakeProcesses::default());
6328 let clock = Arc::new(FakeClock::default());
6329 let events = Arc::new(AttemptEventLog::default());
6330 let reconcile_events = Arc::new(crate::reconcile::EventLog::new());
6331 let delay = Arc::new(FakeDelay::default());
6332 let ports = LifecyclePorts {
6333 store: Arc::clone(&store) as Arc<dyn Store>,
6334 github: Arc::clone(&github) as Arc<dyn LifecycleGithub>,
6335 packages: Arc::clone(&packages) as Arc<dyn RuntimePackages>,
6336 processes: Arc::clone(&processes) as Arc<dyn ProcessSupervisor>,
6337 clock: Arc::clone(&clock) as Arc<dyn Clock>,
6338 demand: Arc::clone(&demand),
6339 delay: Arc::clone(&delay) as Arc<dyn RetryDelay>,
6340 events: Arc::clone(&events) as Arc<dyn AttemptEventSink>,
6341 reconcile_events: Arc::clone(&reconcile_events) as Arc<dyn EventSink>,
6342 };
6343 let launcher = Self::launcher_over(policy.host_id, &paths, ports);
6344 Self {
6345 _root: root,
6346 app_paths: paths,
6347 launcher,
6348 demand,
6349 store,
6350 github,
6351 packages,
6352 processes,
6353 clock,
6354 events,
6355 reconcile_events,
6356 delay,
6357 host,
6358 policy,
6359 allocation_lock: InProcessAllocationLock::new(),
6360 workspace_root: None,
6361 }
6362 }
6363
6364 fn with_host_runner_root(mut self) -> Self {
6371 let host_root = self.host_root();
6372 fs::create_dir_all(&host_root).unwrap();
6373 self.host.runner_root_override = Some(
6374 LocalAbsolutePath::new(host_root.to_str().expect("a UTF-8 temporary path"))
6375 .expect("a local absolute host root"),
6376 );
6377 self.store.put_host(&self.host).unwrap();
6378 self
6379 }
6380
6381 fn with_persistent_workspace(mut self, capacity: u16) -> Self {
6383 self = self.with_host_runner_root();
6384 let root = self._root.path().join("persist");
6385 let root = LocalAbsolutePath::new(root.to_str().expect("a UTF-8 temporary path"))
6386 .expect("a local absolute workspace root");
6387 self.policy = fixtures::policy()
6388 .repository("octo/repo")
6389 .autoscale("home", capacity)
6390 .active()
6391 .build();
6392 self.policy
6393 .set_workspace_policy(
6394 WorkspacePolicy::persistent(root.clone(), TargetScope::Repository)
6395 .expect("a repository may be persistent"),
6396 )
6397 .expect("a repository may be persistent");
6398 self.workspace_root = Some(root);
6399 self.store.insert_policy(&self.policy).unwrap();
6402 self
6403 }
6404
6405 fn workspace_root(&self) -> &LocalAbsolutePath {
6406 self.workspace_root
6407 .as_ref()
6408 .expect("this harness configured a persistent workspace")
6409 }
6410
6411 fn slot_path(&self, slot: u16) -> PathBuf {
6412 self.workspace_root().as_path().join(format!("s{slot}"))
6413 }
6414
6415 fn host_root(&self) -> PathBuf {
6416 self._root.path().join("host-root")
6417 }
6418
6419 fn attempt(&self, id: AttemptId) -> RunnerAttempt {
6420 self.store
6421 .attempt(id)
6422 .unwrap()
6423 .expect("the attempt is journalled")
6424 }
6425
6426 fn conclude(&self, id: AttemptId) -> RunnerAttempt {
6428 let mut attempt = self.attempt(id);
6429 attempt
6430 .conclude(
6431 AttemptOutcome::failed(FailureReason::ProcessExitedUnexpectedly),
6432 self.clock.now(),
6433 )
6434 .unwrap();
6435 self.store.record_attempt(&attempt).unwrap();
6436 attempt
6437 }
6438
6439 async fn cleanup_retaining_work(&self, id: AttemptId) {
6440 self.conclude(id);
6441 self.launcher
6442 .clean(id)
6443 .await
6444 .expect("the slot is scrubbed and the lease released");
6445 }
6446
6447 fn launcher_over(
6450 host: HostId,
6451 paths: &runner_manager_platform::paths::AppPaths,
6452 ports: LifecyclePorts,
6453 ) -> LifecycleLauncher {
6454 LifecycleLauncher::new(
6455 host,
6456 paths.clone(),
6457 paths.logs_dir(),
6458 1,
6459 RecoveryTimeouts::new(
6460 Elapsed::seconds(10),
6461 Elapsed::seconds(10),
6462 Elapsed::seconds(10),
6463 ),
6464 RetryPolicy::bounded(3, Duration::from_millis(10), Duration::from_millis(25)),
6465 ports,
6466 )
6467 }
6468
6469 fn restart(&self) -> LifecycleLauncher {
6472 Self::launcher_over(
6473 self.policy.host_id,
6474 &self.app_paths,
6475 LifecyclePorts {
6476 store: Arc::clone(&self.store) as Arc<dyn Store>,
6477 github: Arc::clone(&self.github) as Arc<dyn LifecycleGithub>,
6478 packages: Arc::clone(&self.packages) as Arc<dyn RuntimePackages>,
6479 processes: Arc::clone(&self.processes) as Arc<dyn ProcessSupervisor>,
6480 clock: Arc::clone(&self.clock) as Arc<dyn Clock>,
6481 demand: Arc::clone(&self.demand),
6482 delay: Arc::clone(&self.delay) as Arc<dyn RetryDelay>,
6483 events: Arc::clone(&self.events) as Arc<dyn AttemptEventSink>,
6484 reconcile_events: Arc::clone(&self.reconcile_events) as Arc<dyn EventSink>,
6485 },
6486 )
6487 }
6488
6489 async fn ready(&self) {
6490 self.launcher
6491 .recover_startup(std::slice::from_ref(&self.policy))
6492 .await
6493 .unwrap();
6494 }
6495
6496 async fn launch(&self) -> RunnerAttempt {
6497 self.launch_result().await.unwrap()
6498 }
6499
6500 async fn launch_result(&self) -> Result<RunnerAttempt, LaunchFailure> {
6501 self.launch_after_waiting(Duration::ZERO).await
6502 }
6503
6504 async fn launch_after_waiting(
6505 &self,
6506 lock_wait: Duration,
6507 ) -> Result<RunnerAttempt, LaunchFailure> {
6508 let reserved = self.reserve().await?;
6509 self.launcher
6510 .finish_launch(
6511 &self.policy,
6512 reserved,
6513 ReservationWaits {
6514 lock: lock_wait,
6515 order: Duration::from_secs(2),
6516 },
6517 )
6518 .await
6519 }
6520
6521 async fn launch_through(
6523 &self,
6524 launcher: &LifecycleLauncher,
6525 ) -> Result<RunnerAttempt, LaunchFailure> {
6526 let reserved = self.reserve_through(launcher).await?;
6527 launcher
6528 .finish_launch(&self.policy, reserved, ReservationWaits::default())
6529 .await
6530 }
6531
6532 async fn reserve(&self) -> Result<RunnerAttempt, LaunchFailure> {
6533 self.reserve_through(&self.launcher).await
6534 }
6535
6536 async fn reserve_through(
6537 &self,
6538 launcher: &LifecycleLauncher,
6539 ) -> Result<RunnerAttempt, LaunchFailure> {
6540 let guard = self.allocation_lock.acquire().await.unwrap();
6541 launcher
6542 .launch(LaunchRequest {
6543 host: &self.host,
6544 policy: &self.policy,
6545 allocation_guard: &guard,
6546 })
6547 .await
6548 }
6549
6550 fn only_attempt(&self) -> RunnerAttempt {
6551 self.store.attempts().unwrap().into_iter().next().unwrap()
6552 }
6553 }
6554
6555 fn isolated_harness() -> Harness {
6556 use runner_manager_domain::execution::{ExecutionPolicy, ResourceLimits};
6557 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6558 .with_host_runner_root();
6559 let image =
6560 ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
6561 .expect("pinned image");
6562 harness
6563 .policy
6564 .set_execution_policy(ExecutionPolicy::Isolated {
6565 backend: Backend::Oci,
6566 image,
6567 resources: ResourceLimits {
6568 cpu_millis: 1000,
6569 memory_mib: 1024,
6570 disk_mib: 4096,
6571 },
6572 })
6573 .expect("isolated policy");
6574 harness.github.watch_journal(Arc::clone(&harness.store));
6575 harness
6576 }
6577
6578 fn launcher_with_isolated(
6579 harness: &Harness,
6580 provider: Arc<FakeIsolatedProvider>,
6581 ) -> LifecycleLauncher {
6582 Harness::launcher_over(
6583 harness.policy.host_id,
6584 &harness.app_paths,
6585 LifecyclePorts {
6586 store: Arc::clone(&harness.store) as Arc<dyn Store>,
6587 github: Arc::clone(&harness.github) as Arc<dyn LifecycleGithub>,
6588 packages: Arc::clone(&harness.packages) as Arc<dyn RuntimePackages>,
6589 processes: provider as Arc<dyn ExecutionProvider>,
6590 clock: Arc::clone(&harness.clock) as Arc<dyn Clock>,
6591 demand: Arc::clone(&harness.demand),
6592 delay: Arc::clone(&harness.delay) as Arc<dyn RetryDelay>,
6593 events: Arc::clone(&harness.events) as Arc<dyn AttemptEventSink>,
6594 reconcile_events: Arc::clone(&harness.reconcile_events) as Arc<dyn EventSink>,
6595 },
6596 )
6597 }
6598
6599 #[tokio::test]
6600 async fn isolated_policy_and_journal_never_enter_native_provider_or_jit() {
6601 use runner_manager_domain::execution::{
6602 AttemptExecution, Backend, ExecutionPolicy, ImageReference, ResourceLimits,
6603 };
6604
6605 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
6606 .with_host_runner_root();
6607 let image =
6608 ImageReference::new(format!("registry.example/runner@sha256:{}", "a".repeat(64)))
6609 .expect("pinned image");
6610 harness
6611 .policy
6612 .set_execution_policy(ExecutionPolicy::Isolated {
6613 backend: Backend::Oci,
6614 image: image.clone(),
6615 resources: ResourceLimits {
6616 cpu_millis: 1000,
6617 memory_mib: 1024,
6618 disk_mib: 4096,
6619 },
6620 })
6621 .expect("valid isolated profile");
6622
6623 harness.ready().await;
6624 assert_eq!(
6625 harness.processes.probe(&harness.policy),
6626 ProviderCapability::Unsupported
6627 );
6628 let failure = harness
6629 .launch_result()
6630 .await
6631 .expect_err("provider is unavailable");
6632 assert_eq!(
6633 failure.reason,
6634 FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6635 );
6636
6637 let runtime = harness.host_root().join("isolated-owned-environment");
6638 fs::create_dir_all(&runtime).expect("provider resource fixture");
6639 fs::write(runtime.join("owned-marker"), "non-secret").expect("fixture marker");
6640 let mut attempt = RunnerAttempt::allocate(
6641 AttemptId::from_u128(0x123),
6642 harness.policy.id,
6643 runtime.clone(),
6644 harness.clock.now(),
6645 );
6646 attempt
6647 .allocate_execution(AttemptExecution::Isolated {
6648 provider_kind: Backend::Oci,
6649 environment_id: None,
6650 resolved_image: image,
6651 generation: "generation-123".into(),
6652 })
6653 .expect("isolated intent");
6654 attempt
6655 .prepared_environment("environment-123".into())
6656 .expect("prepared identity");
6657 harness
6658 .store
6659 .record_attempt(&attempt)
6660 .expect("durable isolated journal");
6661
6662 harness
6663 .launcher
6664 .recover_startup(std::slice::from_ref(&harness.policy))
6665 .await
6666 .expect("unsupported isolated cleanup is profile-local");
6667 let failure = harness
6668 .launch_result()
6669 .await
6670 .expect_err("provider unavailable for this policy");
6671 assert_eq!(
6672 failure.reason,
6673 FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6674 );
6675 harness
6676 .launcher
6677 .supervise(&harness.policy)
6678 .await
6679 .expect("unavailable attempt is deferred");
6680 assert!(harness.launcher.clean(attempt.id).await.is_err());
6681 assert_eq!(
6682 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6683 AttemptState::CleanupDeferred
6684 );
6685 assert!(matches!(
6686 harness.launcher.recover_startup(&[]).await,
6687 Err(LifecycleError::RecoveryIncomplete)
6688 ));
6689 assert!(
6690 runtime.join("owned-marker").is_file(),
6691 "native cleanup touched the isolated environment"
6692 );
6693 assert!(
6694 harness.processes.actions.lock().unwrap().is_empty(),
6695 "native recovery inspected an isolated identity"
6696 );
6697 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
6698 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6699 assert_eq!(harness.packages.materializations.load(Ordering::SeqCst), 0);
6700 }
6701
6702 #[tokio::test]
6703 async fn isolated_prepare_is_durable_before_jit_and_start_uses_one_handoff() {
6704 let harness = isolated_harness();
6705 let provider = Arc::new(FakeIsolatedProvider::default());
6706 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6707 launcher
6708 .recover_startup(std::slice::from_ref(&harness.policy))
6709 .await
6710 .expect("ready");
6711 let attempt = harness
6712 .launch_through(&launcher)
6713 .await
6714 .expect("isolated launch");
6715 assert_eq!(attempt.state(), AttemptState::Starting);
6716 assert_eq!(provider.resource_count(), 1);
6717 assert_eq!(provider.handoffs.load(Ordering::SeqCst), 1);
6718 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6719 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
6720 let fact = harness
6721 .github
6722 .registration_facts()
6723 .pop()
6724 .expect("JIT registration");
6725 assert_eq!(
6726 fact.journal_states,
6727 vec![AttemptState::Prepared],
6728 "provider readiness preceded GitHub JIT"
6729 );
6730 assert_eq!(
6731 harness
6732 .events
6733 .events()
6734 .into_iter()
6735 .filter_map(|event| match event {
6736 AttemptEvent::State { state, .. } => Some(state),
6737 _ => None,
6738 })
6739 .collect::<Vec<_>>(),
6740 vec![
6741 AttemptState::Allocated,
6742 AttemptState::Preparing,
6743 AttemptState::Prepared,
6744 AttemptState::JitReceived,
6745 AttemptState::Starting
6746 ]
6747 );
6748 let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6749 restarted
6750 .recover_startup(std::slice::from_ref(&harness.policy))
6751 .await
6752 .expect("adopt live sandbox");
6753 assert_eq!(provider.resource_count(), 1);
6754 assert_eq!(
6755 harness.github.registrations.load(Ordering::SeqCst),
6756 1,
6757 "restart cannot issue duplicate JIT"
6758 );
6759 harness
6760 .github
6761 .observe(GithubRunnerObservation::Registered { busy: true });
6762 restarted
6763 .supervise(&harness.policy)
6764 .await
6765 .expect("job observed");
6766 assert_eq!(
6767 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6768 AttemptState::Busy
6769 );
6770 provider
6771 .resources
6772 .lock()
6773 .unwrap()
6774 .get_mut(&attempt.id)
6775 .unwrap()
6776 .1 = EnvironmentState::Exited;
6777 harness
6778 .github
6779 .observe(GithubRunnerObservation::NotRegistered);
6780 restarted
6781 .supervise(&harness.policy)
6782 .await
6783 .expect("terminal cleanup");
6784 assert_eq!(provider.resource_count(), 0);
6785 assert_eq!(
6786 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
6787 AttemptState::Cleaned
6788 );
6789 }
6790
6791 #[tokio::test]
6792 async fn attached_isolated_job_exit_is_recorded_as_completed() {
6793 let harness = isolated_harness();
6794 let provider = Arc::new(FakeIsolatedProvider::default());
6795 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6796 launcher
6797 .recover_startup(std::slice::from_ref(&harness.policy))
6798 .await
6799 .unwrap();
6800 let attempt = harness.launch_through(&launcher).await.unwrap();
6801 harness
6802 .github
6803 .observe(GithubRunnerObservation::Registered { busy: true });
6804 launcher.supervise(&harness.policy).await.unwrap();
6805 provider
6806 .resources
6807 .lock()
6808 .unwrap()
6809 .get_mut(&attempt.id)
6810 .unwrap()
6811 .1 = EnvironmentState::Exited;
6812 provider
6813 .completed_successfully
6814 .store(true, Ordering::SeqCst);
6815 harness
6816 .github
6817 .observe(GithubRunnerObservation::NotRegistered);
6818 launcher.supervise(&harness.policy).await.unwrap();
6819 let cleaned = harness.store.attempt(attempt.id).unwrap().unwrap();
6820 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
6821 assert_eq!(cleaned.state(), AttemptState::Cleaned);
6822 }
6823
6824 #[tokio::test]
6825 async fn isolated_prepare_failure_stays_local_and_crash_gap_recovers_owned_resource() {
6826 let harness = isolated_harness();
6827 let provider = Arc::new(FakeIsolatedProvider::default());
6828 provider.prepare_failure.store(true, Ordering::SeqCst);
6829 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6830 launcher
6831 .recover_startup(std::slice::from_ref(&harness.policy))
6832 .await
6833 .expect("ready");
6834 let failure = harness
6835 .launch_through(&launcher)
6836 .await
6837 .expect_err("prepare failed after creating resource");
6838 assert_eq!(
6839 failure.reason,
6840 FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
6841 );
6842 assert_eq!(provider.resource_count(), 1);
6843 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6844 assert!(
6845 !harness
6846 .store
6847 .dump_text()
6848 .unwrap()
6849 .contains("provider-secret-needle")
6850 );
6851 let id = harness.store.attempts().unwrap()[0].id;
6852 assert_eq!(
6853 harness.store.attempt(id).unwrap().unwrap().outcome(),
6854 Some(&AttemptOutcome::failed(FailureReason::IsolationProvider(
6855 IsolationProviderFailure::RuntimeOperationFailed
6856 )))
6857 );
6858 let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
6859 restarted
6860 .recover_startup(std::slice::from_ref(&harness.policy))
6861 .await
6862 .expect("profile-local recovery");
6863 assert_eq!(
6864 provider.resource_count(),
6865 0,
6866 "the exact owned resource was destroyed once"
6867 );
6868 assert_eq!(
6869 harness.store.attempt(id).unwrap().unwrap().state(),
6870 AttemptState::Cleaned
6871 );
6872 let diagnostic =
6873 fs::read_to_string(restarted.diagnostics_root.join(format!("{id}.log"))).unwrap();
6874 assert!(diagnostic.contains("reason=isolation_runtime_operation_failed"));
6875 assert!(!diagnostic.contains("provider-secret-needle"));
6876 assert_eq!(
6877 provider
6878 .actions
6879 .lock()
6880 .unwrap()
6881 .iter()
6882 .filter(|action| **action == "destroy")
6883 .count(),
6884 1
6885 );
6886 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6887 }
6888
6889 #[test]
6890 fn isolation_capability_and_adapter_failures_keep_closed_categories() {
6891 let cases = [
6892 (
6893 ProviderCapability::NotInstalled,
6894 IsolationProviderFailure::NotInstalled,
6895 ),
6896 (
6897 ProviderCapability::PermissionDenied,
6898 IsolationProviderFailure::PermissionDenied,
6899 ),
6900 (
6901 ProviderCapability::ImageUnavailableOrIncompatible,
6902 IsolationProviderFailure::ImageUnavailableOrIncompatible,
6903 ),
6904 (
6905 ProviderCapability::DiskQuotaUnavailable,
6906 IsolationProviderFailure::DiskQuotaUnavailable,
6907 ),
6908 (
6909 ProviderCapability::Degraded,
6910 IsolationProviderFailure::Degraded,
6911 ),
6912 ];
6913 for (capability, category) in cases {
6914 assert_eq!(
6915 capability.refusal(),
6916 FailureReason::IsolationProvider(category)
6917 );
6918 }
6919 let raw = FailureReason::Other("ghp_secret_from_provider".into());
6920 let safe = safe_isolation_reason(raw, IsolationProviderFailure::RuntimeOperationFailed);
6921 assert_eq!(
6922 safe,
6923 FailureReason::IsolationProvider(IsolationProviderFailure::RuntimeOperationFailed)
6924 );
6925 assert!(!safe.to_string().contains("ghp_secret"));
6926 assert_eq!(
6927 safe_isolation_reason(
6928 FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied),
6929 IsolationProviderFailure::RuntimeOperationFailed,
6930 ),
6931 FailureReason::IsolationProvider(IsolationProviderFailure::PermissionDenied)
6932 );
6933 }
6934
6935 #[tokio::test]
6936 async fn isolated_permission_and_image_failures_stop_before_jit_without_native_fallback() {
6937 for image_failure in [false, true] {
6938 let harness = isolated_harness();
6939 let provider = Arc::new(FakeIsolatedProvider::default());
6940 provider
6941 .permission_denied
6942 .store(!image_failure, Ordering::SeqCst);
6943 provider
6944 .incompatible_image
6945 .store(image_failure, Ordering::SeqCst);
6946 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6947 launcher
6948 .recover_startup(std::slice::from_ref(&harness.policy))
6949 .await
6950 .unwrap();
6951 let error = harness
6952 .launch_through(&launcher)
6953 .await
6954 .expect_err("pre-JIT refusal");
6955 let expected = if image_failure {
6956 "resolved image is incompatible"
6957 } else {
6958 "permission denied"
6959 };
6960 assert!(error.reason.to_string().contains(expected));
6961 assert!(
6962 harness.store.attempts().unwrap().is_empty(),
6963 "no allocation was journalled"
6964 );
6965 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6966 assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6967 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6968 }
6969 }
6970
6971 #[tokio::test]
6972 async fn unsupported_host_stops_before_jit_without_allocation_or_native_fallback() {
6973 let harness = isolated_harness();
6974 let provider = Arc::new(FakeIsolatedProvider::default());
6975 provider.unsupported_host.store(true, Ordering::SeqCst);
6976 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
6977 launcher
6978 .recover_startup(std::slice::from_ref(&harness.policy))
6979 .await
6980 .unwrap();
6981 let error = harness
6982 .launch_through(&launcher)
6983 .await
6984 .expect_err("unsupported host must be refused before JIT");
6985
6986 assert_eq!(
6987 error.reason,
6988 FailureReason::IsolationProvider(IsolationProviderFailure::Unsupported)
6989 );
6990 assert!(harness.store.attempts().unwrap().is_empty());
6991 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
6992 assert_eq!(provider.handoffs.load(Ordering::SeqCst), 0);
6993 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
6994 assert!(provider.actions.lock().unwrap().is_empty());
6995 }
6996
6997 #[tokio::test]
6998 async fn no_journalled_environment_id_waits_for_provider_absence_proof() {
6999 let harness = isolated_harness();
7000 let provider = Arc::new(FakeIsolatedProvider::default());
7001 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7002 let runtime = harness.host_root().join("prepare-without-identity");
7003 fs::create_dir_all(&runtime).unwrap();
7004 let mut attempt = RunnerAttempt::allocate(
7005 AttemptId::from_u128(0x9191),
7006 harness.policy.id,
7007 runtime,
7008 harness.clock.now(),
7009 );
7010 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7011 harness.policy.execution_policy()
7012 else {
7013 unreachable!()
7014 };
7015 attempt
7016 .allocate_execution(AttemptExecution::Isolated {
7017 provider_kind: Backend::Oci,
7018 environment_id: None,
7019 resolved_image: image.clone(),
7020 generation: "prepare-generation".into(),
7021 })
7022 .unwrap();
7023 attempt.begin_prepare(harness.clock.now()).unwrap();
7024 attempt
7025 .conclude(
7026 AttemptOutcome::failed(FailureReason::Other("prepare interrupted".into())),
7027 harness.clock.now(),
7028 )
7029 .unwrap();
7030 harness.store.record_attempt(&attempt).unwrap();
7031
7032 provider.recovery_unavailable.store(true, Ordering::SeqCst);
7033 launcher
7034 .clean(attempt.id)
7035 .await
7036 .expect_err("absence unproven");
7037 let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
7038 assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
7039 assert!(deferred.counts_against_capacity());
7040
7041 provider.recovery_unavailable.store(false, Ordering::SeqCst);
7042 launcher.clean(attempt.id).await.expect("absence proven");
7043 assert_eq!(
7044 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
7045 AttemptState::Cleaned
7046 );
7047 }
7048
7049 #[tokio::test]
7050 async fn isolated_terminal_capacity_waits_for_confirmed_absence_and_generation_matches() {
7051 let harness = isolated_harness();
7052 let provider = Arc::new(FakeIsolatedProvider::default());
7053 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7054 launcher
7055 .recover_startup(std::slice::from_ref(&harness.policy))
7056 .await
7057 .expect("ready");
7058 let mut attempt = harness.launch_through(&launcher).await.expect("launch");
7059 attempt
7060 .conclude(
7061 AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7062 harness.clock.now(),
7063 )
7064 .unwrap();
7065 harness.store.record_attempt(&attempt).unwrap();
7066 provider.destroy_deferred.store(true, Ordering::SeqCst);
7067 launcher
7068 .clean(attempt.id)
7069 .await
7070 .expect_err("provider deferred destroy");
7071 let deferred = harness.store.attempt(attempt.id).unwrap().unwrap();
7072 assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
7073 assert!(deferred.counts_against_capacity());
7074 assert_eq!(provider.resource_count(), 1);
7075 provider.destroy_deferred.store(false, Ordering::SeqCst);
7076 let restarted = launcher_with_isolated(&harness, Arc::clone(&provider));
7077 restarted
7078 .recover_startup(std::slice::from_ref(&harness.policy))
7079 .await
7080 .expect("retry cleanup");
7081 assert_eq!(provider.resource_count(), 0);
7082 assert!(
7083 !harness
7084 .store
7085 .attempt(attempt.id)
7086 .unwrap()
7087 .unwrap()
7088 .counts_against_capacity()
7089 );
7090
7091 let second_id = AttemptId::from_u128(0x9999);
7093 let second_runtime = harness.host_root().join("generation-mismatch");
7094 fs::create_dir_all(&second_runtime).unwrap();
7095 let mut terminal = RunnerAttempt::allocate(
7096 second_id,
7097 harness.policy.id,
7098 second_runtime,
7099 harness.clock.now(),
7100 );
7101 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7102 harness.policy.execution_policy()
7103 else {
7104 unreachable!()
7105 };
7106 terminal
7107 .allocate_execution(AttemptExecution::Isolated {
7108 provider_kind: Backend::Oci,
7109 environment_id: None,
7110 resolved_image: image.clone(),
7111 generation: "expected-generation".into(),
7112 })
7113 .unwrap();
7114 let mut stranger = FakeIsolatedProvider::identity(&terminal, harness.policy.host_id);
7115 let EnvironmentIdentity::Isolated { environment_id, .. } = &stranger else {
7116 unreachable!()
7117 };
7118 terminal.begin_prepare(harness.clock.now()).unwrap();
7119 terminal
7120 .prepared_environment(environment_id.clone())
7121 .unwrap();
7122 terminal.mark_prepared(harness.clock.now()).unwrap();
7123 terminal.jit_received(harness.clock.now()).unwrap();
7124 terminal.started_isolated(harness.clock.now()).unwrap();
7125 terminal
7126 .conclude(
7127 AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7128 harness.clock.now(),
7129 )
7130 .unwrap();
7131 harness.store.record_attempt(&terminal).unwrap();
7132 let EnvironmentIdentity::Isolated { generation, .. } = &mut stranger else {
7133 unreachable!()
7134 };
7135 *generation = "another-generation".into();
7136 provider
7137 .resources
7138 .lock()
7139 .unwrap()
7140 .insert(second_id, (stranger, EnvironmentState::Running));
7141 restarted
7142 .clean(terminal.id)
7143 .await
7144 .expect_err("ownership mismatch quarantined");
7145 assert_eq!(provider.resource_count(), 1);
7146 assert_eq!(
7147 harness.store.attempt(terminal.id).unwrap().unwrap().state(),
7148 AttemptState::CleanupDeferred
7149 );
7150 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7151 }
7152
7153 #[tokio::test]
7154 async fn isolated_restart_at_each_transition_adopts_or_destroys_one_resource() {
7155 for stage in [
7156 AttemptState::Allocated,
7157 AttemptState::Preparing,
7158 AttemptState::Prepared,
7159 AttemptState::JitReceived,
7160 AttemptState::Starting,
7161 AttemptState::Idle,
7162 AttemptState::Busy,
7163 AttemptState::Finished,
7164 AttemptState::Failed,
7165 AttemptState::Orphaned,
7166 AttemptState::Destroying,
7167 AttemptState::CleanupDeferred,
7168 ] {
7169 let harness = isolated_harness();
7170 let provider = Arc::new(FakeIsolatedProvider::default());
7171 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7172 let id = AttemptId::from_u128(0x1000 + stage as u128);
7173 let runtime = harness.host_root().join(format!("phase-{stage}"));
7174 fs::create_dir_all(&runtime).unwrap();
7175 let mut attempt =
7176 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7177 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7178 harness.policy.execution_policy()
7179 else {
7180 unreachable!()
7181 };
7182 attempt
7183 .allocate_execution(AttemptExecution::Isolated {
7184 provider_kind: Backend::Oci,
7185 environment_id: None,
7186 resolved_image: image.clone(),
7187 generation: format!("generation-{stage}"),
7188 })
7189 .unwrap();
7190 if stage != AttemptState::Allocated {
7191 attempt.begin_prepare(harness.clock.now()).unwrap();
7192 }
7193 let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
7194 if stage != AttemptState::Allocated {
7195 let resource_state = if matches!(
7196 stage,
7197 AttemptState::Finished
7198 | AttemptState::Failed
7199 | AttemptState::Orphaned
7200 | AttemptState::Destroying
7201 | AttemptState::CleanupDeferred
7202 ) {
7203 EnvironmentState::Exited
7204 } else {
7205 EnvironmentState::Running
7206 };
7207 provider
7208 .resources
7209 .lock()
7210 .unwrap()
7211 .insert(id, (identity.clone(), resource_state));
7212 }
7213 if !matches!(stage, AttemptState::Allocated | AttemptState::Preparing) {
7214 let EnvironmentIdentity::Isolated { environment_id, .. } = &identity else {
7215 unreachable!()
7216 };
7217 attempt
7218 .prepared_environment(environment_id.clone())
7219 .unwrap();
7220 attempt.mark_prepared(harness.clock.now()).unwrap();
7221 }
7222 if matches!(
7223 stage,
7224 AttemptState::JitReceived
7225 | AttemptState::Starting
7226 | AttemptState::Idle
7227 | AttemptState::Busy
7228 | AttemptState::Finished
7229 | AttemptState::Failed
7230 | AttemptState::Orphaned
7231 | AttemptState::Destroying
7232 | AttemptState::CleanupDeferred
7233 ) {
7234 attempt.jit_received(harness.clock.now()).unwrap();
7235 }
7236 if matches!(
7237 stage,
7238 AttemptState::Starting
7239 | AttemptState::Idle
7240 | AttemptState::Busy
7241 | AttemptState::Finished
7242 | AttemptState::Failed
7243 | AttemptState::Orphaned
7244 | AttemptState::Destroying
7245 | AttemptState::CleanupDeferred
7246 ) {
7247 attempt.started_isolated(harness.clock.now()).unwrap();
7248 }
7249 if matches!(
7250 stage,
7251 AttemptState::Idle | AttemptState::Busy | AttemptState::Finished
7252 ) {
7253 attempt.registered_idle(73, harness.clock.now()).unwrap();
7254 }
7255 if matches!(stage, AttemptState::Busy | AttemptState::Finished) {
7256 attempt.assigned_job(73, harness.clock.now()).unwrap();
7257 }
7258 if matches!(
7259 stage,
7260 AttemptState::Finished
7261 | AttemptState::Failed
7262 | AttemptState::Orphaned
7263 | AttemptState::Destroying
7264 | AttemptState::CleanupDeferred
7265 ) {
7266 let outcome = match stage {
7267 AttemptState::Finished => AttemptOutcome::CompletedJob,
7268 AttemptState::Orphaned => AttemptOutcome::Orphaned,
7269 _ => AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7270 };
7271 attempt.conclude(outcome, harness.clock.now()).unwrap();
7272 }
7273 if matches!(
7274 stage,
7275 AttemptState::Destroying | AttemptState::CleanupDeferred
7276 ) {
7277 attempt.begin_destroy(harness.clock.now()).unwrap();
7278 }
7279 if stage == AttemptState::CleanupDeferred {
7280 attempt.defer_cleanup(harness.clock.now()).unwrap();
7281 }
7282 harness.store.record_attempt(&attempt).unwrap();
7283 launcher
7284 .recover_startup(std::slice::from_ref(&harness.policy))
7285 .await
7286 .unwrap_or_else(|error| panic!("restart from {stage}: {error}"));
7287 let resumed = harness.store.attempt(id).unwrap().unwrap();
7288 if matches!(
7289 stage,
7290 AttemptState::Allocated
7291 | AttemptState::Preparing
7292 | AttemptState::Prepared
7293 | AttemptState::Finished
7294 | AttemptState::Failed
7295 | AttemptState::Orphaned
7296 | AttemptState::Destroying
7297 | AttemptState::CleanupDeferred
7298 ) {
7299 assert_eq!(
7300 resumed.state(),
7301 AttemptState::Cleaned,
7302 "{stage} must retire after restart"
7303 );
7304 } else {
7305 assert!(resumed.counts_against_capacity(), "live {stage} is adopted");
7306 assert_eq!(provider.resource_count(), 1);
7307 provider.resources.lock().unwrap().get_mut(&id).unwrap().1 =
7308 EnvironmentState::Exited;
7309 launcher.supervise(&harness.policy).await.unwrap();
7310 assert_eq!(
7311 harness.store.attempt(id).unwrap().unwrap().state(),
7312 AttemptState::Cleaned
7313 );
7314 }
7315 assert_eq!(
7316 provider.resource_count(),
7317 0,
7318 "{stage} resource cleaned exactly once"
7319 );
7320 let destroyed = provider
7321 .actions
7322 .lock()
7323 .unwrap()
7324 .iter()
7325 .filter(|action| **action == "destroy")
7326 .count();
7327 assert_eq!(
7328 destroyed,
7329 usize::from(stage != AttemptState::Allocated),
7330 "{stage} destroy count"
7331 );
7332 assert_eq!(
7333 harness.github.registrations.load(Ordering::SeqCst),
7334 0,
7335 "restart from {stage} cannot duplicate JIT"
7336 );
7337 assert_eq!(provider.native_calls.load(Ordering::SeqCst), 0);
7338 }
7339 }
7340
7341 #[tokio::test]
7342 async fn duplicate_provider_ownership_is_quarantined_until_unambiguous() {
7343 let harness = isolated_harness();
7344 let provider = Arc::new(FakeIsolatedProvider::default());
7345 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7346 let id = AttemptId::from_u128(0x7777);
7347 let runtime = harness.host_root().join("duplicate-provider-ownership");
7348 fs::create_dir_all(&runtime).unwrap();
7349 let mut attempt =
7350 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7351 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7352 harness.policy.execution_policy()
7353 else {
7354 unreachable!()
7355 };
7356 attempt
7357 .allocate_execution(AttemptExecution::Isolated {
7358 provider_kind: Backend::Oci,
7359 environment_id: None,
7360 resolved_image: image.clone(),
7361 generation: "duplicate-generation".into(),
7362 })
7363 .unwrap();
7364 attempt.begin_prepare(harness.clock.now()).unwrap();
7365 harness.store.record_attempt(&attempt).unwrap();
7366 let first = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
7367 let mut second = first.clone();
7368 let EnvironmentIdentity::Isolated { environment_id, .. } = &mut second else {
7369 unreachable!()
7370 };
7371 *environment_id = "another-environment".into();
7372 provider
7373 .resources
7374 .lock()
7375 .unwrap()
7376 .insert(id, (first, EnvironmentState::Running));
7377 provider.extra_resources.lock().unwrap().push(second);
7378 launcher
7379 .recover_startup(std::slice::from_ref(&harness.policy))
7380 .await
7381 .expect("duplicate is local");
7382 let deferred = harness.store.attempt(id).unwrap().unwrap();
7383 assert_eq!(deferred.state(), AttemptState::CleanupDeferred);
7384 assert!(deferred.counts_against_capacity());
7385 assert_eq!(provider.resource_count(), 1);
7386 assert_eq!(
7387 provider
7388 .actions
7389 .lock()
7390 .unwrap()
7391 .iter()
7392 .filter(|action| **action == "destroy")
7393 .count(),
7394 0
7395 );
7396 provider.extra_resources.lock().unwrap().clear();
7397 harness.clock.advance_secs(31);
7399 launcher
7400 .recover_startup(std::slice::from_ref(&harness.policy))
7401 .await
7402 .expect("single identity recovered");
7403 assert_eq!(provider.resource_count(), 0);
7404 assert_eq!(
7405 harness.store.attempt(id).unwrap().unwrap().state(),
7406 AttemptState::Cleaned
7407 );
7408 }
7409
7410 #[tokio::test]
7411 async fn provider_resource_without_live_journal_is_surfaced_but_never_deleted() {
7412 let harness = isolated_harness();
7413 let provider = Arc::new(FakeIsolatedProvider::default());
7414 let launcher = launcher_with_isolated(&harness, Arc::clone(&provider));
7415 let id = AttemptId::from_u128(0x8888);
7416 let runtime = harness.host_root().join("orphan-provider-resource");
7417 fs::create_dir_all(&runtime).unwrap();
7418 let mut attempt =
7419 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
7420 let runner_manager_domain::execution::ExecutionPolicy::Isolated { image, .. } =
7421 harness.policy.execution_policy()
7422 else {
7423 unreachable!()
7424 };
7425 attempt
7426 .allocate_execution(AttemptExecution::Isolated {
7427 provider_kind: Backend::Oci,
7428 environment_id: None,
7429 resolved_image: image.clone(),
7430 generation: "orphan-generation".into(),
7431 })
7432 .unwrap();
7433 let identity = FakeIsolatedProvider::identity(&attempt, harness.policy.host_id);
7434 provider
7435 .resources
7436 .lock()
7437 .unwrap()
7438 .insert(id, (identity, EnvironmentState::Running));
7439 launcher
7440 .recover_startup(std::slice::from_ref(&harness.policy))
7441 .await
7442 .expect("orphan cannot block host");
7443 assert_eq!(provider.resource_count(), 1);
7444 assert!(
7445 harness
7446 .events
7447 .events()
7448 .contains(&AttemptEvent::QuarantinedOwnedResource { attempt: id })
7449 );
7450 assert_eq!(
7451 provider
7452 .actions
7453 .lock()
7454 .unwrap()
7455 .iter()
7456 .filter(|action| **action == "destroy")
7457 .count(),
7458 0
7459 );
7460
7461 let EnvironmentIdentity::Isolated { environment_id, .. } = provider
7462 .resources
7463 .lock()
7464 .unwrap()
7465 .get(&id)
7466 .unwrap()
7467 .0
7468 .clone()
7469 else {
7470 unreachable!()
7471 };
7472 attempt.begin_prepare(harness.clock.now()).unwrap();
7473 attempt.prepared_environment(environment_id).unwrap();
7474 attempt.mark_prepared(harness.clock.now()).unwrap();
7475 attempt.jit_received(harness.clock.now()).unwrap();
7476 attempt.started_isolated(harness.clock.now()).unwrap();
7477 attempt
7478 .conclude(
7479 AttemptOutcome::failed(FailureReason::ProcessStartFailed),
7480 harness.clock.now(),
7481 )
7482 .unwrap();
7483 attempt.begin_destroy(harness.clock.now()).unwrap();
7484 attempt.clean_isolated(harness.clock.now()).unwrap();
7485 harness.store.record_attempt(&attempt).unwrap();
7486 launcher
7487 .recover_startup(std::slice::from_ref(&harness.policy))
7488 .await
7489 .expect("cleaned row cannot authorize deletion");
7490 assert_eq!(provider.resource_count(), 1);
7491 assert_eq!(
7492 provider
7493 .actions
7494 .lock()
7495 .unwrap()
7496 .iter()
7497 .filter(|action| **action == "destroy")
7498 .count(),
7499 0
7500 );
7501 }
7502
7503 #[tokio::test]
7512 async fn a_root_that_refuses_a_launch_is_recorded_and_cleared_when_one_succeeds() {
7513 use runner_manager_platform::service::{clear_runner_root_refusal, runner_root_refusals};
7514
7515 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7516 .with_host_runner_root();
7517 harness.ready().await;
7518
7519 let unusable = harness
7523 ._root
7524 .path()
7525 .join("absent")
7526 .join("deeper")
7527 .join("runners");
7528 let mut host = harness.host.clone();
7529 host.runner_root_override = Some(
7530 LocalAbsolutePath::new(unusable.to_str().expect("a UTF-8 temporary path"))
7531 .expect("a local absolute host root"),
7532 );
7533 harness.store.put_host(&host).unwrap();
7534
7535 let failure = harness
7536 .launch_result()
7537 .await
7538 .expect_err("a root whose parents are missing cannot hold a runner");
7539 assert!(
7540 matches!(failure.reason, FailureReason::Other(_)),
7541 "{failure:?}"
7542 );
7543
7544 let refusals = runner_root_refusals(&harness.app_paths).expect("readable");
7545 let refusal = refusals
7546 .first()
7547 .expect("the refusal reached the one surface that can hold it");
7548 assert_eq!(refusal.policy, harness.policy.id.to_string());
7549 assert_eq!(refusal.kind, "missing_parents");
7550 assert!(
7551 refusal.root.contains("runners") && refusal.detail.contains("runners"),
7552 "the directory must be named in full: {refusal:?}"
7553 );
7554
7555 harness.store.put_host(&harness.host).unwrap();
7558 harness.launch().await;
7559 assert!(
7560 runner_root_refusals(&harness.app_paths)
7561 .expect("readable")
7562 .is_empty(),
7563 "a successful placement clears that policy's record"
7564 );
7565
7566 clear_runner_root_refusal(&harness.app_paths, &harness.policy.id.to_string())
7567 .expect("cleanup");
7568 }
7569
7570 #[tokio::test]
7571 async fn a_launch_reports_how_long_each_step_took() {
7572 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7573 harness.ready().await;
7574 let started = harness.launch().await;
7575 let events = harness.events.events();
7576 let launched = events
7577 .iter()
7578 .position(|event| matches!(event, AttemptEvent::Launched { attempt, .. } if *attempt == started.id))
7579 .expect("a successful launch reports its step timings");
7580 let starting = events
7581 .iter()
7582 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == started.id))
7583 .unwrap();
7584 assert!(starting < launched, "timings follow the start: {events:?}");
7585 }
7586
7587 #[tokio::test]
7588 async fn a_failed_launch_still_reports_its_step_timings() {
7589 let harness = Harness::new(
7590 FakeGithubLifecycle::default().fail(true),
7591 Arc::new(PersistentDemand),
7592 );
7593 harness.ready().await;
7594 let failed = harness.launch_after_waiting(Duration::from_secs(90)).await;
7595 assert!(failed.is_err());
7596
7597 let timings = harness
7598 .events
7599 .events()
7600 .into_iter()
7601 .find_map(|event| match event {
7602 AttemptEvent::Launched {
7603 started: false,
7604 timings,
7605 ..
7606 } => Some(timings),
7607 _ => None,
7608 })
7609 .expect("a launch that failed at registration reports its timings");
7610 assert_eq!(timings.lock_wait, Duration::from_secs(90));
7611 assert_eq!(timings.order_wait, Duration::from_secs(2));
7612 assert_eq!(timings.spawn, Duration::ZERO, "spawn was never reached");
7613 assert!(timings.total() >= Duration::from_secs(92));
7614 }
7615
7616 #[cfg(target_os = "macos")]
7617 #[tokio::test]
7618 async fn a_launch_removes_the_seed_of_a_root_no_longer_in_use() {
7619 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7620 harness.ready().await;
7621
7622 let old_root = harness._root.path().join("old-root");
7624 let old_runtime = old_root.join("abandoned");
7625 fs::create_dir_all(old_root.join(PACKAGE_SEED_DIR).join("2.335.0")).unwrap();
7626 fs::create_dir_all(&old_runtime).unwrap();
7627 let old = RunnerAttempt::allocate(
7628 AttemptId::new_random(),
7629 harness.policy.id,
7630 &old_runtime,
7631 harness.clock.now(),
7632 );
7633 harness.store.record_attempt(&old).unwrap();
7634 let kept = harness.host_root().join(PACKAGE_SEED_DIR);
7635 fs::create_dir_all(kept.join("2.336.0")).unwrap();
7636
7637 harness.launch().await;
7638
7639 assert!(
7640 !old_root.join(PACKAGE_SEED_DIR).exists(),
7641 "the abandoned root's seed is removed"
7642 );
7643 assert!(old_runtime.exists(), "only the seed is touched");
7644 assert!(kept.exists(), "the host root's own seed is kept");
7645 }
7646
7647 #[tokio::test]
7648 async fn a_job_walks_every_state_and_cleans_every_artifact() {
7649 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7650 harness.ready().await;
7651 let started = harness.launch().await;
7652 assert_eq!(started.state(), AttemptState::Starting);
7653 assert_eq!(read_runner_id(started.runtime_path()), Some(73));
7654
7655 harness
7656 .github
7657 .observe(GithubRunnerObservation::Registered { busy: false });
7658 harness.launcher.supervise(&harness.policy).await.unwrap();
7659 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
7660
7661 harness
7662 .github
7663 .observe(GithubRunnerObservation::Registered { busy: true });
7664 harness.launcher.supervise(&harness.policy).await.unwrap();
7665 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
7666
7667 harness.processes.finish_successfully();
7668 harness
7669 .github
7670 .observe(GithubRunnerObservation::NotRegistered);
7671 harness.launcher.supervise(&harness.policy).await.unwrap();
7672 let cleaned = harness.only_attempt();
7673 assert_eq!(cleaned.state(), AttemptState::Cleaned);
7674 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
7675 assert!(!started.runtime_path().exists());
7676 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7677 assert_eq!(harness.github.remaining_runners.load(Ordering::SeqCst), 0);
7678
7679 let states: Vec<_> = harness
7680 .events
7681 .events()
7682 .into_iter()
7683 .filter_map(|event| match event {
7684 AttemptEvent::State { state, .. } => Some(state),
7685 _ => None,
7686 })
7687 .collect();
7688 assert_eq!(
7689 states,
7690 vec![
7691 AttemptState::Allocated,
7692 AttemptState::JitReceived,
7693 AttemptState::Starting,
7694 AttemptState::Idle,
7695 AttemptState::Busy,
7696 AttemptState::Finished,
7697 AttemptState::Cleaned,
7698 ]
7699 );
7700 }
7701
7702 #[tokio::test]
7703 async fn a_cleaned_ephemeral_attempt_reaps_a_directory_recreated_after_cleanup() {
7704 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7705 harness.ready().await;
7706 let started = harness.launch().await;
7707 let runtime = started.runtime_path().to_path_buf();
7708
7709 harness
7710 .github
7711 .observe(GithubRunnerObservation::Registered { busy: true });
7712 harness.launcher.supervise(&harness.policy).await.unwrap();
7713 harness.processes.finish_successfully();
7714 harness
7715 .github
7716 .observe(GithubRunnerObservation::NotRegistered);
7717 harness.launcher.supervise(&harness.policy).await.unwrap();
7718
7719 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7720 assert!(!runtime.exists());
7721 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7722
7723 let residue = runtime.join("_work").join("late-node-process");
7727 fs::create_dir_all(&residue).unwrap();
7728 fs::write(residue.join("node_modules.lock"), b"late residue").unwrap();
7729
7730 harness.launcher.supervise(&harness.policy).await.unwrap();
7731
7732 assert!(!runtime.exists(), "late ephemeral residue is reaped");
7733 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7734 assert_eq!(
7735 harness.packages.releases.load(Ordering::SeqCst),
7736 1,
7737 "reaping residue does not release the package lease twice"
7738 );
7739 }
7740
7741 #[tokio::test]
7742 async fn locked_late_residue_never_blocks_startup_recovery() {
7743 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7744 harness.ready().await;
7745 let started = harness.launch().await;
7746 let runtime = started.runtime_path().to_path_buf();
7747
7748 harness
7749 .github
7750 .observe(GithubRunnerObservation::Registered { busy: true });
7751 harness.launcher.supervise(&harness.policy).await.unwrap();
7752 harness.processes.finish_successfully();
7753 harness
7754 .github
7755 .observe(GithubRunnerObservation::NotRegistered);
7756 harness.launcher.supervise(&harness.policy).await.unwrap();
7757 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7758
7759 let held = runtime.join("_work").join("late-node-process");
7760 fs::create_dir_all(&held).unwrap();
7761 fs::write(held.join("node_modules.lock"), b"late residue").unwrap();
7762 let Some(block) = BlockedDeletion::inject(&held) else {
7763 eprintln!(
7764 "skipped: this account cannot be refused a deletion, so locked late residue cannot be injected"
7765 );
7766 return;
7767 };
7768
7769 let restarted = harness.restart();
7770 restarted
7771 .recover_startup(std::slice::from_ref(&harness.policy))
7772 .await
7773 .expect("late residue cannot take the daemon offline");
7774 assert!(runtime.exists(), "the locked residue remains for a retry");
7775 assert!(
7776 harness
7777 .reconcile_events
7778 .events()
7779 .iter()
7780 .any(|event| matches!(
7781 event,
7782 LifecycleEvent::AttemptCleanFailed {
7783 attempt,
7784 reason: "late_ephemeral_workspace_could_not_be_removed",
7785 ..
7786 } if *attempt == started.id
7787 )),
7788 "the non-blocking cleanup failure remains visible"
7789 );
7790
7791 block.release();
7792 harness.clock.advance_secs(31);
7794 restarted
7795 .supervise(&harness.policy)
7796 .await
7797 .expect("the ordinary retry succeeds after the lock is released");
7798 assert!(!runtime.exists(), "the retry removes the late residue");
7799 assert_eq!(harness.attempt(started.id).state(), AttemptState::Cleaned);
7800 assert_eq!(
7801 harness.packages.releases.load(Ordering::SeqCst),
7802 1,
7803 "retrying residue never releases the package lease twice"
7804 );
7805 }
7806
7807 #[tokio::test]
7808 async fn mixed_locked_cleanup_residue_isolated_from_the_whole_startup_pass() {
7809 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7810 harness.ready().await;
7811
7812 let terminal = harness.launch().await;
7816 let terminal_runtime = terminal.runtime_path().to_path_buf();
7817 harness.conclude(terminal.id);
7818 let Some(terminal_block) = BlockedDeletion::inject(&terminal_runtime.join("late-child"))
7819 else {
7820 eprintln!("skipped: this account cannot inject the two independent deletion refusals");
7821 return;
7822 };
7823
7824 let cleaned = harness.launch().await;
7828 let cleaned_runtime = cleaned.runtime_path().to_path_buf();
7829 harness.conclude(cleaned.id);
7830 harness
7831 .launcher
7832 .clean(cleaned.id)
7833 .await
7834 .expect("the second attempt initially cleans");
7835 let Some(cleaned_block) =
7836 BlockedDeletion::inject(&cleaned_runtime.join("_work").join("late-child"))
7837 else {
7838 terminal_block.release();
7839 eprintln!("skipped: this account cannot inject the two independent deletion refusals");
7840 return;
7841 };
7842
7843 let restarted = harness.restart();
7844 for _ in 0..2 {
7845 restarted
7846 .recover_startup(std::slice::from_ref(&harness.policy))
7847 .await
7848 .expect("repeated recovery remains ready while both residues are locked");
7849 }
7850 assert_ne!(
7851 harness.attempt(terminal.id).state(),
7852 AttemptState::Cleaned,
7853 "the journal does not claim the terminal workspace was removed"
7854 );
7855 assert_eq!(
7856 harness.attempt(cleaned.id).state(),
7857 AttemptState::Cleaned,
7858 "late residue does not undo an already durable cleaned transition"
7859 );
7860 assert!(terminal_runtime.exists());
7861 assert!(cleaned_runtime.exists());
7862 assert_eq!(
7863 harness.packages.releases.load(Ordering::SeqCst),
7864 1,
7865 "repeated recovery neither loses nor duplicates package leases"
7866 );
7867
7868 terminal_block.release();
7869 cleaned_block.release();
7870 harness.clock.advance_secs(31);
7872 restarted
7873 .supervise(&harness.policy)
7874 .await
7875 .expect("one ordinary pass clears both residues after their locks disappear");
7876 assert!(!terminal_runtime.exists());
7877 assert!(!cleaned_runtime.exists());
7878 assert_eq!(harness.attempt(terminal.id).state(), AttemptState::Cleaned);
7879 assert_eq!(harness.attempt(cleaned.id).state(), AttemptState::Cleaned);
7880 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 2);
7881 }
7882
7883 #[tokio::test]
7884 async fn a_terminal_attempt_whose_runtime_is_already_gone_recovers_cleanly() {
7885 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7886 harness.ready().await;
7887 let attempt = harness.launch().await;
7888 let runtime = attempt.runtime_path().to_path_buf();
7889 harness.conclude(attempt.id);
7890 fs::remove_dir_all(&runtime).unwrap();
7891
7892 harness
7893 .restart()
7894 .recover_startup(std::slice::from_ref(&harness.policy))
7895 .await
7896 .expect("an already absent disposable runtime is successful cleanup");
7897
7898 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7899 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7900 }
7901
7902 #[tokio::test]
7903 async fn a_non_workspace_cleanup_failure_still_fails_closed_and_can_retry() {
7904 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7905 harness.ready().await;
7906 let attempt = harness.launch().await;
7907 let runtime = attempt.runtime_path().to_path_buf();
7908 harness.conclude(attempt.id);
7909 harness.packages.fail_releases(1);
7910
7911 let restarted = harness.restart();
7912 let failure = restarted
7913 .recover_startup(std::slice::from_ref(&harness.policy))
7914 .await
7915 .expect_err("a package-accounting failure is not safe to downgrade to residue");
7916 assert!(
7917 failure
7918 .reason()
7919 .to_string()
7920 .contains("package lease could not be released")
7921 );
7922 assert!(
7923 !runtime.exists(),
7924 "workspace removal completed before release failed"
7925 );
7926 assert_ne!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7927
7928 restarted
7929 .recover_startup(std::slice::from_ref(&harness.policy))
7930 .await
7931 .expect("the same journal entry retries safely after the transient failure");
7932 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
7933 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7934 }
7935
7936 #[tokio::test]
7937 async fn startup_never_scrubs_a_cleaned_persistent_work_directory() {
7938 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
7939 .with_persistent_workspace(1);
7940 harness.ready().await;
7941 let attempt = harness.launch().await;
7942 let retained = attempt.runtime_path().join("_work").join("checkout-marker");
7943 fs::create_dir_all(retained.parent().unwrap()).unwrap();
7944 fs::write(&retained, b"persistent checkout").unwrap();
7945 harness.cleanup_retaining_work(attempt.id).await;
7946
7947 harness
7948 .restart()
7949 .recover_startup(std::slice::from_ref(&harness.policy))
7950 .await
7951 .expect("a cleaned persistent slot is already reconciled");
7952
7953 assert_eq!(fs::read(&retained).unwrap(), b"persistent checkout");
7954 assert_eq!(harness.packages.releases.load(Ordering::SeqCst), 1);
7955 }
7956
7957 #[tokio::test]
7958 async fn an_idle_exit_is_not_a_failure_in_the_journal_or_events() {
7959 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
7960 harness.ready().await;
7961 let started = harness.launch().await;
7962 harness
7963 .github
7964 .observe(GithubRunnerObservation::Registered { busy: false });
7965 harness.launcher.supervise(&harness.policy).await.unwrap();
7966 harness.clock.advance_secs(11);
7967 harness.processes.set_alive(false);
7968 harness
7969 .github
7970 .observe(GithubRunnerObservation::NotRegistered);
7971 harness.launcher.supervise(&harness.policy).await.unwrap();
7972
7973 let cleaned = harness.only_attempt();
7974 assert!(cleaned.outcome().unwrap().is_idle_exit());
7975 assert!(!cleaned.outcome().unwrap().is_failure());
7976 assert!(!started.runtime_path().exists());
7977 assert!(
7978 harness
7979 .reconcile_events
7980 .events()
7981 .iter()
7982 .any(|event| matches!(
7983 event,
7984 LifecycleEvent::AttemptCleaned {
7985 outcome: OutcomeKind::IdleExit,
7986 ..
7987 }
7988 ))
7989 );
7990 assert!(!harness.events.events().iter().any(|event| matches!(
7991 event,
7992 AttemptEvent::Concluded {
7993 outcome: OutcomeKind::Failed,
7994 ..
7995 }
7996 )));
7997 }
7998
7999 #[tokio::test]
8000 async fn handoff_is_absent_after_success_and_every_failed_spawn_retry() {
8001 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8002 harness.processes.fail_spawns(2);
8003 harness.ready().await;
8004 let attempt = harness.launch().await;
8005 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 3);
8006 assert!(harness.processes.saw_secret.load(Ordering::SeqCst));
8007 let names: Vec<_> = fs::read_dir(attempt.runtime_path())
8008 .unwrap()
8009 .map(|entry| entry.unwrap().file_name())
8010 .collect();
8011 assert!(
8012 names.iter().all(|name| {
8013 !name
8014 .to_string_lossy()
8015 .starts_with(RestrictiveHandoff::NAME_PREFIX)
8016 }),
8017 "JIT artifact survived: {names:?}"
8018 );
8019 assert_eq!(
8020 *harness.delay.0.lock().unwrap(),
8021 vec![Duration::from_millis(10), Duration::from_millis(20)]
8022 );
8023 }
8024
8025 #[tokio::test]
8026 async fn jit_retry_stops_with_demand_and_a_terminal_403_never_retries() {
8027 let gone = Harness::new(
8028 FakeGithubLifecycle::default().fail(false),
8029 Arc::new(FakeDemand::answering([false])),
8030 );
8031 gone.ready().await;
8032 assert!(gone.launch_result().await.is_err());
8033 assert_eq!(gone.github.registrations.load(Ordering::SeqCst), 1);
8034 assert!(gone.delay.0.lock().unwrap().is_empty());
8035
8036 let forbidden = Harness::new(
8037 FakeGithubLifecycle::default().fail(true),
8038 Arc::new(PersistentDemand),
8039 );
8040 forbidden.ready().await;
8041 assert!(forbidden.launch_result().await.is_err());
8042 assert_eq!(forbidden.github.registrations.load(Ordering::SeqCst), 1);
8043 assert!(forbidden.delay.0.lock().unwrap().is_empty());
8044 assert!(matches!(
8045 forbidden.only_attempt().outcome(),
8046 Some(AttemptOutcome::Failed {
8047 reason: FailureReason::Other(action)
8048 }) if action.contains("403")
8049 ));
8050
8051 let transient = Harness::new(
8052 FakeGithubLifecycle::default().fail(false).fail(false),
8053 Arc::new(PersistentDemand),
8054 );
8055 transient.ready().await;
8056 transient.launch().await;
8057 assert_eq!(transient.github.registrations.load(Ordering::SeqCst), 3);
8058 assert_eq!(
8059 *transient.delay.0.lock().unwrap(),
8060 vec![Duration::from_millis(10), Duration::from_millis(20)]
8061 );
8062 }
8063
8064 #[test]
8072 fn a_workspace_leaves_room_for_the_deepest_path_a_checkout_writes() {
8073 const MAX_PATH: usize = 260;
8074 let root = r"C:\Users\IvanD\AppData\Local\IvanMurzak\runner-manager\data\runtime";
8076 let repo = "GitHub-Runner-Scaler-UI";
8080 let deepest = format!(
8081 r"_work\{repo}\{repo}\.git\objects\pack\pack-{}.keep",
8082 "0".repeat(40)
8083 );
8084
8085 let name = workspace_name(AttemptId::new_random());
8086 assert_eq!(name.len(), WORKSPACE_NAME_LEN, "{name}");
8087 assert!(
8088 name.chars().all(|c| c.is_ascii_hexdigit()),
8089 "a directory name must not carry the identifier's dashes: {name}"
8090 );
8091
8092 let full = format!(r"{root}\{name}\{deepest}");
8093 assert!(
8094 full.len() < MAX_PATH,
8095 "the deepest path a checkout writes must fit: {} characters, limit {MAX_PATH}",
8096 full.len()
8097 );
8098
8099 let old = format!(
8102 r"{root}\{}\{}\{deepest}",
8103 PolicyId::new_random(),
8104 AttemptId::new_random()
8105 );
8106 assert!(
8107 old.len() > MAX_PATH,
8108 "the old layout is supposed to be the thing that did not fit: {} characters",
8109 old.len()
8110 );
8111 }
8112
8113 #[tokio::test]
8114 async fn two_attempts_never_share_a_workspace_even_after_failure() {
8115 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8116 harness.ready().await;
8117 let first = harness.launch().await;
8118 fs::write(first.runtime_path().join("hostile-leftover"), b"first job").unwrap();
8119 harness
8120 .github
8121 .observe(GithubRunnerObservation::Registered { busy: false });
8122 harness.launcher.supervise(&harness.policy).await.unwrap();
8123 harness.clock.advance_secs(11);
8124 harness.processes.set_alive(false);
8125 harness
8126 .github
8127 .observe(GithubRunnerObservation::NotRegistered);
8128 harness.launcher.supervise(&harness.policy).await.unwrap();
8129 assert!(!first.runtime_path().exists());
8130
8131 let second = harness.launch().await;
8132 assert_ne!(first.runtime_path(), second.runtime_path());
8133 assert!(!second.runtime_path().join("hostile-leftover").exists());
8134
8135 fs::write(
8136 second.runtime_path().join("hostile-on-failure"),
8137 b"second job",
8138 )
8139 .unwrap();
8140 harness.processes.set_alive(false);
8141 harness
8142 .github
8143 .observe(GithubRunnerObservation::NotRegistered);
8144 harness.launcher.supervise(&harness.policy).await.unwrap();
8145 assert!(
8146 !second.runtime_path().exists(),
8147 "failed workspace was retained"
8148 );
8149 }
8150
8151 #[tokio::test]
8152 async fn a_runner_that_never_gets_a_job_is_stopped_deregistered_and_not_replaced() {
8153 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8154 harness.ready().await;
8155 let attempt = harness.launch().await;
8156
8157 harness
8161 .github
8162 .observe(GithubRunnerObservation::Registered { busy: false });
8163 harness.launcher.supervise(&harness.policy).await.unwrap();
8164 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8165
8166 harness.clock.advance_secs(9);
8169 harness
8170 .github
8171 .observe(GithubRunnerObservation::Registered { busy: false });
8172 let none_yet = harness.launcher.supervise(&harness.policy).await.unwrap();
8173 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8174 assert!(none_yet.is_empty());
8175 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
8176
8177 harness.clock.advance_secs(1);
8179 harness
8180 .github
8181 .observe(GithubRunnerObservation::Registered { busy: false });
8182 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8183
8184 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
8185 assert_eq!(
8186 concluded.outcome(),
8187 Some(&AttemptOutcome::ExitedIdleWithoutWork),
8188 "a surplus runner did not fail; recording one as a failure sends an operator \
8189 hunting a fault that does not exist"
8190 );
8191 assert_eq!(concluded.state(), AttemptState::Cleaned);
8192 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
8193 assert!(!attempt.runtime_path().exists());
8194
8195 assert_eq!(
8198 *harness.github.deregistrations.lock().unwrap(),
8199 vec![73],
8200 "the attempt's own runner id, deleted exactly once"
8201 );
8202
8203 assert!(
8206 replacements.is_empty(),
8207 "a surplus exit must not request a replacement"
8208 );
8209 }
8210
8211 #[tokio::test]
8212 async fn a_registration_github_will_not_delete_still_concludes_the_attempt() {
8213 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8217 harness.ready().await;
8218 let attempt = harness.launch().await;
8219 harness
8220 .github
8221 .observe(GithubRunnerObservation::Registered { busy: false });
8222 harness.launcher.supervise(&harness.policy).await.unwrap();
8223
8224 harness
8225 .github
8226 .deregistration_fails
8227 .store(true, Ordering::SeqCst);
8228 harness.clock.advance_secs(11);
8229 harness
8230 .github
8231 .observe(GithubRunnerObservation::Registered { busy: false });
8232 harness.launcher.supervise(&harness.policy).await.unwrap();
8233
8234 assert_eq!(
8235 *harness.github.deregistrations.lock().unwrap(),
8236 vec![73],
8237 "the delete was attempted"
8238 );
8239 let concluded = harness.store.attempt(attempt.id).unwrap().unwrap();
8240 assert_eq!(
8241 concluded.outcome(),
8242 Some(&AttemptOutcome::ExitedIdleWithoutWork),
8243 "the attempt concluded anyway"
8244 );
8245 assert_eq!(concluded.state(), AttemptState::Cleaned);
8246 assert!(!attempt.runtime_path().exists());
8247 }
8248
8249 #[tokio::test]
8250 async fn exit_before_acceptance_returns_replacement_intent_without_launching() {
8251 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8252 harness.ready().await;
8253 let first = harness.launch().await;
8254 harness.processes.set_alive(false);
8255 harness
8256 .github
8257 .observe(GithubRunnerObservation::NotRegistered);
8258 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8259 let failed = harness.store.attempt(first.id).unwrap().unwrap();
8260 assert!(matches!(
8261 failed.outcome(),
8262 Some(AttemptOutcome::Failed {
8263 reason: FailureReason::ProcessExitedUnexpectedly
8264 })
8265 ));
8266 assert!(!first.runtime_path().exists());
8267
8268 assert_eq!(
8269 replacements,
8270 vec![ReplacementIntent {
8271 policy: harness.policy.id,
8272 previous_attempt: first.id,
8273 operation: "exit_before_acceptance_replacement",
8274 }]
8275 );
8276 assert_eq!(harness.store.attempts().unwrap().len(), 1);
8277 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
8278 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8279 assert!(harness.delay.0.lock().unwrap().is_empty());
8280 }
8281
8282 fn write_runner_diagnostics(runtime: &Path, ran_a_job: bool) {
8286 let diag = runtime.join("_diag");
8287 fs::create_dir_all(&diag).unwrap();
8288 fs::write(diag.join("Runner_20261007-211941-utc.log"), b"listener").unwrap();
8289 if ran_a_job {
8290 fs::write(diag.join("Worker_20261007-211950-utc.log"), b"worker").unwrap();
8291 }
8292 }
8293
8294 #[tokio::test]
8299 async fn a_job_too_short_to_be_seen_busy_is_recorded_as_completed() {
8300 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8301 harness.ready().await;
8302 let started = harness.launch().await;
8303 assert_eq!(started.state(), AttemptState::Starting);
8304 write_runner_diagnostics(started.runtime_path(), true);
8305 harness.processes.finish_successfully();
8306 harness
8307 .github
8308 .observe(GithubRunnerObservation::NotRegistered);
8309
8310 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8311
8312 let cleaned = harness.only_attempt();
8313 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
8314 assert_eq!(cleaned.state(), AttemptState::Cleaned);
8315 assert_eq!(cleaned.github_runner_id(), Some(73));
8316 assert!(
8317 replacements.is_empty(),
8318 "a finished job needs no replacement"
8319 );
8320 let busy_seen = harness.events.events().into_iter().any(|event| {
8321 matches!(
8322 event,
8323 AttemptEvent::State {
8324 state: AttemptState::Busy,
8325 ..
8326 }
8327 )
8328 });
8329 assert!(busy_seen, "the journal walks the diagram to `busy` first");
8330 }
8331
8332 #[tokio::test]
8334 async fn a_job_taken_between_two_passes_after_idle_is_recorded_as_completed() {
8335 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8336 harness.ready().await;
8337 let started = harness.launch().await;
8338 harness
8339 .github
8340 .observe(GithubRunnerObservation::Registered { busy: false });
8341 harness.launcher.supervise(&harness.policy).await.unwrap();
8342 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8343
8344 write_runner_diagnostics(started.runtime_path(), true);
8345 harness.processes.finish_successfully();
8346 harness
8347 .github
8348 .observe(GithubRunnerObservation::NotRegistered);
8349 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8350
8351 assert_eq!(
8352 harness.only_attempt().outcome(),
8353 Some(&AttemptOutcome::CompletedJob)
8354 );
8355 assert!(replacements.is_empty());
8356 }
8357
8358 #[tokio::test]
8362 async fn an_assignment_the_runner_never_took_up_is_not_a_completed_job() {
8363 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8364 harness.ready().await;
8365 let started = harness.launch().await;
8366 harness
8367 .github
8368 .observe(GithubRunnerObservation::Registered { busy: true });
8369 harness.launcher.supervise(&harness.policy).await.unwrap();
8370 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8371
8372 write_runner_diagnostics(started.runtime_path(), false);
8373 harness.processes.finish_successfully();
8374 harness
8375 .github
8376 .observe(GithubRunnerObservation::NotRegistered);
8377 harness.launcher.supervise(&harness.policy).await.unwrap();
8378
8379 let cleaned = harness.only_attempt();
8380 assert_eq!(
8381 cleaned.outcome(),
8382 Some(&AttemptOutcome::ExitedIdleWithoutWork)
8383 );
8384 assert_eq!(cleaned.state(), AttemptState::Cleaned);
8385 }
8386
8387 #[tokio::test]
8390 async fn a_finished_job_still_listed_at_github_is_completed_and_deregistered() {
8391 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8392 harness.ready().await;
8393 let started = harness.launch().await;
8394 harness
8395 .github
8396 .observe(GithubRunnerObservation::Registered { busy: true });
8397 harness.launcher.supervise(&harness.policy).await.unwrap();
8398
8399 write_runner_diagnostics(started.runtime_path(), true);
8400 harness.processes.finish_successfully();
8401 harness
8402 .github
8403 .observe(GithubRunnerObservation::Registered { busy: true });
8404 harness.launcher.supervise(&harness.policy).await.unwrap();
8405
8406 assert_eq!(
8407 harness.only_attempt().outcome(),
8408 Some(&AttemptOutcome::CompletedJob)
8409 );
8410 assert_eq!(*harness.github.deregistrations.lock().unwrap(), vec![73]);
8411 }
8412
8413 #[tokio::test]
8416 async fn a_live_runner_whose_assignment_was_withdrawn_goes_back_to_idle() {
8417 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8418 harness.ready().await;
8419 let started = harness.launch().await;
8420 harness
8421 .github
8422 .observe(GithubRunnerObservation::Registered { busy: true });
8423 harness.launcher.supervise(&harness.policy).await.unwrap();
8424 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8425
8426 write_runner_diagnostics(started.runtime_path(), false);
8427 harness
8428 .github
8429 .observe(GithubRunnerObservation::Registered { busy: false });
8430 harness.launcher.supervise(&harness.policy).await.unwrap();
8431 assert_eq!(harness.only_attempt().state(), AttemptState::Idle);
8432
8433 harness
8435 .github
8436 .observe(GithubRunnerObservation::Registered { busy: true });
8437 harness.launcher.supervise(&harness.policy).await.unwrap();
8438 write_runner_diagnostics(started.runtime_path(), true);
8439 harness
8440 .github
8441 .observe(GithubRunnerObservation::Registered { busy: false });
8442 harness.launcher.supervise(&harness.policy).await.unwrap();
8443 assert_eq!(harness.only_attempt().state(), AttemptState::Busy);
8444 }
8445
8446 #[tokio::test]
8449 async fn a_clean_exit_waits_for_github_to_answer() {
8450 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8451 harness.ready().await;
8452 let started = harness.launch().await;
8453 write_runner_diagnostics(started.runtime_path(), true);
8454 harness.processes.finish_successfully();
8455 harness.github.observe(GithubRunnerObservation::Unreachable);
8456 harness.launcher.supervise(&harness.policy).await.unwrap();
8457 assert_eq!(harness.only_attempt().state(), AttemptState::Starting);
8458
8459 harness
8460 .github
8461 .observe(GithubRunnerObservation::NotRegistered);
8462 harness.launcher.supervise(&harness.policy).await.unwrap();
8463 assert_eq!(
8464 harness.only_attempt().outcome(),
8465 Some(&AttemptOutcome::CompletedJob)
8466 );
8467 }
8468
8469 #[tokio::test]
8472 async fn a_short_job_with_no_runner_id_still_concludes() {
8473 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8474 harness.ready().await;
8475 let started = harness.launch().await;
8476 fs::remove_file(started.runtime_path().join(RUNNER_ID_FILE)).unwrap();
8477 write_runner_diagnostics(started.runtime_path(), true);
8478 harness.processes.finish_successfully();
8479 harness
8480 .github
8481 .observe(GithubRunnerObservation::NotRegistered);
8482 harness
8483 .launcher
8484 .supervise(&harness.policy)
8485 .await
8486 .expect("supervision goes on");
8487 assert!(harness.only_attempt().is_terminal());
8488 }
8489
8490 #[tokio::test]
8493 async fn without_runner_diagnostics_an_early_exit_is_still_a_failure() {
8494 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8495 harness.ready().await;
8496 harness.launch().await;
8497 harness.processes.finish_successfully();
8498 harness
8499 .github
8500 .observe(GithubRunnerObservation::NotRegistered);
8501 harness.launcher.supervise(&harness.policy).await.unwrap();
8502 assert!(matches!(
8503 harness.only_attempt().outcome(),
8504 Some(AttemptOutcome::Failed {
8505 reason: FailureReason::ProcessExitedUnexpectedly
8506 })
8507 ));
8508 }
8509
8510 #[tokio::test]
8511 async fn expired_jit_is_removed_and_does_not_reregister_after_demand_disappears() {
8512 let harness = Harness::new(
8513 FakeGithubLifecycle::default(),
8514 Arc::new(FakeDemand::answering([false])),
8515 );
8516 let id = AttemptId::new_random();
8517 let runtime = harness
8518 .launcher
8519 .app_paths
8520 .runtime_dir()
8521 .join(harness.policy.id.to_string())
8522 .join(id.to_string());
8523 fs::create_dir_all(&runtime).unwrap();
8524 let mut attempt =
8525 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8526 attempt.jit_received(harness.clock.now()).unwrap();
8527 harness.store.record_attempt(&attempt).unwrap();
8528 harness.clock.advance_secs(11);
8529 let replacements = harness
8530 .launcher
8531 .recover_startup(std::slice::from_ref(&harness.policy))
8532 .await
8533 .unwrap();
8534 assert_eq!(
8535 replacements,
8536 vec![ReplacementIntent {
8537 policy: harness.policy.id,
8538 previous_attempt: id,
8539 operation: "jit_expired_replacement",
8540 }]
8541 );
8542
8543 let cleaned = harness.store.attempt(id).unwrap().unwrap();
8544 assert_eq!(cleaned.state(), AttemptState::Cleaned);
8545 assert!(matches!(
8546 cleaned.outcome(),
8547 Some(AttemptOutcome::Failed {
8548 reason: FailureReason::JitExpired
8549 })
8550 ));
8551 assert!(!runtime.exists());
8552 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
8553 assert!(harness.delay.0.lock().unwrap().is_empty());
8554 }
8555
8556 #[tokio::test]
8557 async fn expired_jit_returns_intent_but_never_launches_inside_lifecycle() {
8558 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8559 let id = AttemptId::new_random();
8560 let runtime = harness
8561 .launcher
8562 .app_paths
8563 .runtime_dir()
8564 .join("expired-with-demand");
8565 fs::create_dir_all(&runtime).unwrap();
8566 let mut attempt =
8567 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8568 attempt.jit_received(harness.clock.now()).unwrap();
8569 harness.store.record_attempt(&attempt).unwrap();
8570 harness.clock.advance_secs(11);
8571 let replacements = harness
8572 .launcher
8573 .recover_startup(std::slice::from_ref(&harness.policy))
8574 .await
8575 .unwrap();
8576
8577 let attempts = harness.store.attempts().unwrap();
8578 assert_eq!(attempts.len(), 1);
8579 assert_eq!(
8580 attempts
8581 .iter()
8582 .find(|attempt| attempt.id == id)
8583 .unwrap()
8584 .state(),
8585 AttemptState::Cleaned
8586 );
8587 assert_eq!(
8588 replacements,
8589 vec![ReplacementIntent {
8590 policy: harness.policy.id,
8591 previous_attempt: id,
8592 operation: "jit_expired_replacement",
8593 }]
8594 );
8595 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
8596 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8597 assert!(harness.delay.0.lock().unwrap().is_empty());
8598 }
8599
8600 #[tokio::test]
8601 async fn package_materialization_retries_are_bounded_and_demand_adjacent() {
8602 let persistent = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8603 persistent.packages.fail_materializations(2);
8604 persistent.ready().await;
8605 persistent.launch().await;
8606 assert_eq!(
8607 persistent.packages.materializations.load(Ordering::SeqCst),
8608 3
8609 );
8610 assert_eq!(
8611 *persistent.delay.0.lock().unwrap(),
8612 vec![Duration::from_millis(10), Duration::from_millis(20)]
8613 );
8614
8615 let gone_before_wait = Harness::new(
8616 FakeGithubLifecycle::default(),
8617 Arc::new(FakeDemand::answering([false])),
8618 );
8619 gone_before_wait.packages.fail_materializations(3);
8620 gone_before_wait.ready().await;
8621 assert!(gone_before_wait.launch_result().await.is_err());
8622 assert_eq!(
8623 gone_before_wait
8624 .packages
8625 .materializations
8626 .load(Ordering::SeqCst),
8627 1
8628 );
8629 assert!(gone_before_wait.delay.0.lock().unwrap().is_empty());
8630
8631 let gone_during_wait = Harness::new(
8632 FakeGithubLifecycle::default(),
8633 Arc::new(FakeDemand::answering([true, false])),
8634 );
8635 gone_during_wait.packages.fail_materializations(3);
8636 gone_during_wait.ready().await;
8637 assert!(gone_during_wait.launch_result().await.is_err());
8638 assert_eq!(
8639 gone_during_wait
8640 .packages
8641 .materializations
8642 .load(Ordering::SeqCst),
8643 1
8644 );
8645 assert_eq!(
8646 *gone_during_wait.delay.0.lock().unwrap(),
8647 vec![Duration::from_millis(10)]
8648 );
8649 }
8650
8651 #[tokio::test]
8652 async fn replacement_is_intent_only_and_never_launches_inside_lifecycle() {
8653 let harness = Harness::new(
8654 FakeGithubLifecycle::default(),
8655 Arc::new(FakeDemand::answering([true, false])),
8656 );
8657 harness.ready().await;
8658 let first = harness.launch().await;
8659 harness.processes.set_alive(false);
8660 harness
8661 .github
8662 .observe(GithubRunnerObservation::NotRegistered);
8663 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8664
8665 assert_eq!(harness.store.attempts().unwrap().len(), 1);
8666 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 1);
8667 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8668 assert!(harness.delay.0.lock().unwrap().is_empty());
8669 assert_eq!(
8670 replacements,
8671 vec![ReplacementIntent {
8672 policy: harness.policy.id,
8673 previous_attempt: first.id,
8674 operation: "exit_before_acceptance_replacement",
8675 }]
8676 );
8677 assert_eq!(
8678 harness.store.attempt(first.id).unwrap().unwrap().state(),
8679 AttemptState::Cleaned
8680 );
8681 }
8682
8683 #[tokio::test]
8684 async fn startup_adopts_a_live_process_and_refuses_launch_before_recovery() {
8685 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8686 let before = harness.launch_result().await;
8687 assert!(before.is_err());
8688 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8689
8690 let id = AttemptId::new_random();
8691 let runtime = harness.launcher.app_paths.runtime_dir().join("adopt");
8692 fs::create_dir_all(&runtime).unwrap();
8693 let mut attempt =
8694 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
8695 attempt.jit_received(harness.clock.now()).unwrap();
8696 attempt.started(4242, harness.clock.now()).unwrap();
8697 harness.store.record_attempt(&attempt).unwrap();
8698 harness.processes.set_alive(true);
8699 harness
8700 .github
8701 .observe(GithubRunnerObservation::NotRegistered);
8702 let replacements = harness
8703 .launcher
8704 .recover_startup(std::slice::from_ref(&harness.policy))
8705 .await
8706 .unwrap();
8707 assert!(replacements.is_empty());
8708 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 0);
8709 assert!(
8710 harness
8711 .events
8712 .events()
8713 .contains(&AttemptEvent::Adopted { attempt: id })
8714 );
8715 }
8716
8717 #[tokio::test]
8718 async fn spawn_before_starting_crash_recovers_pid_then_completes_and_cleans() {
8719 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8720 let id = AttemptId::new_random();
8721 let runtime = harness
8722 .launcher
8723 .app_paths
8724 .runtime_dir()
8725 .join("spawn-before-starting");
8726 fs::create_dir_all(&runtime).unwrap();
8727 let mut attempt =
8728 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8729 attempt.jit_received(harness.clock.now()).unwrap();
8730 harness.store.record_attempt(&attempt).unwrap();
8731 harness.processes.set_alive(true);
8732 harness
8733 .github
8734 .observe(GithubRunnerObservation::Registered { busy: true });
8735
8736 let replacements = harness
8737 .launcher
8738 .recover_startup(std::slice::from_ref(&harness.policy))
8739 .await
8740 .unwrap();
8741 assert!(replacements.is_empty());
8742 let recovered = harness.store.attempt(id).unwrap().unwrap();
8743 assert_eq!(recovered.state(), AttemptState::Busy);
8744 assert_eq!(recovered.process_id(), Some(4242));
8745 assert_eq!(recovered.github_runner_id(), Some(73));
8746 let events = harness.events.events();
8747 let starting = events
8748 .iter()
8749 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Starting } if *attempt == id))
8750 .unwrap();
8751 let busy = events
8752 .iter()
8753 .position(|event| matches!(event, AttemptEvent::State { attempt, state: AttemptState::Busy } if *attempt == id))
8754 .unwrap();
8755 assert!(starting < busy, "recovery skipped a legal edge: {events:?}");
8756
8757 harness.processes.finish_successfully();
8758 harness
8759 .github
8760 .observe(GithubRunnerObservation::NotRegistered);
8761 assert!(
8762 harness
8763 .launcher
8764 .supervise(&harness.policy)
8765 .await
8766 .unwrap()
8767 .is_empty()
8768 );
8769 let cleaned = harness.store.attempt(id).unwrap().unwrap();
8770 assert_eq!(cleaned.state(), AttemptState::Cleaned);
8771 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::CompletedJob));
8772 assert!(!runtime.exists());
8773 }
8774
8775 #[tokio::test]
8776 async fn failed_post_spawn_stop_keeps_capacity_until_supervision_proves_death() {
8777 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8778 harness.processes.fail_spawn_with_live_child();
8779 harness.ready().await;
8780 assert!(harness.launch_result().await.is_err());
8781
8782 let attempt = harness.only_attempt();
8783 assert_eq!(attempt.state(), AttemptState::Starting);
8784 assert_eq!(attempt.process_id(), Some(4242));
8785 assert!(attempt.outcome().is_none());
8786 assert!(attempt.state().counts_against_capacity());
8787 assert_eq!(harness.processes.spawns.load(Ordering::SeqCst), 1);
8788 assert!(harness.delay.0.lock().unwrap().is_empty());
8789
8790 harness.processes.set_alive(false);
8791 harness
8792 .github
8793 .observe(GithubRunnerObservation::NotRegistered);
8794 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
8795 assert_eq!(replacements.len(), 1);
8796 assert_eq!(
8797 harness.store.attempt(attempt.id).unwrap().unwrap().state(),
8798 AttemptState::Cleaned
8799 );
8800 }
8801
8802 #[tokio::test]
8803 async fn remote_runner_identity_closes_both_sides_of_the_registration_crash_boundary() {
8804 for sidecar_already_present in [false, true] {
8805 let harness = Harness::new(
8806 FakeGithubLifecycle::default(),
8807 Arc::new(FakeDemand::answering([false])),
8808 );
8809 let id = AttemptId::new_random();
8810 let runtime =
8811 harness
8812 .launcher
8813 .app_paths
8814 .runtime_dir()
8815 .join(if sidecar_already_present {
8816 "after-id-sidecar"
8817 } else {
8818 "before-id-sidecar"
8819 });
8820 fs::create_dir_all(&runtime).unwrap();
8821 let mut attempt =
8822 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8823 if sidecar_already_present {
8824 write_runner_id(&runtime, 73).unwrap();
8825 attempt.jit_received(harness.clock.now()).unwrap();
8826 }
8827 harness.store.record_attempt(&attempt).unwrap();
8828 harness.processes.set_alive(true);
8829 harness
8830 .github
8831 .observe(GithubRunnerObservation::Registered { busy: false });
8832 harness
8833 .launcher
8834 .recover_startup(std::slice::from_ref(&harness.policy))
8835 .await
8836 .unwrap();
8837
8838 assert_eq!(read_runner_id(&runtime), Some(73));
8839 assert!(
8840 harness
8841 .store
8842 .attempt(id)
8843 .unwrap()
8844 .unwrap()
8845 .outcome()
8846 .is_none()
8847 );
8848 let events = harness.events.events();
8849 let recovered = events.iter().position(|event| {
8850 matches!(
8851 event,
8852 AttemptEvent::RemoteIdentityRecovered {
8853 attempt,
8854 runner_id: 73
8855 } if *attempt == id
8856 )
8857 });
8858 assert_eq!(recovered.is_some(), !sidecar_already_present);
8859 if let Some(recovered) = recovered {
8860 let adopted = events
8861 .iter()
8862 .position(|event| matches!(event, AttemptEvent::Adopted { attempt } if *attempt == id))
8863 .unwrap();
8864 assert!(
8865 recovered < adopted,
8866 "identity was not durable before adoption: {events:?}"
8867 );
8868 }
8869 assert!(runtime.exists());
8870 }
8871 }
8872
8873 #[tokio::test]
8874 async fn recovery_stays_closed_for_unknown_policy_and_unreachable_attempts() {
8875 let unknown = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8876 let unknown_attempt = RunnerAttempt::allocate(
8877 AttemptId::new_random(),
8878 PolicyId::from_u128(0xfeed),
8879 unknown
8880 .launcher
8881 .app_paths
8882 .runtime_dir()
8883 .join("unknown-policy"),
8884 unknown.clock.now(),
8885 );
8886 unknown.store.record_attempt(&unknown_attempt).unwrap();
8887 let expired_id = AttemptId::new_random();
8888 let expired_runtime = unknown
8889 .launcher
8890 .app_paths
8891 .runtime_dir()
8892 .join("expired-beside-unknown");
8893 fs::create_dir_all(&expired_runtime).unwrap();
8894 let mut expired = RunnerAttempt::allocate(
8895 expired_id,
8896 unknown.policy.id,
8897 expired_runtime,
8898 unknown.clock.now(),
8899 );
8900 expired.jit_received(unknown.clock.now()).unwrap();
8901 unknown.store.record_attempt(&expired).unwrap();
8902 unknown.clock.advance_secs(11);
8903 assert!(matches!(
8904 unknown
8905 .launcher
8906 .recover_startup(std::slice::from_ref(&unknown.policy))
8907 .await,
8908 Err(LifecycleError::RecoveryIncomplete)
8909 ));
8910 assert!(unknown.launch_result().await.is_err());
8911 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
8912 let recovered_policy = fixtures::policy()
8913 .id(PolicyId::from_u128(0xfeed))
8914 .repository("octo/repo")
8915 .autoscale("home", 2)
8916 .active()
8917 .build();
8918 let pending = unknown
8919 .launcher
8920 .recover_startup(&[unknown.policy.clone(), recovered_policy])
8921 .await
8922 .unwrap();
8923 assert_eq!(
8924 pending,
8925 vec![ReplacementIntent {
8926 policy: unknown.policy.id,
8927 previous_attempt: expired_id,
8928 operation: "jit_expired_replacement",
8929 }]
8930 );
8931 assert_eq!(unknown.processes.spawns.load(Ordering::SeqCst), 0);
8932
8933 let unreachable = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8934 let id = AttemptId::new_random();
8935 let runtime = unreachable
8936 .launcher
8937 .app_paths
8938 .runtime_dir()
8939 .join("unreachable");
8940 fs::create_dir_all(&runtime).unwrap();
8941 unreachable
8942 .store
8943 .record_attempt(&RunnerAttempt::allocate(
8944 id,
8945 unreachable.policy.id,
8946 runtime,
8947 unreachable.clock.now(),
8948 ))
8949 .unwrap();
8950 unreachable
8951 .github
8952 .observe(GithubRunnerObservation::Unreachable);
8953 assert!(matches!(
8954 unreachable
8955 .launcher
8956 .recover_startup(std::slice::from_ref(&unreachable.policy))
8957 .await,
8958 Err(LifecycleError::RecoveryIncomplete)
8959 ));
8960 assert!(unreachable.launch_result().await.is_err());
8961 assert_eq!(unreachable.processes.spawns.load(Ordering::SeqCst), 0);
8962 }
8963
8964 #[tokio::test]
8965 async fn a_dead_busy_process_unknown_to_github_is_orphaned_and_cleaned() {
8966 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8967 let id = AttemptId::new_random();
8968 let runtime = harness.launcher.app_paths.runtime_dir().join("orphan");
8969 fs::create_dir_all(&runtime).unwrap();
8970 let mut attempt =
8971 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
8972 attempt.jit_received(harness.clock.now()).unwrap();
8973 attempt.started(4242, harness.clock.now()).unwrap();
8974 attempt.assigned_job(73, harness.clock.now()).unwrap();
8975 harness.store.record_attempt(&attempt).unwrap();
8976 harness.processes.set_alive(false);
8977 harness
8978 .github
8979 .observe(GithubRunnerObservation::NotRegistered);
8980 harness
8981 .launcher
8982 .recover_startup(std::slice::from_ref(&harness.policy))
8983 .await
8984 .unwrap();
8985 let cleaned = harness.store.attempt(id).unwrap().unwrap();
8986 assert_eq!(cleaned.state(), AttemptState::Cleaned);
8987 assert_eq!(cleaned.outcome(), Some(&AttemptOutcome::Orphaned));
8988 assert!(!runtime.exists());
8989 }
8990
8991 #[tokio::test]
8992 async fn registration_timeout_journals_intent_stops_then_concludes_with_dead_reason() {
8993 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
8994 harness.ready().await;
8995 let id = AttemptId::new_random();
8996 let runtime = harness.launcher.app_paths.runtime_dir().join("timeout");
8997 fs::create_dir_all(&runtime).unwrap();
8998 let mut attempt =
8999 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
9000 attempt.jit_received(harness.clock.now()).unwrap();
9001 attempt.started(4242, harness.clock.now()).unwrap();
9002 harness.store.record_attempt(&attempt).unwrap();
9003 harness.clock.advance_secs(11);
9004 harness.processes.set_alive(true);
9005 harness
9006 .github
9007 .observe(GithubRunnerObservation::NotRegistered);
9008 let replacements = harness.launcher.supervise(&harness.policy).await.unwrap();
9009 assert_eq!(
9010 replacements,
9011 vec![ReplacementIntent {
9012 policy: harness.policy.id,
9013 previous_attempt: id,
9014 operation: "registration_timeout_replacement",
9015 }]
9016 );
9017
9018 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 1);
9019 assert!(!harness.processes.alive.load(Ordering::SeqCst));
9020 let actions = harness.processes.actions.lock().unwrap().clone();
9021 let intent = actions
9022 .iter()
9023 .position(|action| *action == "terminate_intent")
9024 .unwrap();
9025 let signal = actions
9026 .iter()
9027 .position(|action| *action == "terminate")
9028 .unwrap();
9029 assert!(
9030 intent < signal,
9031 "intent was not durable before signal: {actions:?}"
9032 );
9033
9034 let cleaned = harness.store.attempt(id).unwrap().unwrap();
9035 assert!(matches!(
9036 cleaned.outcome(),
9037 Some(AttemptOutcome::Failed {
9038 reason: FailureReason::TerminatedAfterRegistrationTimeout
9039 })
9040 ));
9041 let events = harness.events.events();
9042 let intent = events
9043 .iter()
9044 .position(|event| matches!(event, AttemptEvent::TerminateIntent { .. }))
9045 .unwrap();
9046 let stopped = events
9047 .iter()
9048 .position(|event| matches!(event, AttemptEvent::Terminated { .. }))
9049 .unwrap();
9050 let concluded = events
9051 .iter()
9052 .position(|event| matches!(event, AttemptEvent::Concluded { .. }))
9053 .unwrap();
9054 assert!(intent < stopped && stopped < concluded, "{events:?}");
9055 }
9056
9057 #[tokio::test]
9058 async fn timeout_crash_recovery_returns_the_same_replacement_intent() {
9059 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9060 let id = AttemptId::new_random();
9061 let runtime = harness
9062 .launcher
9063 .app_paths
9064 .runtime_dir()
9065 .join("timeout-after-crash");
9066 fs::create_dir_all(&runtime).unwrap();
9067 let mut attempt =
9068 RunnerAttempt::allocate(id, harness.policy.id, runtime, harness.clock.now());
9069 attempt.jit_received(harness.clock.now()).unwrap();
9070 attempt.started(4242, harness.clock.now()).unwrap();
9071 harness.store.record_attempt(&attempt).unwrap();
9072 harness.processes.intent.store(true, Ordering::SeqCst);
9073 harness.processes.set_alive(false);
9074 harness
9075 .github
9076 .observe(GithubRunnerObservation::NotRegistered);
9077
9078 let replacements = harness
9079 .launcher
9080 .recover_startup(std::slice::from_ref(&harness.policy))
9081 .await
9082 .unwrap();
9083 assert_eq!(
9084 replacements,
9085 vec![ReplacementIntent {
9086 policy: harness.policy.id,
9087 previous_attempt: id,
9088 operation: "registration_timeout_replacement",
9089 }]
9090 );
9091 let consumed = RunnerLauncher::supervise(&harness.launcher, &harness.policy)
9092 .await
9093 .unwrap();
9094 assert_eq!(consumed, replacements);
9095 assert!(
9096 RunnerLauncher::supervise(&harness.launcher, &harness.policy)
9097 .await
9098 .unwrap()
9099 .is_empty(),
9100 "startup replacement evidence must be consumed exactly once by e1"
9101 );
9102 assert!(matches!(
9103 harness.store.attempt(id).unwrap().unwrap().outcome(),
9104 Some(AttemptOutcome::Failed {
9105 reason: FailureReason::TerminatedAfterRegistrationTimeout
9106 })
9107 ));
9108 }
9109
9110 #[tokio::test]
9111 async fn terminate_intent_sync_failure_prevents_signal_and_conclusion() {
9112 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9113 let id = AttemptId::new_random();
9114 let runtime = harness
9115 .launcher
9116 .app_paths
9117 .runtime_dir()
9118 .join("timeout-sync-failure");
9119 fs::create_dir_all(&runtime).unwrap();
9120 let mut attempt =
9121 RunnerAttempt::allocate(id, harness.policy.id, &runtime, harness.clock.now());
9122 attempt.jit_received(harness.clock.now()).unwrap();
9123 attempt.started(4242, harness.clock.now()).unwrap();
9124 harness.store.record_attempt(&attempt).unwrap();
9125 harness.clock.advance_secs(11);
9126 harness.processes.set_alive(true);
9127 harness.processes.fail_intent();
9128 harness
9129 .github
9130 .observe(GithubRunnerObservation::NotRegistered);
9131
9132 assert!(
9133 harness
9134 .launcher
9135 .recover_startup(std::slice::from_ref(&harness.policy))
9136 .await
9137 .is_err()
9138 );
9139 assert_eq!(harness.processes.terminations.load(Ordering::SeqCst), 0);
9140 assert!(harness.processes.alive.load(Ordering::SeqCst));
9141 assert_eq!(
9142 harness.store.attempt(id).unwrap().unwrap().state(),
9143 AttemptState::Starting
9144 );
9145 assert!(!harness.events.events().iter().any(|event| matches!(
9146 event,
9147 AttemptEvent::Terminated { attempt } | AttemptEvent::Concluded { attempt, .. }
9148 if *attempt == id
9149 )));
9150 }
9151
9152 #[tokio::test]
9153 async fn diagnostics_survive_cleanup_without_the_jit_or_a_token() {
9154 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9155 harness.ready().await;
9156 let attempt = harness.launch().await;
9157 harness
9158 .github
9159 .observe(GithubRunnerObservation::Registered { busy: false });
9160 harness.launcher.supervise(&harness.policy).await.unwrap();
9161 harness.clock.advance_secs(11);
9162 harness.processes.set_alive(false);
9163 harness
9164 .github
9165 .observe(GithubRunnerObservation::NotRegistered);
9166 harness.launcher.supervise(&harness.policy).await.unwrap();
9167 let diagnostic = fs::read_to_string(
9168 harness
9169 .launcher
9170 .diagnostics_root
9171 .join(format!("{}.log", attempt.id)),
9172 )
9173 .unwrap();
9174 assert!(diagnostic.contains("exited_idle_without_work"));
9175 assert!(!diagnostic.contains(JIT));
9176 assert!(!diagnostic.contains("ghp_"));
9177 assert!(!attempt.runtime_path().exists());
9178 }
9179
9180 #[test]
9181 fn native_process_listing_never_contains_jit_and_handoffs_never_survive() {
9182 let root = tempfile::tempdir().unwrap();
9183 let policy = fixtures::policy()
9184 .repository("octo/repo")
9185 .autoscale("home", 1)
9186 .active()
9187 .build();
9188 let runtime = root.path().join("successful");
9189 fs::create_dir_all(&runtime).unwrap();
9190 let processes = NativeProcesses::new();
9191 let config = EncodedJitConfig::new(JIT);
9192 let handoff =
9193 RestrictiveHandoff::create(&runtime, SecretString::from(config.expose().to_owned()))
9194 .unwrap();
9195 let mut child = native_inspection_spec()
9196 .spawn_runner_with_handoff(&handoff)
9197 .expect("native child starts");
9198 let pid = child.pid();
9199 handoff.delete().unwrap();
9200 let command_line = native_command_line(pid);
9201 assert!(
9202 !command_line.contains(JIT),
9203 "the encoded JIT configuration appeared in the native process listing"
9204 );
9205 assert_no_jit_file(&runtime);
9206 child
9207 .stop(Duration::from_secs(1))
9208 .expect("native child stops");
9209
9210 let failed_runtime = root.path().join("failed");
9211 fs::create_dir_all(&failed_runtime).unwrap();
9212 let failed = RunnerAttempt::allocate(
9213 AttemptId::new_random(),
9214 policy.id,
9215 &failed_runtime,
9216 FakeClock::default().now(),
9217 );
9218 assert!(
9219 processes
9220 .spawn(&failed, &EncodedJitConfig::new(JIT))
9221 .is_err(),
9222 "a runtime with no runner executable must fail"
9223 );
9224 assert_no_jit_file(&failed_runtime);
9225 processes
9226 .record_terminate_intent(&failed)
9227 .expect("the intent file and its directory entry are durably synced");
9228 assert_eq!(
9229 fs::read(NativeProcesses::intent_path(&failed)).unwrap(),
9230 b"registration-timeout\n"
9231 );
9232 }
9233
9234 #[test]
9235 fn post_spawn_boundaries_are_bounded_durable_and_never_retry_jit() {
9236 let root = tempfile::tempdir().unwrap();
9237 let policy = fixtures::policy()
9238 .repository("octo/repo")
9239 .autoscale("home", 1)
9240 .active()
9241 .build();
9242 let processes = NativeProcesses::new();
9243 processes.use_long_lived_test_listener();
9244 for (index, boundary) in [
9245 PostSpawnBoundary::HandoffDelete,
9246 PostSpawnBoundary::IdentitySerialize,
9247 PostSpawnBoundary::IdentityWrite,
9248 PostSpawnBoundary::ChildMapInsert,
9249 ]
9250 .into_iter()
9251 .enumerate()
9252 {
9253 let runtime = root.path().join(format!("post-spawn-{index}"));
9254 let bin = runtime.join("bin");
9255 fs::create_dir_all(&bin).unwrap();
9256 #[cfg(windows)]
9257 let listener = bin.join("Runner.Listener.exe");
9258 #[cfg(not(windows))]
9259 let listener = bin.join("Runner.Listener");
9260 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9261 let attempt = RunnerAttempt::allocate(
9262 AttemptId::new_random(),
9263 policy.id,
9264 &runtime,
9265 FakeClock::default().now(),
9266 );
9267 processes.fail_post_spawn_at(boundary);
9268 let failure = processes
9269 .spawn(&attempt, &EncodedJitConfig::new(JIT))
9270 .expect_err("fault must cross the post-spawn cleanup path");
9271 assert!(!failure.retryable, "{boundary:?} allowed duplicate retry");
9272 assert!(
9273 !processes.is_alive(&attempt).unwrap(),
9274 "{boundary:?} left a child"
9275 );
9276 assert!(!NativeProcesses::identity_path(&attempt).exists());
9277 assert_no_jit_file(&runtime);
9278 }
9279 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
9280
9281 let runtime = root.path().join("identity-and-stop-fail");
9282 let bin = runtime.join("bin");
9283 fs::create_dir_all(&bin).unwrap();
9284 #[cfg(windows)]
9285 let listener = bin.join("Runner.Listener.exe");
9286 #[cfg(not(windows))]
9287 let listener = bin.join("Runner.Listener");
9288 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9289 let attempt = RunnerAttempt::allocate(
9290 AttemptId::new_random(),
9291 policy.id,
9292 &runtime,
9293 FakeClock::default().now(),
9294 );
9295 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
9299 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
9300 processes.fail_next_post_spawn_stop();
9301 let failure = processes
9302 .spawn(&attempt, &EncodedJitConfig::new(JIT))
9303 .expect_err("the identity boundary must fail closed");
9304 assert!(failure.live_pid.is_some());
9305 assert_long_lived_listener_ready(&processes, &attempt);
9306 assert!(processes.is_alive(&attempt).unwrap());
9307 assert!(!NativeProcesses::identity_path(&attempt).exists());
9308 assert!(NativeProcesses::fallback_identity_path(&attempt).is_file());
9309 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
9310 processes.terminate(&attempt).unwrap();
9311
9312 let runtime = root.path().join("persistent-stop-and-identity-failures");
9313 let bin = runtime.join("bin");
9314 fs::create_dir_all(&bin).unwrap();
9315 #[cfg(windows)]
9316 let listener = bin.join("Runner.Listener.exe");
9317 #[cfg(not(windows))]
9318 let listener = bin.join("Runner.Listener");
9319 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9320 let mut unresolved = RunnerAttempt::allocate(
9321 AttemptId::new_random(),
9322 policy.id,
9323 &runtime,
9324 FakeClock::default().now(),
9325 );
9326 for _ in 0..3 {
9327 processes.fail_post_spawn_at(PostSpawnBoundary::IdentityWrite);
9328 }
9329 processes.fail_post_spawn_stops(MAX_POST_SPAWN_STOP_ATTEMPTS);
9330 let failure = processes
9331 .spawn(&unresolved, &EncodedJitConfig::new(JIT))
9332 .expect_err("bounded cleanup must return even when every stop errors");
9333 let pid = failure
9334 .live_pid
9335 .expect("the owned child remains supervised in this invocation");
9336 assert!(matches!(failure.reason, FailureReason::Other(_)));
9337 assert_long_lived_listener_ready(&processes, &unresolved);
9338 unresolved.jit_received(FakeClock::default().now()).unwrap();
9339 unresolved.started(pid, FakeClock::default().now()).unwrap();
9340 let journal = SqliteStore::open_in_memory().unwrap();
9341 journal.record_attempt(&unresolved).unwrap();
9342 let recovered = journal.attempt(unresolved.id).unwrap().unwrap();
9343 assert_eq!(recovered.process_id(), Some(pid));
9344 assert_eq!(recovered.state(), AttemptState::Starting);
9345 assert!(processes.is_alive(&unresolved).unwrap());
9346 assert!(!NativeProcesses::identity_path(&unresolved).exists());
9347 assert!(!NativeProcesses::fallback_identity_path(&unresolved).exists());
9348 assert_eq!(
9349 fs::read_to_string(NativeProcesses::unresolved_process_path(&unresolved)).unwrap(),
9350 pid.to_string(),
9351 "bounded cleanup must leave durable unresolved-process evidence before returning"
9352 );
9353 assert!(
9354 NativeProcesses::new().is_alive(&recovered).is_err(),
9355 "restart must fail closed on the durable starting/PID journal rather than trust a bare PID"
9356 );
9357 processes.terminate(&unresolved).unwrap();
9358
9359 let runtime = root.path().join("post-spawn-stop-failed");
9360 let bin = runtime.join("bin");
9361 fs::create_dir_all(&bin).unwrap();
9362 #[cfg(windows)]
9363 let listener = bin.join("Runner.Listener.exe");
9364 #[cfg(not(windows))]
9365 let listener = bin.join("Runner.Listener");
9366 fs::copy(std::env::current_exe().unwrap(), &listener).unwrap();
9367 let attempt = RunnerAttempt::allocate(
9368 AttemptId::new_random(),
9369 policy.id,
9370 &runtime,
9371 FakeClock::default().now(),
9372 );
9373 processes.fail_post_spawn_at(PostSpawnBoundary::ChildMapInsert);
9374 processes.fail_next_post_spawn_stop();
9375 let failure = processes
9376 .spawn(&attempt, &EncodedJitConfig::new(JIT))
9377 .expect_err("the injected stop failure must preserve supervision");
9378 let live_pid = failure
9379 .live_pid
9380 .expect("live PID is returned to the journal");
9381 assert!(!failure.retryable);
9382 assert_long_lived_listener_ready(&processes, &attempt);
9383 assert!(NativeProcesses::identity_path(&attempt).is_file());
9384 assert_eq!(
9385 NativeProcesses::read_identity(&attempt)
9386 .unwrap()
9387 .unwrap()
9388 .pid(),
9389 live_pid
9390 );
9391 assert_eq!(processes.post_spawn_reaps.load(Ordering::SeqCst), 4);
9392 processes.terminate(&attempt).unwrap();
9393 }
9394
9395 #[test]
9396 #[ignore = "spawned only as the platform-stable native listener fixture"]
9397 fn long_lived_native_listener_helper() {
9398 let ready = std::env::var_os("RUNNER_MANAGER_TEST_LISTENER_READY")
9399 .map(PathBuf::from)
9400 .expect("the parent supplies the readiness path");
9401 runner_manager_platform::host_fitness::write_atomically(&ready, b"ready\n")
9404 .expect("the listener publishes readiness");
9405 std::thread::sleep(Duration::from_secs(30));
9406 }
9407
9408 fn assert_long_lived_listener_ready(processes: &NativeProcesses, attempt: &RunnerAttempt) {
9409 let ready = attempt.runtime_path().join(TEST_LISTENER_READY);
9410 let deadline = std::time::Instant::now() + Duration::from_secs(5);
9411 loop {
9412 if ready.is_file() {
9413 assert_eq!(fs::read(&ready).unwrap(), b"ready\n");
9414 return;
9415 }
9416 assert!(
9417 processes.is_alive(attempt).unwrap(),
9418 "the native listener exited before publishing readiness"
9419 );
9420 assert!(
9421 std::time::Instant::now() < deadline,
9422 "the native listener stayed alive but never published readiness"
9423 );
9424 std::thread::sleep(Duration::from_millis(10));
9425 }
9426 }
9427
9428 #[tokio::test]
9429 async fn a_reservation_is_journalled_before_any_effect_and_holds_its_slot() {
9430 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9431 harness.ready().await;
9432
9433 let reserved = harness.reserve().await.unwrap();
9434
9435 assert_eq!(reserved.state(), AttemptState::Allocated);
9436 assert!(reserved.counts_against_capacity());
9437 let journalled = harness.store.attempt(reserved.id).unwrap().unwrap();
9438 assert_eq!(journalled.state(), AttemptState::Allocated);
9439 assert_eq!(harness.packages.materializations.load(Ordering::SeqCst), 0);
9440 assert_eq!(harness.github.registrations.load(Ordering::SeqCst), 0);
9441
9442 let started = harness
9443 .launcher
9444 .finish_launch(&harness.policy, reserved, ReservationWaits::default())
9445 .await
9446 .unwrap();
9447 assert_eq!(started.state(), AttemptState::Starting);
9448 }
9449
9450 #[tokio::test]
9454 async fn a_pass_leaves_a_runner_coming_up_alone_and_supervises_it_once_up() {
9455 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9456 harness.ready().await;
9457 let reserved = harness.reserve().await.unwrap();
9458 let looks_left = || harness.github.observations.lock().unwrap().len();
9459 harness
9460 .github
9461 .observations
9462 .lock()
9463 .unwrap()
9464 .push_back(LifecycleGithubObservation::not_registered());
9465
9466 harness.launcher.supervise(&harness.policy).await.unwrap();
9467 assert_eq!(looks_left(), 1, "it did not look");
9468 let journalled = harness.store.attempt(reserved.id).unwrap().unwrap();
9469 assert_eq!(journalled.state(), AttemptState::Allocated);
9470
9471 harness
9472 .launcher
9473 .finish_launch(&harness.policy, reserved, ReservationWaits::default())
9474 .await
9475 .unwrap();
9476 assert!(harness.launcher.bringing_up.lock().unwrap().is_empty());
9477
9478 harness.launcher.supervise(&harness.policy).await.unwrap();
9479 assert_eq!(looks_left(), 0, "up, it is this pass's to supervise");
9480 }
9481
9482 #[tokio::test]
9485 async fn a_crash_between_reservation_and_spawn_recovers_the_slot() {
9486 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9487 harness.ready().await;
9488 let reserved = harness.reserve().await.unwrap();
9489 harness.clock.advance_secs(31);
9490
9491 harness
9492 .launcher
9493 .recover_startup(std::slice::from_ref(&harness.policy))
9494 .await
9495 .unwrap();
9496
9497 let recovered = harness.store.attempt(reserved.id).unwrap().unwrap();
9498 assert!(
9499 !recovered.counts_against_capacity(),
9500 "the slot is free again: {:?}",
9501 recovered.state()
9502 );
9503 }
9504
9505 #[tokio::test]
9507 async fn a_crash_after_the_package_is_copied_recovers_the_slot() {
9508 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9509 harness.ready().await;
9510 let reserved = harness.reserve().await.unwrap();
9511 harness
9512 .packages
9513 .materialize(&reserved, &mut PackageTimings::default())
9514 .await
9515 .unwrap();
9516 harness.clock.advance_secs(31);
9517
9518 harness
9519 .launcher
9520 .recover_startup(std::slice::from_ref(&harness.policy))
9521 .await
9522 .unwrap();
9523
9524 let recovered = harness.store.attempt(reserved.id).unwrap().unwrap();
9525 assert!(!recovered.counts_against_capacity());
9526 }
9527
9528 #[tokio::test]
9529 async fn runners_prepare_at_once_within_the_bound() {
9530 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9531 harness.ready().await;
9532 let turns = harness.launcher.turns.available_permits();
9533 harness.launcher.turns.forget_permits(turns - 2);
9534 let mut reserved = Vec::new();
9535 for _ in 0..4 {
9536 reserved.push(harness.reserve().await.unwrap());
9537 }
9538
9539 let finished = futures::future::join_all(reserved.into_iter().map(|attempt| {
9540 harness
9541 .launcher
9542 .finish_launch(&harness.policy, attempt, ReservationWaits::default())
9543 }))
9544 .await;
9545
9546 assert!(finished.iter().all(Result::is_ok));
9547 assert_eq!(
9548 harness.packages.peak_materializing.load(Ordering::SeqCst),
9549 2
9550 );
9551 }
9552
9553 #[tokio::test]
9554 async fn every_production_launch_prunes_under_the_same_allocation_guard() {
9555 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
9556 harness.ready().await;
9557 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 0);
9558 harness.launch().await;
9559 assert_eq!(harness.packages.prunes.load(Ordering::SeqCst), 1);
9560 }
9561
9562 fn copy_package_tree(
9563 source: &Path,
9564 destination: &Path,
9565 seeds: &TrustedTrees,
9566 ) -> std::io::Result<()> {
9567 let origin = copy_origin(source, destination, seeds);
9568 copy_package_from(source, &origin, destination, seeds)
9569 }
9570
9571 fn assert_no_jit_file(runtime: &Path) {
9572 for entry in fs::read_dir(runtime).unwrap() {
9573 let path = entry.unwrap().path();
9574 if path.is_file() {
9575 let bytes = fs::read(&path).unwrap();
9576 assert!(
9577 !bytes
9578 .windows(JIT.len())
9579 .any(|window| window == JIT.as_bytes()),
9580 "a JIT payload survived in a runtime file"
9581 );
9582 }
9583 }
9584 }
9585
9586 #[test]
9587 fn production_listener_command_uses_the_supported_jit_contract() {
9588 let runtime = Path::new("runtime");
9589 let spec = runner_listener_spec(
9590 PathBuf::from("Runner.Listener"),
9591 runtime,
9592 Vec::new(),
9593 &RunnerEnv::default(),
9594 );
9595 let arguments: Vec<_> = spec
9596 .arguments()
9597 .iter()
9598 .map(|argument| argument.to_string_lossy().into_owned())
9599 .collect();
9600
9601 assert_eq!(arguments, ["run"]);
9602 assert!(
9603 !arguments
9604 .iter()
9605 .any(|argument| argument == "--jit-config-file"),
9606 "the obsolete file option would be rejected by Runner.Listener 2.336.0"
9607 );
9608 }
9609
9610 #[test]
9614 fn production_listener_layers_runner_env_over_the_platform_defaults() {
9615 let root = tempfile::tempdir().unwrap();
9616 let runtime = root.path().join("attempt");
9617 let host_env =
9618 RunnerEnv::parse("ELECTRON_CACHE=/cache/electron\nHOME=/operator/chose/this\n")
9619 .unwrap();
9620 let caches = vec![
9621 (
9622 "ELECTRON_CACHE",
9623 std::ffi::OsString::from("/rman/_cache/o/r/electron"),
9624 ),
9625 (
9626 "npm_config_cache",
9627 std::ffi::OsString::from("/rman/_cache/o/r/npm"),
9628 ),
9629 ];
9630 let spec = runner_listener_spec(
9631 PathBuf::from("Runner.Listener"),
9632 &runtime,
9633 caches,
9634 &host_env,
9635 );
9636 let env = |name: &str| spec.configured_env(name).map(PathBuf::from);
9637
9638 assert!(spec.expects_normal_scheduling());
9639 assert_eq!(
9640 env("ELECTRON_CACHE"),
9641 Some(PathBuf::from("/cache/electron")),
9642 "runner.env must win over a dependency cache of the same name"
9643 );
9644 assert_eq!(
9645 env("npm_config_cache"),
9646 Some(PathBuf::from("/rman/_cache/o/r/npm"))
9647 );
9648 assert_eq!(
9649 env("HOME"),
9650 Some(PathBuf::from("/operator/chose/this")),
9651 "runner.env must win over a platform default of the same name"
9652 );
9653 for tmp in ["TMPDIR", "TEMP", "TMP"] {
9654 assert_eq!(env(tmp), Some(runtime.join("tmp")), "{tmp}");
9655 }
9656
9657 let home = runtime.join(runner_env::RUNNER_HOME_DIR);
9658 if cfg!(windows) {
9659 assert_eq!(env("USERPROFILE"), Some(home.clone()));
9662 assert_eq!(env("APPDATA"), Some(home.join(r"AppData\Roaming")));
9663 assert_eq!(env("LOCALAPPDATA"), Some(home.join(r"AppData\Local")));
9664 assert!(home.join(r"AppData\Roaming").is_dir());
9665 assert!(home.join(r"AppData\Local").is_dir());
9666 } else {
9667 assert_eq!(env("USERPROFILE"), None);
9669 assert!(!home.exists());
9670 }
9671 }
9672
9673 #[test]
9677 fn production_listener_opts_powershell_out_of_telemetry() {
9678 let root = tempfile::tempdir().unwrap();
9679 let runtime = root.path().join("attempt");
9680 let listener = |host_env: &RunnerEnv| {
9681 runner_listener_spec(
9682 PathBuf::from("Runner.Listener"),
9683 &runtime,
9684 Vec::new(),
9685 host_env,
9686 )
9687 };
9688 let optout = |spec: &SpawnSpec| {
9689 spec.configured_env(runner_env::POWERSHELL_TELEMETRY_OPTOUT)
9690 .map(OsStr::to_os_string)
9691 };
9692
9693 let spec = listener(&RunnerEnv::default());
9694 if std::env::var_os(runner_env::POWERSHELL_TELEMETRY_OPTOUT)
9695 .is_some_and(|value| !value.is_empty())
9696 {
9697 assert_eq!(optout(&spec), None);
9699 } else {
9700 assert_eq!(
9701 optout(&spec),
9702 Some(runner_env::POWERSHELL_TELEMETRY_OPTOUT_VALUE.into())
9703 );
9704 }
9705
9706 let operator = RunnerEnv::parse("POWERSHELL_TELEMETRY_OPTOUT=0\n").unwrap();
9707 assert_eq!(
9708 optout(&listener(&operator)),
9709 Some("0".into()),
9710 "runner.env must win over the telemetry opt-out"
9711 );
9712 }
9713
9714 #[test]
9715 fn an_invalid_runner_env_fails_the_launch_without_echoing_a_value() {
9716 let root = tempfile::tempdir().unwrap();
9717 let path = runner_env::path_in(root.path());
9718 fs::write(&path, "GOOD=1\nghp_notarealtokenbutshapedlikeone\n").unwrap();
9719 let processes = NativeProcesses::new().with_runner_env_file(path.clone());
9720 let failure = processes.host_env().unwrap_err().to_string();
9721 assert!(
9722 failure.contains("runner.env") && failure.contains("line 2"),
9723 "{failure}"
9724 );
9725 assert!(
9726 !failure.contains("ghp_"),
9727 "the failure echoed a value: {failure}"
9728 );
9729
9730 let policy = fixtures::policy()
9733 .repository("octo/repo")
9734 .autoscale("home", 1)
9735 .active()
9736 .build();
9737 let attempt = RunnerAttempt::allocate(
9738 AttemptId::new_random(),
9739 policy.id,
9740 root.path(),
9741 FakeClock::default().now(),
9742 );
9743 assert!(processes.prepare(&attempt, &policy).is_err());
9744
9745 fs::write(&path, "GOOD=1\n").unwrap();
9746 assert!(processes.prepare(&attempt, &policy).is_ok());
9747 assert_eq!(processes.host_env().unwrap().get("GOOD"), Some("1"));
9748 assert!(NativeProcesses::new().host_env().unwrap().is_empty());
9749 }
9750
9751 #[test]
9755 fn prepare_hands_the_policys_cache_namespace_to_the_launch() {
9756 let root = tempfile::tempdir().unwrap();
9757 let runner_root = root.path().join("rman");
9758 let runtime = runner_root.join("a1");
9759 fs::create_dir_all(runtime.join("bin")).unwrap();
9760 let config = runner_manager_platform::dependency_cache::config_path_in(root.path());
9761 let source_root = runner_root.clone();
9762 let processes = NativeProcesses::new().with_dependency_caches(DependencyCaches::new(
9763 config.clone(),
9764 Arc::new(move || Some(source_root.clone())),
9765 None,
9766 ));
9767 let policy = fixtures::policy()
9768 .repository("Octo/Repo")
9769 .autoscale("home", 1)
9770 .active()
9771 .build();
9772 let attempt = RunnerAttempt::allocate(
9773 AttemptId::new_random(),
9774 policy.id,
9775 &runtime,
9776 FakeClock::default().now(),
9777 );
9778 assert!(
9779 processes
9780 .cache_env(&attempt, None, &RunnerEnv::default())
9781 .is_empty(),
9782 "a launch with no prepared target, such as a bare `spawn`, has no cache"
9783 );
9784 let prepared = processes.prepare(&attempt, &policy).unwrap();
9785 let variables = processes.cache_env(
9786 &attempt,
9787 prepared.cache_target.as_ref(),
9788 &RunnerEnv::default(),
9789 );
9790 let npm = variables
9791 .iter()
9792 .find(|(name, _)| *name == "npm_config_cache")
9793 .map(|(_, value)| PathBuf::from(value));
9794 assert_eq!(
9795 npm,
9796 Some(
9797 runner_root
9798 .join("_cache")
9799 .join("octo")
9800 .join("repo")
9801 .join("npm")
9802 )
9803 );
9804
9805 fs::write(&config, "[tools]\nnot-a-tool = true\n").unwrap();
9806 let refused = processes
9807 .prepare(&attempt, &policy)
9808 .unwrap_err()
9809 .to_string();
9810 assert!(refused.contains("caches.toml"), "{refused}");
9811 }
9812
9813 #[cfg(windows)]
9814 fn native_inspection_spec() -> SpawnSpec {
9815 SpawnSpec::new("powershell.exe").args([
9816 "-NoProfile",
9817 "-NonInteractive",
9818 "-Command",
9819 "Start-Sleep -Seconds 30",
9820 ])
9821 }
9822
9823 #[cfg(unix)]
9824 fn native_inspection_spec() -> SpawnSpec {
9825 SpawnSpec::new("/bin/sh").args(["-c", "sleep 30"])
9826 }
9827
9828 #[cfg(windows)]
9829 fn native_command_line(pid: u32) -> String {
9830 let output = std::process::Command::new("powershell.exe")
9831 .args([
9832 "-NoProfile",
9833 "-NonInteractive",
9834 "-Command",
9835 &format!("(Get-CimInstance Win32_Process -Filter 'ProcessId = {pid}').CommandLine"),
9836 ])
9837 .output()
9838 .expect("PowerShell can inspect the native child");
9839 assert!(output.status.success(), "native process inspection failed");
9840 String::from_utf8(output.stdout).expect("Windows command lines are Unicode")
9841 }
9842
9843 #[cfg(target_os = "linux")]
9844 fn native_command_line(pid: u32) -> String {
9845 fs::read(format!("/proc/{pid}/cmdline"))
9846 .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " "))
9847 .expect("/proc exposes the native child command line")
9848 }
9849
9850 #[cfg(target_os = "macos")]
9851 fn native_command_line(pid: u32) -> String {
9852 let output = std::process::Command::new("ps")
9853 .args(["-o", "command=", "-p", &pid.to_string()])
9854 .output()
9855 .expect("ps can inspect the native child");
9856 assert!(output.status.success(), "native process inspection failed");
9857 String::from_utf8(output.stdout).expect("the command line is UTF-8")
9858 }
9859
9860 #[tokio::test]
9863 async fn a_persistent_repository_leases_s1_and_journals_it_before_any_github_effect() {
9864 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9865 .with_persistent_workspace(2);
9866 harness.github.watch_journal(Arc::clone(&harness.store));
9867 harness.ready().await;
9868
9869 let attempt = harness.launch().await;
9870
9871 assert_eq!(
9872 attempt.workspace(),
9873 AttemptWorkspace::persistent_slot(nz(1)),
9874 "the lowest free slot is leased"
9875 );
9876 assert_eq!(attempt.runtime_path(), harness.slot_path(1));
9877 assert!(attempt.holds_slot_lease());
9878 assert_eq!(
9880 harness.attempt(attempt.id).runtime_path(),
9881 harness.slot_path(1)
9882 );
9883
9884 let facts = harness.github.registration_facts();
9888 assert_eq!(facts.len(), 1);
9889 assert_eq!(
9890 facts[0].leased_slots,
9891 vec![1],
9892 "the lease was journalled first"
9893 );
9894 assert_eq!(facts[0].work_folder, DEFAULT_WORK_FOLDER);
9895 }
9896
9897 #[tokio::test]
9898 async fn a_terminal_but_uncleaned_attempt_keeps_its_slot_without_holding_capacity() {
9899 let harness = Harness::new(
9900 FakeGithubLifecycle::default().fail(true),
9901 Arc::new(PersistentDemand),
9902 )
9903 .with_persistent_workspace(2);
9904 harness.ready().await;
9905
9906 harness.launch_result().await.unwrap_err();
9908 let first = harness.store.attempts().unwrap().remove(0);
9909 assert_eq!(first.state(), AttemptState::Failed);
9910 assert!(
9911 !first.state().counts_against_capacity(),
9912 "a concluded attempt is invisible to host capacity"
9913 );
9914 assert!(
9915 first.holds_slot_lease(),
9916 "and still owns its directory, so its slot is not free"
9917 );
9918
9919 let second = harness.launch().await;
9920 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
9921 assert_eq!(second.runtime_path(), harness.slot_path(2));
9922 assert_eq!(
9923 harness
9924 .store
9925 .slot_leases_for_policy(harness.policy.id)
9926 .unwrap()
9927 .len(),
9928 2
9929 );
9930 }
9931
9932 #[tokio::test]
9933 async fn two_sequential_allocations_at_capacity_one_reuse_s1_and_its_retained_work() {
9934 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9935 .with_persistent_workspace(1);
9936 harness.ready().await;
9937
9938 let first = harness.launch().await;
9939 assert_eq!(first.runtime_path(), harness.slot_path(1));
9940
9941 let checkout = harness.slot_path(1).join(DEFAULT_WORK_FOLDER).join("repo");
9943 fs::create_dir_all(&checkout).unwrap();
9944 fs::write(checkout.join("checkout.txt"), b"from the first job").unwrap();
9945
9946 harness.cleanup_retaining_work(first.id).await;
9947
9948 let second = harness.launch().await;
9949 assert_ne!(second.id, first.id);
9950 assert_eq!(
9951 second.workspace(),
9952 AttemptWorkspace::persistent_slot(nz(1)),
9953 "a released slot is leased again rather than skipped"
9954 );
9955 assert_eq!(
9956 second.runtime_path(),
9957 first.runtime_path(),
9958 "the same slot is the same exact path"
9959 );
9960 assert_eq!(
9961 fs::read_to_string(checkout.join("checkout.txt")).unwrap(),
9962 "from the first job",
9963 "the retained job workspace survived the second allocation"
9964 );
9965 assert!(harness.slot_path(1).join("runner-package").exists());
9967 }
9968
9969 #[tokio::test]
9970 async fn lowering_capacity_leaves_higher_slots_alone_and_raising_it_permits_them_again() {
9971 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
9972 .with_persistent_workspace(2);
9973 harness.ready().await;
9974
9975 let first = harness.launch().await;
9976 let second = harness.launch().await;
9977 assert_eq!(second.runtime_path(), harness.slot_path(2));
9978 let kept = harness
9979 .slot_path(2)
9980 .join(DEFAULT_WORK_FOLDER)
9981 .join("kept.txt");
9982 fs::create_dir_all(kept.parent().unwrap()).unwrap();
9983 fs::write(&kept, b"s2 was here").unwrap();
9984 harness.cleanup_retaining_work(second.id).await;
9985
9986 harness.policy.set_max_capacity(nz(1)).unwrap();
9988 let refusal = harness.launch_result().await.unwrap_err().to_string();
9989 assert!(
9990 refusal.contains("s1 to s1"),
9991 "the refusal names the ceiling it reached: {refusal}"
9992 );
9993 assert!(
9994 harness.slot_path(2).exists() && kept.exists(),
9995 "lowering capacity deletes nothing; the higher slot is merely unusable"
9996 );
9997
9998 harness.policy.set_max_capacity(nz(2)).unwrap();
10000 let third = harness.launch().await;
10001 assert_eq!(third.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10002 assert_eq!(third.runtime_path(), harness.slot_path(2));
10003 assert_eq!(fs::read_to_string(&kept).unwrap(), "s2 was here");
10004 assert!(first.holds_slot_lease(), "s1 was never disturbed");
10005 }
10006
10007 #[tokio::test]
10008 async fn organization_and_ephemeral_policies_never_enter_slot_allocation() {
10009 for policy in [
10010 fixtures::policy()
10011 .organization("octo")
10012 .autoscale("home", 2)
10013 .active()
10014 .build(),
10015 fixtures::policy()
10016 .repository("octo/repo")
10017 .autoscale("home", 2)
10018 .active()
10019 .build(),
10020 ] {
10021 let mut harness =
10022 Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10023 .with_host_runner_root();
10024 assert_eq!(policy.workspace_policy(), &WorkspacePolicy::Ephemeral);
10025 harness.policy = policy;
10026 harness.ready().await;
10027
10028 let attempt = harness.launch().await;
10029 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
10030 assert_eq!(attempt.workspace().slot_number(), None);
10031 assert!(!attempt.holds_slot_lease());
10032 assert_eq!(
10033 attempt.runtime_path().parent().unwrap(),
10034 harness.host_root(),
10035 "a disposable attempt is a child of the host root, never of a slot"
10036 );
10037 assert!(
10038 harness
10039 .store
10040 .slot_leases_for_policy(harness.policy.id)
10041 .unwrap()
10042 .is_empty()
10043 );
10044 }
10045 }
10046
10047 #[tokio::test]
10048 async fn two_concurrent_allocations_never_share_a_slot() {
10049 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10050 .with_persistent_workspace(2);
10051 harness.github.watch_journal(Arc::clone(&harness.store));
10052 harness.ready().await;
10053
10054 let (first, second) = tokio::join!(harness.launch_result(), harness.launch_result());
10058 let first = first.unwrap();
10059 let second = second.unwrap();
10060
10061 let slots: BTreeSet<u16> = [&first, &second]
10062 .iter()
10063 .map(|attempt| {
10064 attempt
10065 .workspace()
10066 .slot_number()
10067 .expect("a persistent attempt leases a slot")
10068 })
10069 .collect();
10070 assert_eq!(slots, BTreeSet::from([1, 2]), "one slot each, never shared");
10071 assert_ne!(first.runtime_path(), second.runtime_path());
10072 assert_eq!(
10073 harness
10074 .store
10075 .slot_leases_for_policy(harness.policy.id)
10076 .unwrap()
10077 .len(),
10078 2
10079 );
10080
10081 let facts = harness.github.registration_facts();
10086 assert_eq!(facts.len(), 2);
10087 for fact in facts {
10088 let attempt = [&first, &second]
10089 .into_iter()
10090 .find(|attempt| runner_name(attempt.id) == fact.runner_name)
10091 .expect("every registration belongs to one of the two attempts");
10092 let slot = attempt
10093 .workspace()
10094 .slot_number()
10095 .expect("a persistent attempt leases a slot");
10096 assert!(
10097 fact.leased_slots.contains(&slot),
10098 "a JIT request never precedes its own lease: s{slot} not in {:?}",
10099 fact.leased_slots
10100 );
10101 assert_eq!(fact.work_folder, DEFAULT_WORK_FOLDER);
10102 }
10103 }
10104
10105 #[tokio::test]
10106 async fn the_database_is_the_final_fence_against_two_attempts_in_one_slot() {
10107 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10108 .with_persistent_workspace(2);
10109 harness.ready().await;
10110 let first = harness.launch().await;
10111
10112 let clash = RunnerAttempt::allocate_in(
10116 AttemptId::new_random(),
10117 harness.policy.id,
10118 first.runtime_path(),
10119 AttemptWorkspace::persistent_slot(nz(1)),
10120 harness.clock.now(),
10121 );
10122 assert!(matches!(
10123 harness.store.record_attempt(&clash).unwrap_err(),
10124 StoreError::SlotAlreadyLeased { slot: 1, .. }
10125 ));
10126
10127 let error = harness.launcher.record_allocation(&clash).unwrap_err();
10130 let rendered = error.to_string();
10131 assert!(rendered.contains("slot s1"), "{rendered}");
10132 assert!(rendered.contains("nothing was written"), "{rendered}");
10133 assert_eq!(
10134 harness.store.attempts().unwrap().len(),
10135 1,
10136 "the losing allocator journalled nothing"
10137 );
10138 }
10139
10140 #[test]
10141 fn slot_selection_fills_the_lowest_gap_and_stops_at_the_ceiling() {
10142 let leased = |slots: &[u16]| -> Vec<RunnerAttempt> {
10143 slots
10144 .iter()
10145 .map(|slot| {
10146 RunnerAttempt::allocate_in(
10147 AttemptId::new_random(),
10148 fixtures::POLICY_ID,
10149 format!("/srv/rman/acme/s{slot}"),
10150 AttemptWorkspace::persistent_slot(nz(*slot)),
10151 fixtures::created_at(),
10152 )
10153 })
10154 .collect()
10155 };
10156
10157 assert_eq!(lowest_free_slot(&[], nz(1)), Some(nz(1)));
10158 assert_eq!(lowest_free_slot(&leased(&[1]), nz(4)), Some(nz(2)));
10159 assert_eq!(lowest_free_slot(&leased(&[1, 3]), nz(4)), Some(nz(2)));
10161 assert_eq!(lowest_free_slot(&leased(&[1]), nz(1)), None);
10163 assert_eq!(lowest_free_slot(&leased(&[1, 2]), nz(2)), None);
10164 let ephemeral = vec![RunnerAttempt::allocate(
10166 AttemptId::new_random(),
10167 fixtures::POLICY_ID,
10168 "/srv/rman/host/abc",
10169 fixtures::created_at(),
10170 )];
10171 assert_eq!(lowest_free_slot(&ephemeral, nz(1)), Some(nz(1)));
10172 }
10173
10174 #[test]
10175 fn a_slot_is_reusable_only_when_it_is_empty_or_holds_one_real_work_directory() {
10176 let root = tempfile::tempdir().unwrap();
10177 let slot = root.path().join("s1");
10178 fs::create_dir(&slot).unwrap();
10179 accept_reusable_slot(&slot).expect("an empty slot is reusable");
10180
10181 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10182 accept_reusable_slot(&slot).expect("a retained job workspace is reusable");
10183
10184 fs::create_dir(slot.join("bin")).unwrap();
10187 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
10188 let refusal = accept_reusable_slot(&slot).unwrap_err().to_string();
10189 assert!(refusal.contains("bin"), "{refusal}");
10190 assert!(refusal.contains(".github-runner-id"), "{refusal}");
10191
10192 let file_work = root.path().join("s2");
10194 fs::create_dir(&file_work).unwrap();
10195 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
10196 assert!(accept_reusable_slot(&file_work).is_err());
10197 }
10198
10199 #[cfg(unix)]
10200 #[test]
10201 fn a_link_shaped_work_directory_is_refused_rather_than_followed() {
10202 let root = tempfile::tempdir().unwrap();
10206 let elsewhere = root.path().join("elsewhere");
10207 fs::create_dir(&elsewhere).unwrap();
10208
10209 let slot = root.path().join("s1");
10210 fs::create_dir(&slot).unwrap();
10211 std::os::unix::fs::symlink(&elsewhere, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10212 assert!(accept_reusable_slot(&slot).is_err());
10213
10214 let linked_slot = root.path().join("s2");
10215 std::os::unix::fs::symlink(&elsewhere, &linked_slot).unwrap();
10216 assert!(create_or_validate_slot(&linked_slot).is_err());
10217 }
10218
10219 #[test]
10220 fn a_slot_standing_where_a_file_is_refuses_rather_than_replacing_it() {
10221 let root = tempfile::tempdir().unwrap();
10222 let occupied = root.path().join("s1");
10223 fs::write(&occupied, b"an operator's file").unwrap();
10224 let refusal = create_or_validate_slot(&occupied).unwrap_err().to_string();
10225 assert!(refusal.contains("is not a directory"), "{refusal}");
10226 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
10227
10228 let fresh = root.path().join("s2");
10229 create_or_validate_slot(&fresh).expect("a missing slot is created");
10230 assert!(fresh.is_dir());
10231 create_or_validate_slot(&fresh).expect("an existing directory is accepted");
10232 }
10233
10234 #[test]
10235 fn the_retained_work_directory_is_matched_the_way_the_filesystem_matches_it() {
10236 assert!(is_work_folder(OsStr::new(DEFAULT_WORK_FOLDER)));
10237 assert!(!is_work_folder(OsStr::new("_work2")));
10238 assert_eq!(is_work_folder(OsStr::new("_Work")), cfg!(windows));
10242 }
10243
10244 #[test]
10245 fn package_materialization_never_overwrites_or_follows_a_retained_work_directory() {
10246 let root = tempfile::tempdir().unwrap();
10247 let package = root.path().join("package");
10248 fs::create_dir_all(package.join("bin")).unwrap();
10249 fs::write(package.join("bin").join("Runner.Listener"), b"binary").unwrap();
10250 fs::create_dir_all(package.join("externals").join(DEFAULT_WORK_FOLDER)).unwrap();
10252
10253 let slot = root.path().join("s1");
10254 let retained = slot.join(DEFAULT_WORK_FOLDER).join("repo");
10255 fs::create_dir_all(&retained).unwrap();
10256 fs::write(retained.join("checkout.txt"), b"from the first job").unwrap();
10257
10258 copy_package_tree(&package, &slot, &TrustedTrees::default())
10259 .expect("the package lays out around `_work`");
10260 assert!(slot.join("bin").join("Runner.Listener").exists());
10261 assert!(
10262 slot.join("externals").join(DEFAULT_WORK_FOLDER).is_dir(),
10263 "the guard is top-level only"
10264 );
10265 assert_eq!(
10266 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
10267 "from the first job"
10268 );
10269
10270 fs::create_dir(package.join(DEFAULT_WORK_FOLDER)).unwrap();
10272 let error = copy_package_tree(&package, &slot, &TrustedTrees::default()).unwrap_err();
10273 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
10274 assert_eq!(
10275 fs::read_to_string(retained.join("checkout.txt")).unwrap(),
10276 "from the first job"
10277 );
10278 }
10279
10280 #[test]
10281 fn rolling_back_a_materialization_keeps_a_slot_but_removes_a_disposable_directory() {
10282 let root = tempfile::tempdir().unwrap();
10283
10284 let slot = root.path().join("s1");
10285 let retained = slot.join(DEFAULT_WORK_FOLDER);
10286 fs::create_dir_all(retained.join("repo")).unwrap();
10287 fs::write(retained.join("repo").join("checkout.txt"), b"kept").unwrap();
10288 fs::create_dir_all(slot.join("bin")).unwrap();
10289 fs::write(slot.join(".github-runner-id"), b"73").unwrap();
10290 let persistent = RunnerAttempt::allocate_in(
10291 AttemptId::new_random(),
10292 fixtures::POLICY_ID,
10293 &slot,
10294 AttemptWorkspace::persistent_slot(nz(1)),
10295 fixtures::created_at(),
10296 );
10297
10298 remove_materialized_package(&persistent).unwrap();
10299 assert!(slot.is_dir(), "the slot itself is not removed");
10300 assert!(!slot.join("bin").exists());
10301 assert!(!slot.join(".github-runner-id").exists());
10302 assert_eq!(
10303 fs::read_to_string(retained.join("repo").join("checkout.txt")).unwrap(),
10304 "kept"
10305 );
10306
10307 let disposable_path = root.path().join("abcdef012345");
10308 fs::create_dir_all(disposable_path.join(DEFAULT_WORK_FOLDER)).unwrap();
10309 let disposable = RunnerAttempt::allocate(
10310 AttemptId::new_random(),
10311 fixtures::POLICY_ID,
10312 &disposable_path,
10313 fixtures::created_at(),
10314 );
10315 remove_materialized_package(&disposable).unwrap();
10316 assert!(
10317 !disposable_path.exists(),
10318 "a disposable directory is still removed whole"
10319 );
10320 }
10321
10322 fn litter_the_slot(slot: &Path) {
10332 for directory in ["bin", "externals", "_diag"] {
10333 fs::create_dir_all(slot.join(directory)).unwrap();
10334 }
10335 fs::write(slot.join("bin").join("Runner.Listener"), b"binary").unwrap();
10336 for file in SENSITIVE_SLOT_ENTRIES
10337 .iter()
10338 .filter(|entry| !slot.join(entry).is_dir())
10339 {
10340 fs::write(slot.join(file), b"runner state").unwrap();
10341 }
10342 fs::write(
10344 slot.join(format!(
10345 "{}0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.tmp",
10346 RestrictiveHandoff::NAME_PREFIX
10347 )),
10348 JIT.as_bytes(),
10349 )
10350 .unwrap();
10351 }
10352
10353 fn retain_under_work(slot: &Path) -> PathBuf {
10355 let checkout = slot.join(DEFAULT_WORK_FOLDER).join("repo").join("target");
10356 fs::create_dir_all(&checkout).unwrap();
10357 let marker = checkout.join("build-output.bin");
10358 fs::write(&marker, RETAINED).unwrap();
10359 marker
10360 }
10361
10362 const RETAINED: &str = "a Git-ignored build output the next job reuses";
10364
10365 fn entries_of(directory: &Path) -> Vec<String> {
10367 let mut names: Vec<String> = fs::read_dir(directory)
10368 .unwrap()
10369 .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
10370 .collect();
10371 names.sort();
10372 names
10373 }
10374
10375 fn only_the_job_workspace() -> Vec<String> {
10377 vec![DEFAULT_WORK_FOLDER.to_owned()]
10378 }
10379
10380 #[cfg(unix)]
10381 #[test]
10382 fn disposable_tree_removal_does_not_open_a_dotnet_diagnostic_fifo() {
10383 use std::sync::mpsc;
10384
10385 let temporary = tempfile::tempdir().unwrap();
10386 let tree = temporary.path().join("attempt");
10387 let diagnostic = tree.join("tmp/clr-debug-pipe-runner-in");
10388 fs::create_dir_all(diagnostic.parent().unwrap()).unwrap();
10389 assert!(
10390 std::process::Command::new("mkfifo")
10391 .arg(&diagnostic)
10392 .status()
10393 .unwrap()
10394 .success()
10395 );
10396
10397 let (finished, result) = mpsc::channel();
10398 std::thread::spawn(move || {
10399 let removed = remove_runtime_tree(&tree);
10400 let _ = finished.send(removed);
10401 });
10402
10403 result
10404 .recv_timeout(Duration::from_secs(2))
10405 .expect("runtime deletion must not wait for a FIFO peer")
10406 .unwrap();
10407 assert!(!diagnostic.exists());
10408 }
10409
10410 #[cfg(windows)]
10413 #[test]
10414 fn disposable_tree_removal_unlinks_a_junction_nobody_may_list() {
10415 let temporary = tempfile::tempdir().unwrap();
10416 let tree = temporary.path().join("attempt");
10417 let cache = tree.join(r"tmp\home\AppData\Local\Microsoft\Windows\INetCache");
10418 let outside = temporary.path().join("outside");
10419 fs::create_dir_all(&cache).unwrap();
10420 fs::create_dir_all(&outside).unwrap();
10421 fs::write(outside.join("keep.txt"), b"not the attempt's").unwrap();
10422 fs::write(tree.join("tmp").join("job.log"), b"job output").unwrap();
10423 let junction = cache.join("Content.IE5");
10424 let created = std::process::Command::new("cmd")
10425 .arg("/C")
10426 .arg("mklink")
10427 .arg("/J")
10428 .arg(&junction)
10429 .arg(&outside)
10430 .output()
10431 .unwrap();
10432 assert!(created.status.success(), "{created:?}");
10433 let denied = std::process::Command::new("icacls")
10437 .arg(&junction)
10438 .args(["/L", "/deny", "*S-1-1-0:(RD)"])
10439 .output()
10440 .unwrap();
10441 assert!(denied.status.success(), "{denied:?}");
10442
10443 let unfixed = remove_dir_all::remove_dir_all(&tree)
10445 .expect_err("the fixture no longer reproduces the WinINet junction");
10446 assert_eq!(unfixed.kind(), std::io::ErrorKind::PermissionDenied);
10447 assert!(junction.symlink_metadata().is_ok());
10448
10449 remove_runtime_tree(&tree).expect("a tree holding a deny-list junction is removed");
10450 assert!(!tree.exists(), "the attempt tree survived");
10451 assert_eq!(
10452 fs::read(outside.join("keep.txt")).unwrap(),
10453 b"not the attempt's",
10454 "removing the junction followed it into the directory it points at"
10455 );
10456 }
10457
10458 struct BlockedDeletion {
10474 directory: PathBuf,
10475 #[cfg(windows)]
10476 _handle: fs::File,
10477 }
10478
10479 impl BlockedDeletion {
10480 const HELD: &'static str = "held-open";
10481
10482 fn inject(directory: &Path) -> Option<Self> {
10485 #[cfg(unix)]
10486 if !Self::refusal_is_possible() {
10487 return None;
10488 }
10489 fs::create_dir_all(directory).unwrap();
10490 fs::write(
10491 directory.join(Self::HELD),
10492 b"a file the scrub cannot remove",
10493 )
10494 .unwrap();
10495 #[cfg(windows)]
10496 let handle = {
10497 use std::os::windows::fs::OpenOptionsExt;
10498
10499 fs::OpenOptions::new()
10500 .read(true)
10501 .share_mode(0)
10502 .open(directory.join(Self::HELD))
10503 .expect("the blocking handle opens")
10504 };
10505 #[cfg(unix)]
10506 Self::set_mode(directory, 0o555);
10507 Some(Self {
10508 directory: directory.to_path_buf(),
10509 #[cfg(windows)]
10510 _handle: handle,
10511 })
10512 }
10513
10514 fn release(self) {
10515 drop(self);
10516 }
10517
10518 #[cfg(unix)]
10519 fn refusal_is_possible() -> bool {
10520 let probe = tempfile::tempdir().unwrap();
10521 let directory = probe.path().join("probe");
10522 fs::create_dir(&directory).unwrap();
10523 fs::write(directory.join("file"), b"probe").unwrap();
10524 Self::set_mode(&directory, 0o555);
10525 let refused = fs::remove_dir_all(&directory).is_err();
10526 Self::set_mode(&directory, 0o755);
10527 refused
10528 }
10529
10530 #[cfg(unix)]
10531 fn set_mode(directory: &Path, mode: u32) {
10532 use std::os::unix::fs::PermissionsExt;
10533
10534 let mut permissions = fs::metadata(directory).unwrap().permissions();
10535 permissions.set_mode(mode);
10536 fs::set_permissions(directory, permissions).unwrap();
10537 }
10538 }
10539
10540 impl Drop for BlockedDeletion {
10541 fn drop(&mut self) {
10542 #[cfg(unix)]
10543 Self::set_mode(&self.directory, 0o755);
10544 #[cfg(not(unix))]
10545 let _ = &self.directory;
10546 }
10547 }
10548
10549 #[tokio::test]
10550 async fn two_sequential_jobs_keep_the_checkout_and_start_without_the_earlier_runner_state() {
10551 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10552 .with_persistent_workspace(1);
10553 harness.ready().await;
10554
10555 let first = harness.launch().await;
10556 let slot = harness.slot_path(1);
10557 assert_eq!(first.runtime_path(), slot);
10558 assert_eq!(
10559 read_runner_id(&slot),
10560 Some(73),
10561 "the attempt registered, so its identity is on disk"
10562 );
10563 let marker = retain_under_work(&slot);
10564 litter_the_slot(&slot);
10565
10566 harness.cleanup_retaining_work(first.id).await;
10567
10568 assert_eq!(entries_of(&slot), only_the_job_workspace());
10571 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
10572 assert_eq!(
10573 read_runner_id(&slot),
10574 None,
10575 "the first attempt's registration identity is gone before the second starts"
10576 );
10577 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
10578 assert!(!harness.attempt(first.id).holds_slot_lease());
10579
10580 let second = harness.launch().await;
10581 assert_ne!(second.id, first.id);
10582 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(1)));
10583 assert_eq!(
10584 second.runtime_path(),
10585 slot,
10586 "the same slot, so the same retained `_work`"
10587 );
10588 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
10589 }
10590
10591 #[tokio::test]
10592 async fn cleaning_a_persistent_slot_needs_no_policy_and_scans_no_directory_for_ownership() {
10593 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10594 .with_persistent_workspace(1);
10595 harness.ready().await;
10596 let attempt = harness.launch().await;
10597 let slot = harness.slot_path(1);
10598 let marker = retain_under_work(&slot);
10599 litter_the_slot(&slot);
10600 harness.conclude(attempt.id);
10601
10602 harness
10608 .store
10609 .remove_policy(harness.policy.id, harness.policy.revision())
10610 .unwrap();
10611 assert!(harness.store.policy(harness.policy.id).unwrap().is_none());
10612
10613 harness
10614 .launcher
10615 .clean(attempt.id)
10616 .await
10617 .expect("journal facts alone are enough to clean the slot");
10618
10619 assert_eq!(entries_of(&slot), only_the_job_workspace());
10620 assert!(marker.exists());
10621 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10622 }
10623
10624 #[tokio::test]
10625 async fn an_injected_partial_deletion_quarantines_the_slot_across_a_restart() {
10626 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10627 .with_persistent_workspace(2);
10628 harness.ready().await;
10629 let first = harness.launch().await;
10630 let slot = harness.slot_path(1);
10631 let marker = retain_under_work(&slot);
10632 litter_the_slot(&slot);
10633 harness.conclude(first.id);
10634
10635 let Some(block) = BlockedDeletion::inject(&slot.join("bin")) else {
10636 eprintln!(
10637 "skipped: this account cannot be refused a deletion, so no partial deletion can \
10638 be injected"
10639 );
10640 return;
10641 };
10642
10643 let refusal = harness
10644 .launcher
10645 .clean(first.id)
10646 .await
10647 .expect_err("a deletion that failed may not report a cleaned slot");
10648 let rendered = refusal.reason.to_string();
10649 assert!(rendered.contains("could not be removed"), "{rendered}");
10650
10651 let held = harness.attempt(first.id);
10652 assert_eq!(held.state(), AttemptState::Failed, "still not cleaned");
10653 assert!(held.holds_slot_lease(), "so the slot is still leased");
10654 assert!(
10655 !held.state().counts_against_capacity(),
10656 "and a concluded attempt still costs the host no capacity"
10657 );
10658
10659 let restarted = harness.restart();
10664 restarted
10665 .recover_startup(std::slice::from_ref(&harness.policy))
10666 .await
10667 .expect("one quarantined slot does not stop the host recovering");
10668 assert_eq!(
10669 harness.attempt(first.id).state(),
10670 AttemptState::Failed,
10671 "the quarantine survived the restart"
10672 );
10673 assert!(
10674 harness
10675 .reconcile_events
10676 .events()
10677 .iter()
10678 .any(|event| matches!(
10679 event,
10680 LifecycleEvent::AttemptCleanFailed {
10681 reason: "slot_entry_could_not_be_removed",
10682 ..
10683 }
10684 )),
10685 "the refusal is reported rather than retried in silence"
10686 );
10687
10688 let second = harness
10691 .launch_through(&restarted)
10692 .await
10693 .expect("the host can still launch");
10694 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
10695
10696 block.release();
10699 restarted
10700 .clean(first.id)
10701 .await
10702 .expect("the retried cleanup completes");
10703 assert_eq!(entries_of(&slot), only_the_job_workspace());
10704 assert!(marker.exists());
10705 assert_eq!(harness.attempt(first.id).state(), AttemptState::Cleaned);
10706 }
10707
10708 #[tokio::test]
10719 async fn an_injected_deletion_failure_leaves_a_disposable_attempt_uncleaned() {
10720 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
10721 harness.ready().await;
10722 let attempt = harness.launch().await;
10723 let runtime = attempt.runtime_path().to_path_buf();
10724 assert_eq!(attempt.workspace(), AttemptWorkspace::Ephemeral);
10725 harness.conclude(attempt.id);
10726
10727 let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
10728 eprintln!(
10729 "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
10730 );
10731 return;
10732 };
10733
10734 let refusal = harness
10735 .launcher
10736 .clean(attempt.id)
10737 .await
10738 .expect_err("a deletion that failed may not report a removed workspace");
10739 let rendered = refusal.reason.to_string();
10740 assert!(
10741 rendered.contains("could not be removed"),
10742 "the refusal names what happened: {rendered}"
10743 );
10744 assert_ne!(
10745 harness.attempt(attempt.id).state(),
10746 AttemptState::Cleaned,
10747 "an attempt whose directory is still on disk is not cleaned"
10748 );
10749 assert!(
10750 runtime.is_dir(),
10751 "the directory the removal could not finish is still there, which is the fact the journal must keep agreeing with"
10752 );
10753
10754 let restarted = harness.restart();
10755 restarted
10756 .recover_startup(std::slice::from_ref(&harness.policy))
10757 .await
10758 .expect("one locked ephemeral workspace does not stop startup recovery");
10759 assert!(
10760 harness
10761 .reconcile_events
10762 .events()
10763 .iter()
10764 .any(|event| matches!(
10765 event,
10766 LifecycleEvent::AttemptCleanFailed {
10767 attempt: failed_attempt,
10768 reason: "ephemeral_workspace_could_not_be_removed",
10769 ..
10770 } if *failed_attempt == attempt.id
10771 )),
10772 "the deferred cleanup remains visible while the daemon keeps running"
10773 );
10774
10775 block.release();
10778 harness
10779 .launcher
10780 .clean(attempt.id)
10781 .await
10782 .expect("the retried cleanup completes");
10783 assert!(!runtime.exists(), "the whole attempt directory goes");
10784 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10785 }
10786
10787 #[tokio::test]
10791 async fn supervision_backs_off_a_workspace_it_cannot_remove() {
10792 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand));
10793 harness.ready().await;
10794 let attempt = harness.launch().await;
10795 let runtime = attempt.runtime_path().to_path_buf();
10796 harness.conclude(attempt.id);
10797 let Some(block) = BlockedDeletion::inject(&runtime.join("held-open-subdirectory")) else {
10798 eprintln!(
10799 "skipped: this account cannot be refused a deletion, so no partial deletion can be injected"
10800 );
10801 return;
10802 };
10803 let failures = || {
10804 harness
10805 .reconcile_events
10806 .events()
10807 .iter()
10808 .filter(|event| {
10809 matches!(
10810 event,
10811 LifecycleEvent::AttemptCleanFailed { attempt: failed, .. }
10812 if *failed == attempt.id
10813 )
10814 })
10815 .count()
10816 };
10817 let supervise = async || {
10818 harness
10819 .launcher
10820 .supervise(&harness.policy)
10821 .await
10822 .expect("a stuck workspace does not fail supervision");
10823 };
10824
10825 supervise().await;
10826 assert_eq!(failures(), 1);
10827 supervise().await;
10828 assert_eq!(
10829 failures(),
10830 1,
10831 "the failed cleanup was retried on the very next pass"
10832 );
10833 harness.clock.advance_secs(31);
10834 supervise().await;
10835 assert_eq!(
10836 failures(),
10837 2,
10838 "the cleanup was not retried once its delay passed"
10839 );
10840
10841 block.release();
10842 harness.clock.advance_secs(61);
10843 supervise().await;
10844 assert!(
10845 !runtime.exists(),
10846 "the retry removes the workspace once it can"
10847 );
10848 assert_eq!(harness.attempt(attempt.id).state(), AttemptState::Cleaned);
10849 }
10850
10851 #[tokio::test]
10852 async fn changing_a_repository_back_to_ephemeral_leaves_every_old_slot_untouched() {
10853 let mut harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10854 .with_persistent_workspace(1);
10855 harness.ready().await;
10856 let first = harness.launch().await;
10857 let slot = harness.slot_path(1);
10858 let marker = retain_under_work(&slot);
10859 harness.cleanup_retaining_work(first.id).await;
10860
10861 harness
10865 .policy
10866 .set_workspace_policy(WorkspacePolicy::Ephemeral)
10867 .unwrap();
10868
10869 let second = harness.launch().await;
10870 assert_eq!(second.workspace(), AttemptWorkspace::Ephemeral);
10871 assert_eq!(
10872 second.runtime_path().parent().unwrap(),
10873 harness.host_root(),
10874 "a disposable attempt is a child of the host root"
10875 );
10876 assert!(slot.is_dir(), "the old slot is left where it stands");
10877 assert_eq!(fs::read_to_string(&marker).unwrap(), RETAINED);
10878
10879 harness.conclude(second.id);
10882 harness.launcher.clean(second.id).await.unwrap();
10883 assert!(!second.runtime_path().exists());
10884 assert!(marker.exists());
10885 }
10886
10887 #[tokio::test]
10888 async fn a_persistent_slot_is_scrubbed_only_after_the_process_is_signalled_and_gone() {
10889 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
10890 .with_persistent_workspace(1);
10891 harness.ready().await;
10892 let slot = harness.slot_path(1);
10893 fs::create_dir_all(&slot).unwrap();
10894 let marker = retain_under_work(&slot);
10895 litter_the_slot(&slot);
10896
10897 let id = AttemptId::new_random();
10898 let mut attempt = RunnerAttempt::allocate_in(
10899 id,
10900 harness.policy.id,
10901 &slot,
10902 AttemptWorkspace::persistent_slot(nz(1)),
10903 harness.clock.now(),
10904 );
10905 attempt.jit_received(harness.clock.now()).unwrap();
10906 attempt.started(4242, harness.clock.now()).unwrap();
10907 harness.store.record_attempt(&attempt).unwrap();
10908 harness.clock.advance_secs(11);
10909 harness.processes.set_alive(true);
10910 harness
10911 .github
10912 .observe(GithubRunnerObservation::NotRegistered);
10913
10914 harness.launcher.supervise(&harness.policy).await.unwrap();
10915
10916 let actions = harness.processes.actions.lock().unwrap().clone();
10920 let intent = actions
10921 .iter()
10922 .position(|action| *action == "terminate_intent")
10923 .unwrap();
10924 let signal = actions
10925 .iter()
10926 .position(|action| *action == "terminate")
10927 .unwrap();
10928 assert!(intent < signal, "{actions:?}");
10929 assert!(!harness.processes.alive.load(Ordering::SeqCst));
10930
10931 let cleaned = harness.attempt(id);
10932 assert_eq!(cleaned.state(), AttemptState::Cleaned);
10933 assert!(matches!(
10934 cleaned.outcome(),
10935 Some(AttemptOutcome::Failed {
10936 reason: FailureReason::TerminatedAfterRegistrationTimeout
10937 })
10938 ));
10939 assert_eq!(entries_of(&slot), only_the_job_workspace());
10940 assert!(marker.exists());
10941 }
10942
10943 #[test]
10944 fn a_scrub_retains_one_real_work_directory_and_removes_every_other_entry() {
10945 let root = tempfile::tempdir().unwrap();
10946 let slot = root.path().join("s1");
10947 fs::create_dir(&slot).unwrap();
10948 let marker = retain_under_work(&slot);
10949 litter_the_slot(&slot);
10950 fs::write(slot.join("runner-package"), b"verified").unwrap();
10951
10952 scrub_slot_entries(&slot).expect("a slot of ordinary runner state scrubs");
10953 verify_slot_scrubbed(&slot).expect("and proves it afterwards");
10954
10955 assert_eq!(entries_of(&slot), only_the_job_workspace());
10956 assert!(marker.exists());
10957 }
10958
10959 #[test]
10960 fn a_residue_refusal_never_reports_the_under_count_as_the_fact() {
10961 let slot = Path::new("/runners/s1");
10962
10963 let counted = residue_detail(slot, 2, &["`bin`".to_owned()]);
10966 assert!(counted.contains("2 entries other than"), "{counted}");
10967 assert!(counted.contains("including `bin`"), "{counted}");
10968 assert_eq!(
10969 residue_detail(slot, 1, &[]),
10970 format!(
10971 "1 entry other than `{DEFAULT_WORK_FOLDER}` survived cleanup of {}",
10972 slot.display()
10973 )
10974 );
10975
10976 let raced = residue_detail(slot, 0, &["`.credentials`".to_owned()]);
10981 assert!(!raced.contains('0'), "{raced}");
10982 assert!(raced.contains("reported nothing but"), "{raced}");
10983 assert!(raced.contains("`.credentials` survived cleanup"), "{raced}");
10984 }
10985
10986 #[test]
10987 fn verification_asks_the_filesystem_rather_than_the_listing_that_missed_an_entry() {
10988 let root = tempfile::tempdir().unwrap();
10989 let slot = root.path().join("s1");
10990 fs::create_dir(&slot).unwrap();
10991 fs::create_dir(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
10992 verify_slot_scrubbed(&slot).expect("only `_work` is a clean slot");
10993
10994 for survivor in ["bin", ".credentials", IDENTITY_FILE, RUNNER_ID_FILE] {
10998 fs::write(slot.join(survivor), b"left behind").unwrap();
10999 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
11000 assert_eq!(quarantine.refusal, SlotRefusal::Residue);
11001 assert!(
11002 quarantine.detail.contains(&format!("`{survivor}`")),
11003 "{quarantine}"
11004 );
11005 fs::remove_file(slot.join(survivor)).unwrap();
11006 }
11007
11008 let handoff = slot.join(format!("{}whatever.tmp", RestrictiveHandoff::NAME_PREFIX));
11011 fs::write(&handoff, JIT.as_bytes()).unwrap();
11012 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
11013 assert!(
11014 quarantine.detail.contains("an encoded JIT handoff"),
11015 "{quarantine}"
11016 );
11017 assert!(!quarantine.detail.contains(JIT), "{quarantine}");
11018 fs::remove_file(&handoff).unwrap();
11019
11020 fs::write(slot.join("ghp_DO_NOT_LEAK"), b"named by the job").unwrap();
11024 let quarantine = verify_slot_scrubbed(&slot).unwrap_err();
11025 assert!(
11026 quarantine.detail.contains("1 entry other than"),
11027 "{quarantine}"
11028 );
11029 assert!(
11030 !quarantine.detail.contains("ghp_DO_NOT_LEAK"),
11031 "{quarantine}"
11032 );
11033 }
11034
11035 #[test]
11036 fn a_slot_is_derived_from_the_journal_and_refused_when_it_disagrees() {
11037 let root = tempfile::tempdir().unwrap();
11038 let configured =
11039 LocalAbsolutePath::new(root.path().to_str().unwrap()).expect("a local absolute root");
11040 let slot = configured.as_path().join("s1");
11041 fs::create_dir(&slot).unwrap();
11042
11043 verify_journalled_slot(&slot, nz(1), Some(&configured))
11044 .expect("the journalled slot agrees");
11045 verify_journalled_slot(&slot, nz(1), None)
11046 .expect("and a policy that is gone removes a check, not the ability to clean");
11047
11048 assert_eq!(
11051 verify_journalled_slot(&slot, nz(2), None)
11052 .unwrap_err()
11053 .refusal,
11054 SlotRefusal::NotTheJournalledSlot
11055 );
11056 for stray in ["s1/nested", "not-a-slot", "s01"] {
11057 let path = configured.as_path().join(stray);
11058 assert_eq!(
11059 verify_journalled_slot(&path, nz(1), None)
11060 .unwrap_err()
11061 .refusal,
11062 SlotRefusal::NotTheJournalledSlot,
11063 "{}",
11064 path.display()
11065 );
11066 }
11067
11068 let elsewhere = tempfile::tempdir().unwrap();
11071 let other =
11072 LocalAbsolutePath::new(elsewhere.path().to_str().unwrap()).expect("a second root");
11073 assert_eq!(
11074 verify_journalled_slot(&slot, nz(1), Some(&other))
11075 .unwrap_err()
11076 .refusal,
11077 SlotRefusal::PolicyRootDisagrees
11078 );
11079 }
11080
11081 #[cfg(unix)]
11082 #[test]
11083 fn a_substituted_work_directory_quarantines_the_slot_and_deletes_nothing_outside_it() {
11084 let root = tempfile::tempdir().unwrap();
11088 let outside = root.path().join("operator-data");
11089 fs::create_dir(&outside).unwrap();
11090 let sentinel = outside.join("do-not-delete.txt");
11091 fs::write(
11092 &sentinel,
11093 b"an operator's data, outside every approved root",
11094 )
11095 .unwrap();
11096
11097 let slot = root.path().join("s1");
11098 fs::create_dir(&slot).unwrap();
11099 fs::create_dir(slot.join("bin")).unwrap();
11100 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
11101
11102 let quarantine = scrub_slot_entries(&slot).unwrap_err();
11103 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
11104 assert!(
11105 sentinel.exists(),
11106 "the deletion followed the link out of the slot"
11107 );
11108 assert!(outside.is_dir());
11109 assert!(
11110 slot.join(DEFAULT_WORK_FOLDER).symlink_metadata().is_ok(),
11111 "the substituted link is left for the operator, never unlinked as if it were ours"
11112 );
11113
11114 let file_work = root.path().join("s2");
11118 fs::create_dir(&file_work).unwrap();
11119 fs::write(file_work.join(DEFAULT_WORK_FOLDER), b"not a directory").unwrap();
11120 assert_eq!(
11121 scrub_slot_entries(&file_work).unwrap_err().refusal,
11122 SlotRefusal::WorkNotADirectory
11123 );
11124 }
11125
11126 #[cfg(unix)]
11127 #[test]
11128 fn a_slot_replaced_by_a_link_out_of_its_root_is_refused_before_anything_is_read() {
11129 let root = tempfile::tempdir().unwrap();
11130 let outside = root.path().join("operator-data");
11131 fs::create_dir(&outside).unwrap();
11132 let sentinel = outside.join("do-not-delete.txt");
11133 fs::write(
11134 &sentinel,
11135 b"an operator's data, outside every approved root",
11136 )
11137 .unwrap();
11138
11139 let inside = root.path().join("inside");
11142 fs::create_dir(&inside).unwrap();
11143 let slot = inside.join("s1");
11144 std::os::unix::fs::symlink(&outside, &slot).unwrap();
11145
11146 assert_eq!(
11147 verify_journalled_slot(&slot, nz(1), None)
11148 .unwrap_err()
11149 .refusal,
11150 SlotRefusal::Containment
11151 );
11152 assert!(sentinel.exists());
11153 assert!(
11154 slot.symlink_metadata().is_ok(),
11155 "the link is left for the operator rather than removed as if it were ours"
11156 );
11157 }
11158
11159 #[cfg(windows)]
11169 #[test]
11170 fn a_slot_root_replaced_by_a_junction_is_refused_before_anything_is_read() {
11171 let root = tempfile::tempdir().unwrap();
11172 let outside = root.path().join("operator-data");
11173 fs::create_dir(&outside).unwrap();
11174 let sentinel = outside.join("do-not-delete.txt");
11175 fs::write(
11176 &sentinel,
11177 b"an operator's data, outside every approved root",
11178 )
11179 .unwrap();
11180
11181 let inside = root.path().join("inside");
11184 fs::create_dir(&inside).unwrap();
11185 let slot = inside.join("s1");
11186 let Some(()) = plant_junction(&slot, &outside) else {
11187 eprintln!("skipped: this machine would not create a directory junction");
11188 return;
11189 };
11190
11191 assert_eq!(
11192 verify_journalled_slot(&slot, nz(1), None)
11193 .unwrap_err()
11194 .refusal,
11195 SlotRefusal::Containment
11196 );
11197 assert!(
11198 sentinel.exists(),
11199 "the refusal resolved the junction and reached the operator's data"
11200 );
11201 assert!(
11202 slot.symlink_metadata().is_ok(),
11203 "the junction is left for the operator rather than removed as if it were ours"
11204 );
11205 }
11206
11207 #[cfg(windows)]
11216 fn plant_junction(link: &Path, target: &Path) -> Option<()> {
11217 let made = std::process::Command::new("cmd")
11218 .arg("/C")
11219 .arg("mklink")
11220 .arg("/J")
11221 .arg(link)
11222 .arg(target)
11223 .output()
11224 .ok()?;
11225 (made.status.success() && link.symlink_metadata().is_ok()).then_some(())
11226 }
11227
11228 #[test]
11233 fn removing_an_attempt_never_reaches_into_the_dependency_caches_it_linked() {
11234 let root = tempfile::tempdir().unwrap();
11235 let cache = root.path().join("_cache").join("o").join("r").join("npm");
11236 fs::create_dir_all(&cache).unwrap();
11237 let cached = cache.join("package.tgz");
11238 fs::write(&cached, b"kept between jobs").unwrap();
11239 let attempt = root.path().join("0123456789abcdef");
11240 let work = attempt.join("_work").join("repo");
11241 fs::create_dir_all(&work).unwrap();
11242 fs::hard_link(&cached, work.join("hard-linked.tgz")).unwrap();
11243 #[cfg(unix)]
11244 std::os::unix::fs::symlink(&cache, work.join("linked-cache")).unwrap();
11245 #[cfg(windows)]
11246 if plant_junction(&work.join("linked-cache"), &cache).is_none() {
11247 eprintln!("this machine would not create a junction; the hard link is still checked");
11248 }
11249
11250 remove_runtime_tree(&attempt).unwrap();
11251 assert!(!attempt.exists());
11252 assert_eq!(fs::read(&cached).unwrap(), b"kept between jobs");
11253 }
11254
11255 #[cfg(windows)]
11256 #[test]
11257 fn a_work_directory_replaced_by_a_junction_fails_closed_and_deletes_nothing_beyond_it() {
11258 let root = tempfile::tempdir().unwrap();
11259 let outside = root.path().join("operator-data");
11260 fs::create_dir(&outside).unwrap();
11261 let sentinel = outside.join("do-not-delete.txt");
11262 fs::write(
11263 &sentinel,
11264 b"an operator's data, outside every approved root",
11265 )
11266 .unwrap();
11267
11268 let slot = root.path().join("s1");
11269 fs::create_dir(&slot).unwrap();
11270 fs::create_dir(slot.join("bin")).unwrap();
11271 let Some(()) = plant_junction(&slot.join(DEFAULT_WORK_FOLDER), &outside) else {
11272 eprintln!("skipped: this machine would not create a directory junction");
11273 return;
11274 };
11275
11276 let work = fs::symlink_metadata(slot.join(DEFAULT_WORK_FOLDER)).unwrap();
11280 assert!(is_link_like(&work), "a junction is a reparse point");
11281 let quarantine = scrub_slot_entries(&slot).unwrap_err();
11282 assert_eq!(quarantine.refusal, SlotRefusal::WorkNotADirectory);
11283 assert!(
11284 sentinel.exists(),
11285 "the deletion followed the junction out of the slot"
11286 );
11287 assert!(outside.is_dir());
11288
11289 let elsewhere = root.path().join("s2");
11292 fs::create_dir(&elsewhere).unwrap();
11293 fs::create_dir(elsewhere.join(DEFAULT_WORK_FOLDER)).unwrap();
11294 if plant_junction(&elsewhere.join("externals"), &outside).is_some() {
11295 scrub_slot_entries(&elsewhere).expect("an ordinary entry is removed, junction or not");
11296 verify_slot_scrubbed(&elsewhere).expect("and the slot verifies");
11297 assert!(sentinel.exists(), "the junction was followed, not unlinked");
11298 assert_eq!(entries_of(&elsewhere), only_the_job_workspace());
11299 }
11300 }
11301
11302 #[cfg(unix)]
11303 #[tokio::test]
11304 async fn a_substituted_work_directory_leaves_the_attempt_uncleaned_and_still_leased() {
11305 let harness = Harness::new(FakeGithubLifecycle::default(), Arc::new(PersistentDemand))
11306 .with_persistent_workspace(2);
11307 harness.ready().await;
11308 let first = harness.launch().await;
11309 let slot = harness.slot_path(1);
11310 harness.conclude(first.id);
11311
11312 let outside = harness._root.path().join("operator-data");
11313 fs::create_dir_all(&outside).unwrap();
11314 let sentinel = outside.join("do-not-delete.txt");
11315 fs::write(&sentinel, b"outside every approved root").unwrap();
11316 std::os::unix::fs::symlink(&outside, slot.join(DEFAULT_WORK_FOLDER)).unwrap();
11317
11318 harness
11319 .launcher
11320 .clean(first.id)
11321 .await
11322 .expect_err("a slot whose `_work` was substituted is quarantined");
11323 assert!(sentinel.exists());
11324
11325 let held = harness.attempt(first.id);
11326 assert_eq!(held.state(), AttemptState::Failed);
11327 assert!(held.holds_slot_lease());
11328
11329 let second = harness.launch().await;
11331 assert_eq!(second.workspace(), AttemptWorkspace::persistent_slot(nz(2)));
11332 }
11333
11334 #[test]
11335 fn a_slot_that_is_a_file_is_refused_and_a_slot_that_is_gone_is_not() {
11336 let root = tempfile::tempdir().unwrap();
11337 let occupied = root.path().join("s1");
11338 fs::write(&occupied, b"an operator's file").unwrap();
11339 verify_journalled_slot(&occupied, nz(1), None).expect("the path is the journalled slot");
11342 assert_eq!(
11343 slot_is_present(&occupied).unwrap_err().refusal,
11344 SlotRefusal::SlotNotADirectory
11345 );
11346 assert_eq!(fs::read_to_string(&occupied).unwrap(), "an operator's file");
11347
11348 assert!(!slot_is_present(&root.path().join("s2")).unwrap());
11351 let present = root.path().join("s3");
11352 fs::create_dir(&present).unwrap();
11353 assert!(slot_is_present(&present).unwrap());
11354 }
11355
11356 #[test]
11357 fn cleanup_dispatches_on_the_journalled_kind_and_not_on_what_the_directory_holds() {
11358 let root = tempfile::tempdir().unwrap();
11359
11360 let disposable = root.path().join("abcdef012345");
11364 fs::create_dir_all(disposable.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
11365 let ephemeral = RunnerAttempt::allocate(
11366 AttemptId::new_random(),
11367 fixtures::POLICY_ID,
11368 &disposable,
11369 fixtures::created_at(),
11370 );
11371 remove_materialized_package(&ephemeral).unwrap();
11372 assert!(!disposable.exists());
11373
11374 let slot = root.path().join("s1");
11376 fs::create_dir_all(slot.join(DEFAULT_WORK_FOLDER).join("repo")).unwrap();
11377 fs::create_dir_all(slot.join("bin")).unwrap();
11378 let persistent = RunnerAttempt::allocate_in(
11379 AttemptId::new_random(),
11380 fixtures::POLICY_ID,
11381 &slot,
11382 AttemptWorkspace::persistent_slot(nz(1)),
11383 fixtures::created_at(),
11384 );
11385 remove_materialized_package(&persistent).unwrap();
11386 assert_eq!(entries_of(&slot), only_the_job_workspace());
11387 assert!(slot.join(DEFAULT_WORK_FOLDER).join("repo").is_dir());
11388 }
11389
11390 #[test]
11391 fn every_slot_refusal_names_a_distinct_event_class_and_keeps_the_lease() {
11392 let refusals = [
11393 SlotRefusal::NotTheJournalledSlot,
11394 SlotRefusal::PolicyRootDisagrees,
11395 SlotRefusal::Containment,
11396 SlotRefusal::SlotNotADirectory,
11397 SlotRefusal::Enumeration,
11398 SlotRefusal::WorkNotADirectory,
11399 SlotRefusal::Deletion,
11400 SlotRefusal::Residue,
11401 ];
11402 let classes: BTreeSet<&str> = refusals.iter().map(|refusal| refusal.class()).collect();
11403 assert_eq!(
11404 classes.len(),
11405 refusals.len(),
11406 "an event class shared by two refusals tells an operator less than it appears to"
11407 );
11408 for refusal in refusals {
11409 assert!(
11412 refusal
11413 .class()
11414 .chars()
11415 .all(|c| c.is_ascii_lowercase() || c == '_'),
11416 "{}",
11417 refusal.class()
11418 );
11419 assert!(
11420 refusal.remediation().contains("slot lease"),
11421 "every refusal has to say the lease is still held: {}",
11422 refusal.class()
11423 );
11424 }
11425 }
11426
11427 #[test]
11428 fn copy_package_tree_copies_files_and_preserves_paths_with_spaces() {
11429 let root = tempfile::tempdir().unwrap();
11430 let source = root.path().join("source with spaces");
11431 let dest = root.path().join("dest with spaces");
11432
11433 fs::create_dir_all(&source).unwrap();
11434 fs::write(source.join("file1.txt"), b"hello").unwrap();
11435
11436 let nested = source.join("nested dir");
11437 fs::create_dir_all(&nested).unwrap();
11438 fs::write(nested.join("file2.txt"), b"world").unwrap();
11439
11440 let nested_work = nested.join(DEFAULT_WORK_FOLDER);
11442 fs::create_dir_all(&nested_work).unwrap();
11443 fs::write(nested_work.join("allowed.txt"), b"allowed").unwrap();
11444
11445 copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap();
11446
11447 assert_eq!(fs::read_to_string(dest.join("file1.txt")).unwrap(), "hello");
11448 assert_eq!(
11449 fs::read_to_string(dest.join("nested dir").join("file2.txt")).unwrap(),
11450 "world"
11451 );
11452 assert_eq!(
11453 fs::read_to_string(
11454 dest.join("nested dir")
11455 .join(DEFAULT_WORK_FOLDER)
11456 .join("allowed.txt")
11457 )
11458 .unwrap(),
11459 "allowed"
11460 );
11461 }
11462
11463 #[test]
11464 fn every_field_name_the_attempt_sink_emits_is_allowed() {
11465 use runner_manager_platform::logging::is_field_allowed;
11466
11467 for field in [
11469 "event",
11470 "attempt_id",
11471 "reason",
11472 "retry_in_ms",
11473 "duration_ms",
11474 "order_wait_ms",
11475 "lock_wait_ms",
11476 "turn_wait_ms",
11477 "materialize_ms",
11478 "package_wait_ms",
11479 "package_install_ms",
11480 "package_verify_ms",
11481 "package_copy_ms",
11482 "package_downloaded",
11483 "prepare_ms",
11484 "register_ms",
11485 "spawn_ms",
11486 ] {
11487 assert!(
11488 is_field_allowed(field),
11489 "`{field}` would be logged as `[redacted]`"
11490 );
11491 }
11492 }
11493
11494 #[cfg(unix)]
11495 fn package_entry(root: &Path, version: &str, manifest: &str) -> PathBuf {
11496 let entry = root.join("packages").join(version);
11497 fs::create_dir_all(entry.join("bin")).unwrap();
11498 fs::write(entry.join(crate::package::MANIFEST_FILE), manifest).unwrap();
11499 fs::write(entry.join("bin").join("Runner.Listener"), version).unwrap();
11500 entry
11501 }
11502
11503 #[cfg(unix)]
11505 fn listener_inode(seed: &Path) -> u64 {
11506 use std::os::unix::fs::MetadataExt as _;
11507 fs::metadata(seed.join("bin").join("Runner.Listener"))
11508 .unwrap()
11509 .ino()
11510 }
11511
11512 #[cfg(unix)]
11513 #[test]
11514 fn a_package_seed_is_built_once_and_reused_while_untouched() {
11515 let root = tempfile::tempdir().unwrap();
11516 let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
11517 let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
11518 let trusted = TrustedTrees::default();
11519
11520 let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
11521 assert_eq!(seed, seeds.join("2.336.0"));
11522 assert_eq!(
11523 fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
11524 "2.336.0"
11525 );
11526
11527 let built = listener_inode(&seed);
11528 assert_eq!(
11529 refresh_package_seed(&source, &seeds, &trusted).unwrap(),
11530 seed
11531 );
11532 assert_eq!(listener_inode(&seed), built, "an untouched seed is reused");
11533 }
11534
11535 #[cfg(unix)]
11536 #[test]
11537 fn a_tampered_or_unrecorded_seed_is_rebuilt() {
11538 let root = tempfile::tempdir().unwrap();
11539 let source = package_entry(root.path(), "2.336.0", "{\"digest\":\"a\"}");
11540 let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
11541 let trusted = TrustedTrees::default();
11542 let seed = refresh_package_seed(&source, &seeds, &trusted).unwrap();
11543
11544 fs::write(seed.join("bin").join("Runner.Listener"), b"planted").unwrap();
11546 refresh_package_seed(&source, &seeds, &trusted).unwrap();
11547 assert_eq!(
11548 fs::read_to_string(seed.join("bin").join("Runner.Listener")).unwrap(),
11549 "2.336.0",
11550 "a seed whose print moved is rebuilt from the cache entry"
11551 );
11552
11553 let old_listener = fs::File::open(seed.join("bin").join("Runner.Listener")).unwrap();
11557 let built = listener_inode(&seed);
11558 refresh_package_seed(&source, &seeds, &TrustedTrees::default()).unwrap();
11559 assert_ne!(
11560 listener_inode(&seed),
11561 built,
11562 "an unrecorded seed is rebuilt"
11563 );
11564 drop(old_listener);
11565 }
11566
11567 #[cfg(unix)]
11568 #[test]
11569 fn a_package_seed_is_rebuilt_for_a_new_entry_and_drops_every_other() {
11570 let root = tempfile::tempdir().unwrap();
11571 let seeds = root.path().join("runners").join(PACKAGE_SEED_DIR);
11572 let trusted = TrustedTrees::default();
11573 let old = package_entry(root.path(), "2.335.0", "{\"digest\":\"old\"}");
11574 refresh_package_seed(&old, &seeds, &trusted).unwrap();
11575 fs::create_dir_all(seeds.join(".staging-interrupted")).unwrap();
11576 fs::write(seeds.join("stray"), b"").unwrap();
11577
11578 let new = package_entry(root.path(), "2.336.0", "{\"digest\":\"new\"}");
11579 let seed = refresh_package_seed(&new, &seeds, &trusted).unwrap();
11580
11581 let mut left: Vec<_> = fs::read_dir(&seeds)
11582 .unwrap()
11583 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
11584 .collect();
11585 left.sort();
11586 assert_eq!(left, ["2.336.0"]);
11587
11588 fs::write(
11590 new.join(crate::package::MANIFEST_FILE),
11591 "{\"digest\":\"again\"}",
11592 )
11593 .unwrap();
11594 refresh_package_seed(&new, &seeds, &trusted).unwrap();
11595 assert_eq!(
11596 fs::read_to_string(seed.join(crate::package::MANIFEST_FILE)).unwrap(),
11597 "{\"digest\":\"again\"}",
11598 "a stale seed is rebuilt from the changed entry"
11599 );
11600 }
11601
11602 #[cfg(unix)]
11603 #[test]
11604 fn a_package_on_the_runtime_volume_is_its_own_seed() {
11605 let root = tempfile::tempdir().unwrap();
11606 let source = package_entry(root.path(), "2.336.0", "{}");
11607 let runtime = root.path().join("runners").join("attempt");
11608 fs::create_dir_all(&runtime).unwrap();
11609
11610 let trusted = TrustedTrees::default();
11611 assert_eq!(package_seed(&source, &runtime, &trusted).unwrap(), source);
11612 copy_package_tree(&source, &runtime, &trusted).unwrap();
11613 assert!(runtime.join("bin").join("Runner.Listener").exists());
11614 assert!(!root.path().join("runners").join(PACKAGE_SEED_DIR).exists());
11615 }
11616
11617 #[test]
11618 fn copy_package_tree_refuses_top_level_work_folder() {
11619 let root = tempfile::tempdir().unwrap();
11620 let source = root.path().join("source");
11621 let dest = root.path().join("dest");
11622
11623 fs::create_dir_all(&source).unwrap();
11624 fs::write(source.join("file1.txt"), b"hello").unwrap();
11625
11626 let top_work = source.join(DEFAULT_WORK_FOLDER);
11628 fs::create_dir_all(&top_work).unwrap();
11629
11630 let err = copy_package_tree(&source, &dest, &TrustedTrees::default()).unwrap_err();
11631 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
11632 }
11633}