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