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