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